Skip to content

Commit 098fb59

Browse files
committed
Add index for commands and use it in depends definition resolution
1 parent 3687018 commit 098fb59

4 files changed

Lines changed: 200 additions & 24 deletions

File tree

internal/lsp/handlers.go

Lines changed: 32 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"slices"
66

77
"github.com/lets-cli/lets/internal/util"
8+
"github.com/tliron/commonlog"
89
"github.com/tliron/glsp"
910
lsp "github.com/tliron/glsp/protocol_3_16"
1011
)
@@ -43,6 +44,7 @@ func (s *lspServer) setTrace(context *glsp.Context, params *lsp.SetTraceParams)
4344

4445
func (s *lspServer) textDocumentDidOpen(context *glsp.Context, params *lsp.DidOpenTextDocumentParams) error {
4546
s.storage.AddDocument(params.TextDocument.URI, params.TextDocument.Text)
47+
go s.index.IndexDocument(params.TextDocument.URI, params.TextDocument.Text)
4648
return nil
4749
}
4850

@@ -51,6 +53,7 @@ func (s *lspServer) textDocumentDidChange(context *glsp.Context, params *lsp.Did
5153
switch c := change.(type) {
5254
case lsp.TextDocumentContentChangeEventWhole:
5355
s.storage.AddDocument(params.TextDocument.URI, c.Text)
56+
go s.index.IndexDocument(params.TextDocument.URI, c.Text)
5457
case lsp.TextDocumentContentChangeEvent:
5558
return errors.New("incremental changes not supported")
5659
}
@@ -60,7 +63,9 @@ func (s *lspServer) textDocumentDidChange(context *glsp.Context, params *lsp.Did
6063
}
6164

6265
type definitionHandler struct {
66+
log commonlog.Logger
6367
parser *parser
68+
index *index
6469
}
6570

6671
func (h *definitionHandler) findMixinsDefinition(doc *string, params *lsp.DefinitionParams) (any, error) {
@@ -89,46 +94,47 @@ func (h *definitionHandler) findMixinsDefinition(doc *string, params *lsp.Defini
8994
}, nil
9095
}
9196

97+
func locationForCommand(uri string, position lsp.Position) lsp.Location {
98+
return lsp.Location{
99+
URI: uri,
100+
Range: lsp.Range{
101+
Start: lsp.Position{
102+
Line: position.Line,
103+
Character: 2, // TODO: do we have to assume indentation?
104+
},
105+
End: lsp.Position{
106+
Line: position.Line,
107+
Character: 2, // TODO: do we need + len ?
108+
},
109+
},
110+
}
111+
}
112+
92113
func (h *definitionHandler) findCommandDefinition(doc *string, params *lsp.DefinitionParams) (any, error) {
93114
path := normalizePath(params.TextDocument.URI)
94115

95116
commandName := h.parser.extractCommandReference(doc, params.Position)
96117
if commandName == "" {
97-
h.parser.log.Debugf("no command reference resolved at %s:%d:%d", path, params.Position.Line, params.Position.Character)
118+
h.log.Debugf("no command reference resolved at %s:%d:%d", path, params.Position.Line, params.Position.Character)
98119
return nil, nil
99120
}
100121

101-
command := h.parser.findCommand(doc, commandName)
102-
if command == nil {
103-
h.parser.log.Debugf("command reference %q did not match any local command", commandName)
122+
commandInfo, found := h.index.findCommand(commandName)
123+
if !found {
124+
h.log.Debugf("command reference %q did not match any local command", commandName)
104125
return nil, nil
105126
}
106127

107-
h.parser.log.Debugf(
128+
h.log.Debugf(
108129
"resolved command definition %q -> %s:%d:%d",
109130
commandName,
110131
path,
111-
command.position.Line,
112-
command.position.Character,
132+
commandInfo.position.Line,
133+
commandInfo.position.Character,
113134
)
114135

115-
// TODO: theoretically we can have multiple commands with the same name if we have mixins
116-
return []lsp.Location{
117-
{
118-
// TODO: support commands in other files
119-
URI: params.TextDocument.URI,
120-
Range: lsp.Range{
121-
Start: lsp.Position{
122-
Line: command.position.Line,
123-
Character: 2, // TODO: do we have to assume indentation?
124-
},
125-
End: lsp.Position{
126-
Line: command.position.Line,
127-
Character: 2, // TODO: do we need + len ?
128-
},
129-
},
130-
},
131-
}, nil
136+
loc := locationForCommand(commandInfo.fileURI, commandInfo.position)
137+
return []lsp.Location{loc}, nil
132138
}
133139

134140
type completionHandler struct {
@@ -165,7 +171,9 @@ func (h *completionHandler) buildDependsCompletions(doc *string, params *lsp.Com
165171
// Returns: Location | []Location | []LocationLink | nil.
166172
func (s *lspServer) textDocumentDefinition(context *glsp.Context, params *lsp.DefinitionParams) (any, error) {
167173
definitionHandler := definitionHandler{
174+
log: s.log,
168175
parser: newParser(s.log),
176+
index: s.index,
169177
}
170178
doc := s.storage.GetDocument(params.TextDocument.URI)
171179

internal/lsp/index.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package lsp
2+
3+
import (
4+
"sync"
5+
6+
"github.com/tliron/commonlog"
7+
lsp "github.com/tliron/glsp/protocol_3_16"
8+
)
9+
10+
// TODO: maybe use Command struct ?
11+
type commandInfo struct {
12+
fileURI string
13+
// position stored at the time of indexing and may be stale
14+
position lsp.Position
15+
}
16+
17+
type index struct {
18+
log commonlog.Logger
19+
mu sync.RWMutex
20+
commands map[string]commandInfo
21+
commandsByURI map[string]map[string]struct{}
22+
}
23+
24+
func newIndex(log commonlog.Logger) *index {
25+
return &index{
26+
log: log,
27+
commands: make(map[string]commandInfo),
28+
commandsByURI: make(map[string]map[string]struct{}),
29+
}
30+
}
31+
32+
// IndexDocument extracts commands from a document and updates the index to reflect that document's current state.
33+
func (i *index) IndexDocument(uri string, doc string) {
34+
parser := newParser(i.log)
35+
commands := parser.getCommands(&doc)
36+
37+
indexedCommands := make(map[string]commandInfo, len(commands))
38+
indexedNames := make(map[string]struct{}, len(commands))
39+
40+
for _, command := range commands {
41+
indexedCommands[command.name] = commandInfo{
42+
fileURI: uri,
43+
position: command.position,
44+
}
45+
// TODOL maybe use Set
46+
indexedNames[command.name] = struct{}{}
47+
}
48+
49+
i.mu.Lock()
50+
defer i.mu.Unlock()
51+
52+
i.log.Debugf("Indexed %d commands in file %s", len(indexedNames), uri)
53+
54+
for name := range i.commandsByURI[uri] {
55+
delete(i.commands, name)
56+
}
57+
58+
for name, info := range indexedCommands {
59+
i.commands[name] = info
60+
}
61+
62+
if len(indexedNames) == 0 {
63+
delete(i.commandsByURI, uri)
64+
return
65+
}
66+
67+
i.commandsByURI[uri] = indexedNames
68+
}
69+
70+
func (i *index) findCommand(name string) (commandInfo, bool) {
71+
i.mu.RLock()
72+
defer i.mu.RUnlock()
73+
74+
command, ok := i.commands[name]
75+
return command, ok
76+
}

internal/lsp/index_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
package lsp
2+
3+
import (
4+
"reflect"
5+
"testing"
6+
)
7+
8+
func TestIndexDocumentStoresCommands(t *testing.T) {
9+
doc := `commands:
10+
build:
11+
cmd: echo build
12+
test:
13+
cmd: echo test`
14+
15+
idx := newIndex(logger)
16+
idx.IndexDocument("file:///tmp/lets.yaml", doc)
17+
18+
tests := []struct {
19+
name string
20+
want commandInfo
21+
}{
22+
{
23+
name: "build",
24+
want: commandInfo{
25+
fileURI: "file:///tmp/lets.yaml",
26+
position: pos(1, 2),
27+
},
28+
},
29+
{
30+
name: "test",
31+
want: commandInfo{
32+
fileURI: "file:///tmp/lets.yaml",
33+
position: pos(3, 2),
34+
},
35+
},
36+
}
37+
38+
for _, tt := range tests {
39+
t.Run(tt.name, func(t *testing.T) {
40+
got, ok := idx.findCommand(tt.name)
41+
if !ok {
42+
t.Fatalf("findCommand(%q) did not find command", tt.name)
43+
}
44+
45+
if !reflect.DeepEqual(got, tt.want) {
46+
t.Fatalf("findCommand(%q) = %#v, want %#v", tt.name, got, tt.want)
47+
}
48+
})
49+
}
50+
}
51+
52+
func TestIndexDocumentReplacesCommandsForSameDocument(t *testing.T) {
53+
originalDoc := `commands:
54+
build:
55+
cmd: echo build
56+
test:
57+
cmd: echo test`
58+
59+
updatedDoc := `commands:
60+
release:
61+
depends: [build]
62+
cmd: echo release`
63+
64+
idx := newIndex(logger)
65+
idx.IndexDocument("file:///tmp/lets.yaml", originalDoc)
66+
idx.IndexDocument("file:///tmp/lets.yaml", updatedDoc)
67+
68+
if _, ok := idx.findCommand("build"); ok {
69+
t.Fatal("expected build to be removed after reindex")
70+
}
71+
72+
if _, ok := idx.findCommand("test"); ok {
73+
t.Fatal("expected test to be removed after reindex")
74+
}
75+
76+
got, ok := idx.findCommand("release")
77+
if !ok {
78+
t.Fatal("expected release to be indexed after reindex")
79+
}
80+
81+
want := commandInfo{
82+
fileURI: "file:///tmp/lets.yaml",
83+
position: pos(1, 2),
84+
}
85+
86+
if !reflect.DeepEqual(got, want) {
87+
t.Fatalf("findCommand(%q) = %#v, want %#v", "release", got, want)
88+
}
89+
}

internal/lsp/server.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ type lspServer struct {
1818
version string
1919
server *server.Server
2020
storage *storage
21+
index *index
2122
log commonlog.Logger
2223
}
2324

@@ -54,6 +55,7 @@ func Run(ctx context.Context, version string) error {
5455
version: version,
5556
server: glspServer,
5657
storage: newStorage(),
58+
index: newIndex(logger),
5759
log: logger,
5860
}
5961

@@ -65,6 +67,7 @@ func Run(ctx context.Context, version string) error {
6567
handler.TextDocumentDidChange = lspServer.textDocumentDidChange
6668
handler.TextDocumentDefinition = lspServer.textDocumentDefinition
6769
handler.TextDocumentCompletion = lspServer.textDocumentCompletion
70+
// TODO: add onDelete
6871

6972
return lspServer.Run()
7073
}

0 commit comments

Comments
 (0)