Skip to content

Commit 669058c

Browse files
committed
load mixin files and index them asynchronously
1 parent 098fb59 commit 669058c

8 files changed

Lines changed: 218 additions & 10 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: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package lsp
22

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

79
"github.com/lets-cli/lets/internal/util"
810
"github.com/tliron/commonlog"
@@ -42,9 +44,43 @@ func (s *lspServer) setTrace(context *glsp.Context, params *lsp.SetTraceParams)
4244
return nil
4345
}
4446

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+
parser := newParser(s.log)
55+
path := normalizePath(uri)
56+
57+
for _, filename := range parser.getMixinFilenames(doc) {
58+
mixinPath := replacePathFilename(path, strings.TrimPrefix(filename, "-"))
59+
if !util.FileExists(mixinPath) {
60+
s.log.Debugf("mixin target does not exist: %s", mixinPath)
61+
continue
62+
}
63+
64+
data, err := os.ReadFile(mixinPath)
65+
if err != nil {
66+
s.log.Warningf("failed to read mixin %s: %v", mixinPath, err)
67+
continue
68+
}
69+
70+
mixinURI := pathToURI(mixinPath)
71+
text := string(data)
72+
73+
s.storage.AddDocument(mixinURI, text)
74+
s.index.IndexDocument(mixinURI, text)
75+
}
76+
}
77+
4578
func (s *lspServer) textDocumentDidOpen(context *glsp.Context, params *lsp.DidOpenTextDocumentParams) error {
4679
s.storage.AddDocument(params.TextDocument.URI, params.TextDocument.Text)
80+
4781
go s.index.IndexDocument(params.TextDocument.URI, params.TextDocument.Text)
82+
go s.loadMixins(params.TextDocument.URI)
83+
4884
return nil
4985
}
5086

@@ -53,7 +89,9 @@ func (s *lspServer) textDocumentDidChange(context *glsp.Context, params *lsp.Did
5389
switch c := change.(type) {
5490
case lsp.TextDocumentContentChangeEventWhole:
5591
s.storage.AddDocument(params.TextDocument.URI, c.Text)
92+
5693
go s.index.IndexDocument(params.TextDocument.URI, c.Text)
94+
go s.loadMixins(params.TextDocument.URI)
5795
case lsp.TextDocumentContentChangeEvent:
5896
return errors.New("incremental changes not supported")
5997
}
@@ -134,6 +172,7 @@ func (h *definitionHandler) findCommandDefinition(doc *string, params *lsp.Defin
134172
)
135173

136174
loc := locationForCommand(commandInfo.fileURI, commandInfo.position)
175+
137176
return []lsp.Location{loc}, nil
138177
}
139178

internal/lsp/handlers_test.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
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+
index: newIndex(logger),
43+
log: logger,
44+
}
45+
46+
mainURI := pathToURI(mainPath)
47+
server.storage.AddDocument(mainURI, mainDoc)
48+
server.loadMixins(mainURI)
49+
50+
baseMixinURI := pathToURI(baseMixinPath)
51+
localMixinURI := pathToURI(localMixinPath)
52+
53+
if got := server.storage.GetDocument(baseMixinURI); got == nil || *got != baseMixinDoc {
54+
t.Fatalf("storage for %s = %#v, want %q", baseMixinURI, got, baseMixinDoc)
55+
}
56+
57+
if got := server.storage.GetDocument(localMixinURI); got == nil || *got != localMixinDoc {
58+
t.Fatalf("storage for %s = %#v, want %q", localMixinURI, got, localMixinDoc)
59+
}
60+
61+
buildInfo, ok := server.index.findCommand("build")
62+
if !ok {
63+
t.Fatal("expected build command from mixin to be indexed")
64+
}
65+
66+
if buildInfo.fileURI != baseMixinURI {
67+
t.Fatalf("build indexed at %s, want %s", buildInfo.fileURI, baseMixinURI)
68+
}
69+
70+
testInfo, ok := server.index.findCommand("test")
71+
if !ok {
72+
t.Fatal("expected test command from mixin to be indexed")
73+
}
74+
75+
if testInfo.fileURI != localMixinURI {
76+
t.Fatalf("test indexed at %s, want %s", testInfo.fileURI, localMixinURI)
77+
}
78+
}
79+
80+
func TestLoadMixinsSkipsMissingFiles(t *testing.T) {
81+
dir := t.TempDir()
82+
83+
mainPath := filepath.Join(dir, "lets.yaml")
84+
mainURI := pathToURI(mainPath)
85+
86+
mainDoc := `mixins:
87+
- missing.yaml
88+
commands:
89+
release:
90+
cmd: echo release`
91+
92+
server := &lspServer{
93+
storage: newStorage(),
94+
index: newIndex(logger),
95+
log: logger,
96+
}
97+
98+
server.storage.AddDocument(mainURI, mainDoc)
99+
server.loadMixins(mainURI)
100+
101+
if got := server.storage.GetDocument(pathToURI(filepath.Join(dir, "missing.yaml"))); got != nil {
102+
t.Fatalf("expected missing mixin to not be stored, got %#v", got)
103+
}
104+
105+
if _, ok := server.index.findCommand("missing"); ok {
106+
t.Fatal("expected no indexed command for missing mixin")
107+
}
108+
}

internal/lsp/index.go

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,10 @@
11
package lsp
22

33
import (
4+
"maps"
45
"sync"
56

7+
"github.com/lets-cli/lets/internal/set"
68
"github.com/tliron/commonlog"
79
lsp "github.com/tliron/glsp/protocol_3_16"
810
)
@@ -18,14 +20,14 @@ type index struct {
1820
log commonlog.Logger
1921
mu sync.RWMutex
2022
commands map[string]commandInfo
21-
commandsByURI map[string]map[string]struct{}
23+
commandsByURI map[string]set.Set[string]
2224
}
2325

2426
func newIndex(log commonlog.Logger) *index {
2527
return &index{
2628
log: log,
2729
commands: make(map[string]commandInfo),
28-
commandsByURI: make(map[string]map[string]struct{}),
30+
commandsByURI: make(map[string]set.Set[string]),
2931
}
3032
}
3133

@@ -35,15 +37,14 @@ func (i *index) IndexDocument(uri string, doc string) {
3537
commands := parser.getCommands(&doc)
3638

3739
indexedCommands := make(map[string]commandInfo, len(commands))
38-
indexedNames := make(map[string]struct{}, len(commands))
40+
indexedNames := set.NewSet[string]()
3941

4042
for _, command := range commands {
4143
indexedCommands[command.name] = commandInfo{
4244
fileURI: uri,
4345
position: command.position,
4446
}
45-
// TODOL maybe use Set
46-
indexedNames[command.name] = struct{}{}
47+
indexedNames.Add(command.name)
4748
}
4849

4950
i.mu.Lock()
@@ -55,9 +56,7 @@ func (i *index) IndexDocument(uri string, doc string) {
5556
delete(i.commands, name)
5657
}
5758

58-
for name, info := range indexedCommands {
59-
i.commands[name] = info
60-
}
59+
maps.Copy(i.commands, indexedCommands)
6160

6261
if len(indexedNames) == 0 {
6362
delete(i.commandsByURI, uri)
@@ -72,5 +71,6 @@ func (i *index) findCommand(name string) (commandInfo, bool) {
7271
defer i.mu.RUnlock()
7372

7473
command, ok := i.commands[name]
74+
7575
return command, ok
7676
}

internal/lsp/storage.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
package lsp
22

3+
import "sync"
4+
35
type storage struct {
6+
mu sync.RWMutex
47
documents map[string]*string
58
}
69

@@ -11,9 +14,15 @@ func newStorage() *storage {
1114
}
1215

1316
func (s *storage) GetDocument(uri string) *string {
17+
s.mu.RLock()
18+
defer s.mu.RUnlock()
19+
1420
return s.documents[uri]
1521
}
1622

1723
func (s *storage) AddDocument(uri string, text string) {
24+
s.mu.Lock()
25+
defer s.mu.Unlock()
26+
1827
s.documents[uri] = &text
1928
}

internal/lsp/treesitter.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,31 @@ func (p *parser) extractFilenameFromMixins(document *string, position lsp.Positi
295295
return ""
296296
}
297297

298+
func (p *parser) getMixinFilenames(document *string) []string {
299+
var filenames []string
300+
301+
executeYAMLQuery(document, `
302+
(block_mapping_pair
303+
key: (flow_node) @key
304+
value: (block_node
305+
(block_sequence
306+
(block_sequence_item
307+
(flow_node
308+
(plain_scalar
309+
(string_scalar)) @value))))
310+
(#eq? @key "mixins")
311+
)
312+
`, func(capture ts.QueryCapture, docBytes []byte) bool {
313+
if capture.Name == "value" {
314+
filenames = append(filenames, capture.Node.Text(docBytes))
315+
}
316+
317+
return false
318+
})
319+
320+
return filenames
321+
}
322+
298323
func (p *parser) extractCommandReference(document *string, position lsp.Position) string {
299324
if commandName := p.extractDependsCommandReference(document, position); commandName != "" {
300325
p.log.Debugf("resolved command reference from depends: %q", commandName)
@@ -550,7 +575,7 @@ func (p *parser) getCurrentCommand(document *string, position lsp.Position) *Com
550575
return nil
551576
}
552577

553-
func (p *parser) findCommand(document *string, commandName string) *Command {
578+
func (p *parser) findCommand(document *string, commandName string) *Command { //nolint
554579
tree, docBytes, err := parseYAMLDocument(document)
555580
if err != nil {
556581
return nil

internal/lsp/treesitter_test.go

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -414,7 +414,14 @@ func TestFindCommandDefinitionFromAlias(t *testing.T) {
414414
<<: *docs
415415
cmd: npm start`
416416

417-
handler := definitionHandler{parser: newParser(logger)}
417+
idx := newIndex(logger)
418+
idx.IndexDocument("file:///tmp/lets.yaml", doc)
419+
420+
handler := definitionHandler{
421+
log: logger,
422+
parser: newParser(logger),
423+
index: idx,
424+
}
418425
params := &lsp.DefinitionParams{
419426
TextDocumentPositionParams: lsp.TextDocumentPositionParams{
420427
TextDocument: lsp.TextDocumentIdentifier{URI: "file:///tmp/lets.yaml"},
@@ -540,6 +547,24 @@ commands:
540547
}
541548
}
542549

550+
func TestGetMixinFilenames(t *testing.T) {
551+
doc := `shell: bash
552+
mixins:
553+
- lets.base.yaml
554+
- -lets.local.yaml
555+
commands:
556+
build:
557+
cmd: echo build`
558+
559+
p := newParser(logger)
560+
got := p.getMixinFilenames(&doc)
561+
want := []string{"lets.base.yaml", "-lets.local.yaml"}
562+
563+
if !reflect.DeepEqual(got, want) {
564+
t.Fatalf("getMixinFilenames() = %#v, want %#v", got, want)
565+
}
566+
}
567+
543568
func TestDependsHelpersWithBlockAndFlowSequences(t *testing.T) {
544569
doc := `shell: bash
545570
commands:

0 commit comments

Comments
 (0)