Skip to content

Commit 7e4e9ac

Browse files
authored
Merge pull request #328 from lets-cli/add-lsp-index
Add lsp index
2 parents 520e6d5 + 02d11fe commit 7e4e9ac

10 files changed

Lines changed: 418 additions & 32 deletions

File tree

docs/docs/changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ title: Changelog
1919
* `[Added]` Show background update notifications for interactive sessions, with Homebrew-aware guidance and `LETS_CHECK_UPDATE` opt-out.
2020
* `[Changed]` Centralize the `lets:` log prefix in the formatter and render debug messages in blue.
2121
* `[Fixed]` Resolve `go to definition` from YAML merge aliases such as `<<: *test` to the referenced command in `lets self lsp`.
22+
* `[Added]` Load local mixin files into LSP storage and command index so mixin commands are available for navigation.
2223

2324
## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59)
2425

internal/executor/dependency_error.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ func (e *DependencyError) FailureMessage() string {
4444

4545
func (e *DependencyError) TreeMessage() string {
4646
red := color.New(color.FgRed).SprintFunc()
47+
4748
var builder strings.Builder
4849

4950
builder.WriteString(dependencyTreeHeader)

internal/lsp/handlers.go

Lines changed: 74 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,12 @@ package lsp
22

33
import (
44
"errors"
5+
"os"
56
"slices"
7+
"strings"
68

79
"github.com/lets-cli/lets/internal/util"
10+
"github.com/tliron/commonlog"
811
"github.com/tliron/glsp"
912
lsp "github.com/tliron/glsp/protocol_3_16"
1013
)
@@ -41,8 +44,42 @@ func (s *lspServer) setTrace(context *glsp.Context, params *lsp.SetTraceParams)
4144
return nil
4245
}
4346

47+
// loadMixins reads local mixin files referenced by a document and adds them to storage and index.
48+
func (s *lspServer) loadMixins(uri string) {
49+
doc := s.storage.GetDocument(uri)
50+
if doc == nil {
51+
return
52+
}
53+
54+
path := normalizePath(uri)
55+
56+
for _, filename := range s.parser.getMixinFilenames(doc) {
57+
mixinPath := replacePathFilename(path, strings.TrimPrefix(filename, "-"))
58+
if !util.FileExists(mixinPath) {
59+
s.log.Debugf("mixin target does not exist: %s", mixinPath)
60+
continue
61+
}
62+
63+
data, err := os.ReadFile(mixinPath)
64+
if err != nil {
65+
s.log.Warningf("failed to read mixin %s: %v", mixinPath, err)
66+
continue
67+
}
68+
69+
mixinURI := pathToURI(mixinPath)
70+
text := string(data)
71+
72+
s.storage.AddDocument(mixinURI, text)
73+
s.index.IndexDocument(mixinURI, text)
74+
}
75+
}
76+
4477
func (s *lspServer) textDocumentDidOpen(context *glsp.Context, params *lsp.DidOpenTextDocumentParams) error {
4578
s.storage.AddDocument(params.TextDocument.URI, params.TextDocument.Text)
79+
80+
go s.index.IndexDocument(params.TextDocument.URI, params.TextDocument.Text)
81+
go s.loadMixins(params.TextDocument.URI)
82+
4683
return nil
4784
}
4885

@@ -51,6 +88,9 @@ func (s *lspServer) textDocumentDidChange(context *glsp.Context, params *lsp.Did
5188
switch c := change.(type) {
5289
case lsp.TextDocumentContentChangeEventWhole:
5390
s.storage.AddDocument(params.TextDocument.URI, c.Text)
91+
92+
go s.index.IndexDocument(params.TextDocument.URI, c.Text)
93+
go s.loadMixins(params.TextDocument.URI)
5494
case lsp.TextDocumentContentChangeEvent:
5595
return errors.New("incremental changes not supported")
5696
}
@@ -60,7 +100,9 @@ func (s *lspServer) textDocumentDidChange(context *glsp.Context, params *lsp.Did
60100
}
61101

62102
type definitionHandler struct {
103+
log commonlog.Logger
63104
parser *parser
105+
index *index
64106
}
65107

66108
func (h *definitionHandler) findMixinsDefinition(doc *string, params *lsp.DefinitionParams) (any, error) {
@@ -89,46 +131,48 @@ func (h *definitionHandler) findMixinsDefinition(doc *string, params *lsp.Defini
89131
}, nil
90132
}
91133

134+
func locationForCommand(uri string, position lsp.Position) lsp.Location {
135+
return lsp.Location{
136+
URI: uri,
137+
Range: lsp.Range{
138+
Start: lsp.Position{
139+
Line: position.Line,
140+
Character: 2, // TODO: do we have to assume indentation?
141+
},
142+
End: lsp.Position{
143+
Line: position.Line,
144+
Character: 2, // TODO: do we need + len ?
145+
},
146+
},
147+
}
148+
}
149+
92150
func (h *definitionHandler) findCommandDefinition(doc *string, params *lsp.DefinitionParams) (any, error) {
93151
path := normalizePath(params.TextDocument.URI)
94152

95153
commandName := h.parser.extractCommandReference(doc, params.Position)
96154
if commandName == "" {
97-
h.parser.log.Debugf("no command reference resolved at %s:%d:%d", path, params.Position.Line, params.Position.Character)
155+
h.log.Debugf("no command reference resolved at %s:%d:%d", path, params.Position.Line, params.Position.Character)
98156
return nil, nil
99157
}
100158

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)
159+
commandInfo, found := h.index.findCommand(commandName)
160+
if !found {
161+
h.log.Debugf("command reference %q did not match any local command", commandName)
104162
return nil, nil
105163
}
106164

107-
h.parser.log.Debugf(
165+
h.log.Debugf(
108166
"resolved command definition %q -> %s:%d:%d",
109167
commandName,
110168
path,
111-
command.position.Line,
112-
command.position.Character,
169+
commandInfo.position.Line,
170+
commandInfo.position.Character,
113171
)
114172

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
173+
loc := locationForCommand(commandInfo.fileURI, commandInfo.position)
174+
175+
return []lsp.Location{loc}, nil
132176
}
133177

134178
type completionHandler struct {
@@ -165,12 +209,13 @@ func (h *completionHandler) buildDependsCompletions(doc *string, params *lsp.Com
165209
// Returns: Location | []Location | []LocationLink | nil.
166210
func (s *lspServer) textDocumentDefinition(context *glsp.Context, params *lsp.DefinitionParams) (any, error) {
167211
definitionHandler := definitionHandler{
168-
parser: newParser(s.log),
212+
log: s.log,
213+
parser: s.parser,
214+
index: s.index,
169215
}
170216
doc := s.storage.GetDocument(params.TextDocument.URI)
171217

172-
p := newParser(s.log)
173-
positionType := p.getPositionType(doc, params.Position)
218+
positionType := s.parser.getPositionType(doc, params.Position)
174219
s.log.Debugf(
175220
"definition request uri=%s line=%d char=%d type=%s",
176221
normalizePath(params.TextDocument.URI),
@@ -193,12 +238,11 @@ func (s *lspServer) textDocumentDefinition(context *glsp.Context, params *lsp.De
193238
// Returns: []CompletionItem | CompletionList | nil.
194239
func (s *lspServer) textDocumentCompletion(context *glsp.Context, params *lsp.CompletionParams) (any, error) {
195240
completionHandler := completionHandler{
196-
parser: newParser(s.log),
241+
parser: s.parser,
197242
}
198243
doc := s.storage.GetDocument(params.TextDocument.URI)
199244

200-
p := newParser(s.log)
201-
switch p.getPositionType(doc, params.Position) {
245+
switch s.parser.getPositionType(doc, params.Position) {
202246
case PositionTypeDepends:
203247
return completionHandler.buildDependsCompletions(doc, params)
204248
default:

internal/lsp/handlers_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
package lsp
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
)
8+
9+
func TestLoadMixinsStoresAndIndexesMixinDocuments(t *testing.T) {
10+
dir := t.TempDir()
11+
12+
mainPath := filepath.Join(dir, "lets.yaml")
13+
baseMixinPath := filepath.Join(dir, "lets.base.yaml")
14+
localMixinPath := filepath.Join(dir, "lets.local.yaml")
15+
16+
baseMixinDoc := `commands:
17+
build:
18+
cmd: echo build`
19+
20+
localMixinDoc := `commands:
21+
test:
22+
cmd: echo test`
23+
24+
if err := os.WriteFile(baseMixinPath, []byte(baseMixinDoc), 0o644); err != nil {
25+
t.Fatalf("WriteFile(%s) error = %v", baseMixinPath, err)
26+
}
27+
28+
if err := os.WriteFile(localMixinPath, []byte(localMixinDoc), 0o644); err != nil {
29+
t.Fatalf("WriteFile(%s) error = %v", localMixinPath, err)
30+
}
31+
32+
mainDoc := `mixins:
33+
- lets.base.yaml
34+
- -lets.local.yaml
35+
commands:
36+
release:
37+
depends: [build, test]
38+
cmd: echo release`
39+
40+
server := &lspServer{
41+
storage: newStorage(),
42+
parser: newParser(logger),
43+
index: newIndex(logger),
44+
log: logger,
45+
}
46+
47+
mainURI := pathToURI(mainPath)
48+
server.storage.AddDocument(mainURI, mainDoc)
49+
server.loadMixins(mainURI)
50+
51+
baseMixinURI := pathToURI(baseMixinPath)
52+
localMixinURI := pathToURI(localMixinPath)
53+
54+
if got := server.storage.GetDocument(baseMixinURI); got == nil || *got != baseMixinDoc {
55+
t.Fatalf("storage for %s = %#v, want %q", baseMixinURI, got, baseMixinDoc)
56+
}
57+
58+
if got := server.storage.GetDocument(localMixinURI); got == nil || *got != localMixinDoc {
59+
t.Fatalf("storage for %s = %#v, want %q", localMixinURI, got, localMixinDoc)
60+
}
61+
62+
buildInfo, ok := server.index.findCommand("build")
63+
if !ok {
64+
t.Fatal("expected build command from mixin to be indexed")
65+
}
66+
67+
if buildInfo.fileURI != baseMixinURI {
68+
t.Fatalf("build indexed at %s, want %s", buildInfo.fileURI, baseMixinURI)
69+
}
70+
71+
testInfo, ok := server.index.findCommand("test")
72+
if !ok {
73+
t.Fatal("expected test command from mixin to be indexed")
74+
}
75+
76+
if testInfo.fileURI != localMixinURI {
77+
t.Fatalf("test indexed at %s, want %s", testInfo.fileURI, localMixinURI)
78+
}
79+
}
80+
81+
func TestLoadMixinsSkipsMissingFiles(t *testing.T) {
82+
dir := t.TempDir()
83+
84+
mainPath := filepath.Join(dir, "lets.yaml")
85+
mainURI := pathToURI(mainPath)
86+
87+
mainDoc := `mixins:
88+
- missing.yaml
89+
commands:
90+
release:
91+
cmd: echo release`
92+
93+
server := &lspServer{
94+
storage: newStorage(),
95+
parser: newParser(logger),
96+
index: newIndex(logger),
97+
log: logger,
98+
}
99+
100+
server.storage.AddDocument(mainURI, mainDoc)
101+
server.loadMixins(mainURI)
102+
103+
if got := server.storage.GetDocument(pathToURI(filepath.Join(dir, "missing.yaml"))); got != nil {
104+
t.Fatalf("expected missing mixin to not be stored, got %#v", got)
105+
}
106+
107+
if _, ok := server.index.findCommand("missing"); ok {
108+
t.Fatal("expected no indexed command for missing mixin")
109+
}
110+
}

internal/lsp/index.go

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

0 commit comments

Comments
 (0)