Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions examples/statemachine/call_hierarchy.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Copyright 2026 TypeFox GmbH
// This program and the accompanying materials are made available under the
// terms of the MIT License, which is available in the project root.

package statemachine

import (
"context"

core "typefox.dev/fastbelt"
"typefox.dev/fastbelt/server"
"typefox.dev/fastbelt/util/service"
"typefox.dev/fastbelt/workspace"
"typefox.dev/lsp"
)

// stateMachineCallHierarchyProvider treats States and Transitions as a call
// graph: a State is like a function, and each Transition to another State is
// like a call site.
//
// - HandleOutgoingCallsRequest returns the state's own Transitions() -
// "calls made by this function".
// - HandleIncomingCallsRequest scans every other state's transitions for
// ones that target this state - "callers of this function".
//
// HandlePrepareCallHierarchyRequest uses the shared server.NameFinder service
// to resolve the cursor to a state.
type stateMachineCallHierarchyProvider struct {
sc *service.Container
}

var _ server.CallHierarchyProvider = (*stateMachineCallHierarchyProvider)(nil)

func (p *stateMachineCallHierarchyProvider) document(uri lsp.DocumentURI) *core.Document {
documentManager := service.MustGet[workspace.DocumentManager](p.sc)
return documentManager.Get(core.ParseURI(string(uri)))
}

func (p *stateMachineCallHierarchyProvider) HandlePrepareCallHierarchyRequest(ctx context.Context, params *lsp.CallHierarchyPrepareParams) ([]lsp.CallHierarchyItem, error) {
doc := p.document(params.TextDocument.URI)
if doc == nil || doc.Root == nil {
return nil, nil
}

offset := doc.TextDoc.OffsetAt(params.Position)
first, second := doc.Tokens.SearchOffset2(offset)
if first == nil {
return nil, nil
}

nameFinder := service.MustGet[server.NameFinder](p.sc)
foundName := nameFinder.Find(ctx, first, second)
if foundName.Target == nil {
return nil, nil
}

state, ok := foundName.Target.Owner().(State)
if !ok {
return nil, nil
}

return []lsp.CallHierarchyItem{stateHierarchyItem(doc, state)}, nil
}

func (p *stateMachineCallHierarchyProvider) HandleIncomingCallsRequest(ctx context.Context, params *lsp.CallHierarchyIncomingCallsParams) ([]lsp.CallHierarchyIncomingCall, error) {
doc := p.document(params.Item.URI)
if doc == nil || doc.Root == nil {
return nil, nil
}
target, ok := findStateByName(doc, params.Item.Name)
if !ok {
return nil, nil
}

var calls []lsp.CallHierarchyIncomingCall
for node := range core.AllNodes(doc.Root) {
source, ok := node.(State)
if !ok {
continue
}

var fromRanges []lsp.Range
for _, transition := range source.Transitions() {
stateRef := transition.State()
if stateRef == nil {
continue
}
if resolved := stateRef.Ref(ctx); resolved == target {
fromRanges = append(fromRanges, stateRef.TextRange().LspRange(doc.TextDoc))
}
}
if len(fromRanges) > 0 {
calls = append(calls, lsp.CallHierarchyIncomingCall{
From: stateHierarchyItem(doc, source),
FromRanges: fromRanges,
})
}
}
return calls, nil
}

func (p *stateMachineCallHierarchyProvider) HandleOutgoingCallsRequest(ctx context.Context, params *lsp.CallHierarchyOutgoingCallsParams) ([]lsp.CallHierarchyOutgoingCall, error) {
doc := p.document(params.Item.URI)
if doc == nil || doc.Root == nil {
return nil, nil
}
source, ok := findStateByName(doc, params.Item.Name)
if !ok {
return nil, nil
}

// Multiple transitions can target the same state; group them into a
// single outgoing call entry with multiple FromRanges rather than
// duplicating the "to" item once per transition.
var order []State
byTarget := map[State][]lsp.Range{}
for _, transition := range source.Transitions() {
stateRef := transition.State()
if stateRef == nil {
continue
}
target := stateRef.Ref(ctx)
if target == nil {
continue
}
if _, seen := byTarget[target]; !seen {
order = append(order, target)
}
byTarget[target] = append(byTarget[target], stateRef.TextRange().LspRange(doc.TextDoc))
}

var calls []lsp.CallHierarchyOutgoingCall
for _, target := range order {
calls = append(calls, lsp.CallHierarchyOutgoingCall{
To: stateHierarchyItem(doc, target),
FromRanges: byTarget[target],
})
}
return calls, nil
}

// stateHierarchyItem builds the LSP representation of a State. The
// selection range narrows down to just the state's own name (via
// NameToken), while Range covers the full "state ... end" block.
func stateHierarchyItem(doc *core.Document, state State) lsp.CallHierarchyItem {
blockRange := state.TextRange().LspRange(doc.TextDoc)
selectionRange := blockRange
if nameToken := state.NameToken(); nameToken != nil {
selectionRange = nameToken.Range.LspRange(doc.TextDoc)
}
return lsp.CallHierarchyItem{
Name: state.Name(),
Kind: lsp.Class,
URI: doc.TextDoc.URI(),
Range: blockRange,
SelectionRange: selectionRange,
}
}

// findStateByName re-locates a State by name within doc. HandleIncomingCallsRequest
// and HandleOutgoingCallsRequest only receive the lsp.CallHierarchyItem produced
// earlier by HandlePrepareCallHierarchyRequest (name, URI, ranges), not the
// original AST node, so the node must be found again. State names are unique
// within a single statemachine document, so matching by name is sufficient.
func findStateByName(doc *core.Document, name string) (State, bool) {
for node := range core.AllNodes(doc.Root) {
if state, ok := node.(State); ok && state.Name() == name {
return state, true
}
}
return nil, false
}
131 changes: 131 additions & 0 deletions examples/statemachine/code_lens.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
// Copyright 2026 TypeFox GmbH
// This program and the accompanying materials are made available under the
// terms of the MIT License, which is available in the project root.

package statemachine

import (
"context"
"encoding/json"
"fmt"

core "typefox.dev/fastbelt"
"typefox.dev/fastbelt/server"
"typefox.dev/fastbelt/util/service"
"typefox.dev/fastbelt/workspace"
"typefox.dev/lsp"
)

// stateMachineCodeLensProvider shows above each Event/Command declaration
// how many places reference it, reusing the shared ReferencesFinder service
// rather than re-implementing reference counting.
//
// States intentionally don't get a lens here: their transition count is
// already shown inline via stateMachineInlayHintProvider, and showing the
// same number in two places on the same line would be redundant.
//
// Reference counting happens lazily, via server.ResolvingCodeLensProvider:
// HandleCodeLensRequest only returns the range and a small Data payload
// identifying the declaration, deferring the actual (ReferencesFinder-based)
// count to HandleCodeLensResolveRequest, which the client only calls for
// lenses currently visible in the viewport. This avoids counting references
// for declarations that are scrolled out of view.
//
// CodeLensProvider has no default implementation to embed or delegate to
// (see server.CodeLensProvider's doc comment), so this implements the full
// interface directly.
type stateMachineCodeLensProvider struct {
sc *service.Container
}

var _ server.ResolvingCodeLensProvider = (*stateMachineCodeLensProvider)(nil)

// codeLensData identifies the declaration a lens belongs to.
type codeLensData struct {
URI string `json:"uri"`
Kind string `json:"kind"` // "event" or "command"
Name string `json:"name"`
}

func (p *stateMachineCodeLensProvider) HandleCodeLensRequest(ctx context.Context, params *lsp.CodeLensParams) ([]lsp.CodeLens, error) {
documentManager := service.MustGet[workspace.DocumentManager](p.sc)
uri := core.ParseURI(string(params.TextDocument.URI))
doc := documentManager.Get(uri)
if doc == nil || doc.Root == nil {
return nil, nil
}

var lenses []lsp.CodeLens
for node := range core.AllNodes(doc.Root) {
switch n := node.(type) {
case *EventImpl:
lenses = append(lenses, lsp.CodeLens{
Range: n.TextRange().LspRange(doc.TextDoc),
Data: codeLensData{URI: string(params.TextDocument.URI), Kind: "event", Name: n.Name()},
})
case *CommandImpl:
lenses = append(lenses, lsp.CodeLens{
Range: n.TextRange().LspRange(doc.TextDoc),
Data: codeLensData{URI: string(params.TextDocument.URI), Kind: "command", Name: n.Name()},
})
}
}

return lenses, nil
}

func (p *stateMachineCodeLensProvider) HandleCodeLensResolveRequest(ctx context.Context, lens *lsp.CodeLens) (*lsp.CodeLens, error) {
raw, err := json.Marshal(lens.Data)
if err != nil {
return lens, nil
}
var data codeLensData
if err := json.Unmarshal(raw, &data); err != nil {
return lens, nil
}

documentManager := service.MustGet[workspace.DocumentManager](p.sc)
doc := documentManager.Get(core.ParseURI(data.URI))
if doc == nil || doc.Root == nil {
return lens, nil
}

target := findEventOrCommandByName(doc, data.Kind, data.Name)
if target == nil {
return lens, nil
}

referencesFinder := service.MustGet[server.ReferencesFinder](p.sc)
count := countReferences(ctx, referencesFinder, target)
lens.Command = &lsp.Command{Title: fmt.Sprintf("%d reference(s)", count)}
return lens, nil
}

// findEventOrCommandByName re-locates an Event or Command by name within
// doc. HandleCodeLensResolveRequest only receives the Data payload set
// earlier by HandleCodeLensRequest, not the original AST node, so the node
// must be found again. Event/Command names are unique within a single
// statemachine document, so matching by name is sufficient.
func findEventOrCommandByName(doc *core.Document, kind, name string) core.AstNode {
for node := range core.AllNodes(doc.Root) {
switch n := node.(type) {
case *EventImpl:
if kind == "event" && n.Name() == name {
return n
}
case *CommandImpl:
if kind == "command" && n.Name() == name {
return n
}
}
}
return nil
}

func countReferences(ctx context.Context, finder server.ReferencesFinder, target core.AstNode) int {
count := 0
for range finder.Find(ctx, target, server.FindReferencesOptions{IncludeDeclaration: false}) {
count++
}
return count
}
63 changes: 63 additions & 0 deletions examples/statemachine/inlay_hint.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
// Copyright 2026 TypeFox GmbH
// This program and the accompanying materials are made available under the
// terms of the MIT License, which is available in the project root.

package statemachine

import (
"context"
"fmt"

core "typefox.dev/fastbelt"
"typefox.dev/fastbelt/linking"
"typefox.dev/fastbelt/server"
"typefox.dev/fastbelt/util/service"
"typefox.dev/fastbelt/workspace"
"typefox.dev/lsp"
)

// stateMachineInlayHintProvider shows the number of outgoing transitions
// inline right after each State's own name, e.g. "state off: 1 transition(s)".
//
// This surfaces the same underlying data as the Code Lens example
// (stateMachineCodeLensProvider's "N transition(s)" lens), just through a
// different LSP mechanism: an inline decoration attached to the name itself
// instead of a clickable lens above the declaration.
type stateMachineInlayHintProvider struct {
sc *service.Container
}

var _ server.InlayHintProvider = (*stateMachineInlayHintProvider)(nil)

func (p *stateMachineInlayHintProvider) HandleInlayHintRequest(ctx context.Context, params *lsp.InlayHintParams) ([]lsp.InlayHint, error) {
documentManager := service.MustGet[workspace.DocumentManager](p.sc)
doc := documentManager.Get(core.ParseURI(string(params.TextDocument.URI)))
if doc == nil || doc.Root == nil {
return nil, nil
}

var hints []lsp.InlayHint
for node := range server.NodesInRange(doc, params.Range) {
state, ok := node.(*StateImpl)
if !ok {
continue
}

// linking.Name resolves the node's "Name" attribute generically (works
// for any grammar-generated type with a Name field), giving us the text
// range of just the identifier itself, not the whole "state ... end" block.
name := linking.Name(state)
if name == nil {
continue
}

count := len(state.Transitions())
hints = append(hints, lsp.InlayHint{
Position: name.TextRange().LspRange(doc.TextDoc).End,
Label: []lsp.InlayHintLabelPart{{Value: fmt.Sprintf(": %d transition(s)", count)}},
Kind: lsp.Type,
})
}

return hints, nil
}
3 changes: 3 additions & 0 deletions examples/statemachine/services.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ func CreateLspServices(setup func(*service.Container)) *service.Container {
sc := service.NewContainer()
SetupServices(sc)
SetupGeneratedServerServices(sc)
service.Put[server.CodeLensProvider](sc, &stateMachineCodeLensProvider{sc: sc})
service.Put[server.CallHierarchyProvider](sc, &stateMachineCallHierarchyProvider{sc: sc})
service.Put[server.InlayHintProvider](sc, &stateMachineInlayHintProvider{sc: sc})
server.SetupDefaultServices(sc)
if setup != nil {
setup(sc)
Expand Down
Loading