Skip to content
Merged
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
1 change: 1 addition & 0 deletions docs/docs/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ title: Changelog
* `[Added]` Show background update notifications for interactive sessions, with Homebrew-aware guidance and `LETS_CHECK_UPDATE` opt-out.
* `[Changed]` Centralize the `lets:` log prefix in the formatter and render debug messages in blue.
* `[Fixed]` Resolve `go to definition` from YAML merge aliases such as `<<: *test` to the referenced command in `lets self lsp`.
* `[Added]` Load local mixin files into LSP storage and command index so mixin commands are available for navigation.

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

Expand Down
1 change: 1 addition & 0 deletions internal/executor/dependency_error.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ func (e *DependencyError) FailureMessage() string {

func (e *DependencyError) TreeMessage() string {
red := color.New(color.FgRed).SprintFunc()

var builder strings.Builder

builder.WriteString(dependencyTreeHeader)
Expand Down
104 changes: 74 additions & 30 deletions internal/lsp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,12 @@ package lsp

import (
"errors"
"os"
"slices"
"strings"

"github.com/lets-cli/lets/internal/util"
"github.com/tliron/commonlog"
"github.com/tliron/glsp"
lsp "github.com/tliron/glsp/protocol_3_16"
)
Expand Down Expand Up @@ -41,8 +44,42 @@ func (s *lspServer) setTrace(context *glsp.Context, params *lsp.SetTraceParams)
return nil
}

// loadMixins reads local mixin files referenced by a document and adds them to storage and index.
func (s *lspServer) loadMixins(uri string) {
doc := s.storage.GetDocument(uri)
if doc == nil {
return
}

path := normalizePath(uri)

for _, filename := range s.parser.getMixinFilenames(doc) {
mixinPath := replacePathFilename(path, strings.TrimPrefix(filename, "-"))
if !util.FileExists(mixinPath) {
s.log.Debugf("mixin target does not exist: %s", mixinPath)
continue
}

data, err := os.ReadFile(mixinPath)
if err != nil {
s.log.Warningf("failed to read mixin %s: %v", mixinPath, err)
continue
}

mixinURI := pathToURI(mixinPath)
text := string(data)

s.storage.AddDocument(mixinURI, text)
s.index.IndexDocument(mixinURI, text)
}
}

func (s *lspServer) textDocumentDidOpen(context *glsp.Context, params *lsp.DidOpenTextDocumentParams) error {
s.storage.AddDocument(params.TextDocument.URI, params.TextDocument.Text)

go s.index.IndexDocument(params.TextDocument.URI, params.TextDocument.Text)
go s.loadMixins(params.TextDocument.URI)

return nil
}

Expand All @@ -51,6 +88,9 @@ func (s *lspServer) textDocumentDidChange(context *glsp.Context, params *lsp.Did
switch c := change.(type) {
case lsp.TextDocumentContentChangeEventWhole:
s.storage.AddDocument(params.TextDocument.URI, c.Text)

go s.index.IndexDocument(params.TextDocument.URI, c.Text)
go s.loadMixins(params.TextDocument.URI)
case lsp.TextDocumentContentChangeEvent:
return errors.New("incremental changes not supported")
}
Expand All @@ -60,7 +100,9 @@ func (s *lspServer) textDocumentDidChange(context *glsp.Context, params *lsp.Did
}

type definitionHandler struct {
log commonlog.Logger
parser *parser
index *index
}

func (h *definitionHandler) findMixinsDefinition(doc *string, params *lsp.DefinitionParams) (any, error) {
Expand Down Expand Up @@ -89,46 +131,48 @@ func (h *definitionHandler) findMixinsDefinition(doc *string, params *lsp.Defini
}, nil
}

func locationForCommand(uri string, position lsp.Position) lsp.Location {
return lsp.Location{
URI: uri,
Range: lsp.Range{
Start: lsp.Position{
Line: position.Line,
Character: 2, // TODO: do we have to assume indentation?
},
End: lsp.Position{
Line: position.Line,
Character: 2, // TODO: do we need + len ?
},
},
}
}

func (h *definitionHandler) findCommandDefinition(doc *string, params *lsp.DefinitionParams) (any, error) {
path := normalizePath(params.TextDocument.URI)

commandName := h.parser.extractCommandReference(doc, params.Position)
if commandName == "" {
h.parser.log.Debugf("no command reference resolved at %s:%d:%d", path, params.Position.Line, params.Position.Character)
h.log.Debugf("no command reference resolved at %s:%d:%d", path, params.Position.Line, params.Position.Character)
return nil, nil
}

command := h.parser.findCommand(doc, commandName)
if command == nil {
h.parser.log.Debugf("command reference %q did not match any local command", commandName)
commandInfo, found := h.index.findCommand(commandName)
if !found {
h.log.Debugf("command reference %q did not match any local command", commandName)
return nil, nil
}

h.parser.log.Debugf(
h.log.Debugf(
"resolved command definition %q -> %s:%d:%d",
commandName,
path,
command.position.Line,
command.position.Character,
commandInfo.position.Line,
commandInfo.position.Character,
)

// TODO: theoretically we can have multiple commands with the same name if we have mixins
return []lsp.Location{
{
// TODO: support commands in other files
URI: params.TextDocument.URI,
Range: lsp.Range{
Start: lsp.Position{
Line: command.position.Line,
Character: 2, // TODO: do we have to assume indentation?
},
End: lsp.Position{
Line: command.position.Line,
Character: 2, // TODO: do we need + len ?
},
},
},
}, nil
loc := locationForCommand(commandInfo.fileURI, commandInfo.position)

return []lsp.Location{loc}, nil
}

type completionHandler struct {
Expand Down Expand Up @@ -165,12 +209,13 @@ func (h *completionHandler) buildDependsCompletions(doc *string, params *lsp.Com
// Returns: Location | []Location | []LocationLink | nil.
func (s *lspServer) textDocumentDefinition(context *glsp.Context, params *lsp.DefinitionParams) (any, error) {
definitionHandler := definitionHandler{
parser: newParser(s.log),
log: s.log,
parser: s.parser,
index: s.index,
}
doc := s.storage.GetDocument(params.TextDocument.URI)

p := newParser(s.log)
positionType := p.getPositionType(doc, params.Position)
positionType := s.parser.getPositionType(doc, params.Position)
s.log.Debugf(
"definition request uri=%s line=%d char=%d type=%s",
normalizePath(params.TextDocument.URI),
Expand All @@ -193,12 +238,11 @@ func (s *lspServer) textDocumentDefinition(context *glsp.Context, params *lsp.De
// Returns: []CompletionItem | CompletionList | nil.
func (s *lspServer) textDocumentCompletion(context *glsp.Context, params *lsp.CompletionParams) (any, error) {
completionHandler := completionHandler{
parser: newParser(s.log),
parser: s.parser,
}
doc := s.storage.GetDocument(params.TextDocument.URI)

p := newParser(s.log)
switch p.getPositionType(doc, params.Position) {
switch s.parser.getPositionType(doc, params.Position) {
case PositionTypeDepends:
return completionHandler.buildDependsCompletions(doc, params)
default:
Expand Down
110 changes: 110 additions & 0 deletions internal/lsp/handlers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package lsp

import (
"os"
"path/filepath"
"testing"
)

func TestLoadMixinsStoresAndIndexesMixinDocuments(t *testing.T) {
dir := t.TempDir()

mainPath := filepath.Join(dir, "lets.yaml")
baseMixinPath := filepath.Join(dir, "lets.base.yaml")
localMixinPath := filepath.Join(dir, "lets.local.yaml")

baseMixinDoc := `commands:
build:
cmd: echo build`

localMixinDoc := `commands:
test:
cmd: echo test`

if err := os.WriteFile(baseMixinPath, []byte(baseMixinDoc), 0o644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", baseMixinPath, err)
}

if err := os.WriteFile(localMixinPath, []byte(localMixinDoc), 0o644); err != nil {
t.Fatalf("WriteFile(%s) error = %v", localMixinPath, err)
}

mainDoc := `mixins:
- lets.base.yaml
- -lets.local.yaml
commands:
release:
depends: [build, test]
cmd: echo release`

server := &lspServer{
storage: newStorage(),
parser: newParser(logger),
index: newIndex(logger),
log: logger,
}

mainURI := pathToURI(mainPath)
server.storage.AddDocument(mainURI, mainDoc)
server.loadMixins(mainURI)

baseMixinURI := pathToURI(baseMixinPath)
localMixinURI := pathToURI(localMixinPath)

if got := server.storage.GetDocument(baseMixinURI); got == nil || *got != baseMixinDoc {
t.Fatalf("storage for %s = %#v, want %q", baseMixinURI, got, baseMixinDoc)
}

if got := server.storage.GetDocument(localMixinURI); got == nil || *got != localMixinDoc {
t.Fatalf("storage for %s = %#v, want %q", localMixinURI, got, localMixinDoc)
}

buildInfo, ok := server.index.findCommand("build")
if !ok {
t.Fatal("expected build command from mixin to be indexed")
}

if buildInfo.fileURI != baseMixinURI {
t.Fatalf("build indexed at %s, want %s", buildInfo.fileURI, baseMixinURI)
}

testInfo, ok := server.index.findCommand("test")
if !ok {
t.Fatal("expected test command from mixin to be indexed")
}

if testInfo.fileURI != localMixinURI {
t.Fatalf("test indexed at %s, want %s", testInfo.fileURI, localMixinURI)
}
}

func TestLoadMixinsSkipsMissingFiles(t *testing.T) {
dir := t.TempDir()

mainPath := filepath.Join(dir, "lets.yaml")
mainURI := pathToURI(mainPath)

mainDoc := `mixins:
- missing.yaml
commands:
release:
cmd: echo release`

server := &lspServer{
storage: newStorage(),
parser: newParser(logger),
index: newIndex(logger),
log: logger,
}

server.storage.AddDocument(mainURI, mainDoc)
server.loadMixins(mainURI)

if got := server.storage.GetDocument(pathToURI(filepath.Join(dir, "missing.yaml"))); got != nil {
t.Fatalf("expected missing mixin to not be stored, got %#v", got)
}

if _, ok := server.index.findCommand("missing"); ok {
t.Fatal("expected no indexed command for missing mixin")
}
}
77 changes: 77 additions & 0 deletions internal/lsp/index.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package lsp

import (
"maps"
"sync"

"github.com/lets-cli/lets/internal/set"
"github.com/tliron/commonlog"
lsp "github.com/tliron/glsp/protocol_3_16"
)

// TODO: maybe use Command struct ?
type commandInfo struct {
fileURI string
// position stored at the time of indexing and may be stale
position lsp.Position
}

type index struct {
log commonlog.Logger
parser *parser
mu sync.RWMutex
commands map[string]commandInfo
commandsByURI map[string]set.Set[string]
}

func newIndex(log commonlog.Logger) *index {
return &index{
log: log,
parser: newParser(log),
commands: make(map[string]commandInfo),
commandsByURI: make(map[string]set.Set[string]),
}
}

// IndexDocument extracts commands from a document and updates the index to reflect that document's current state.
func (i *index) IndexDocument(uri string, doc string) {
commands := i.parser.getCommands(&doc)

indexedCommands := make(map[string]commandInfo, len(commands))
indexedNames := set.NewSet[string]()

for _, command := range commands {
indexedCommands[command.name] = commandInfo{
fileURI: uri,
position: command.position,
}
indexedNames.Add(command.name)
}

i.mu.Lock()
defer i.mu.Unlock()

i.log.Debugf("Indexed %d commands in file %s", len(indexedNames), uri)

for name := range i.commandsByURI[uri] {
delete(i.commands, name)
}

maps.Copy(i.commands, indexedCommands)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Indexing logic breaks when the same command name exists in multiple files.

This relies on command names being globally unique. With two files defining the same command, maps.Copy overwrites commands[name] while commandsByURI for the original file still lists that command. On reindexing the first file, the cleanup loop deletes commands[name], removing the entry that actually belongs to the second file.

If multiple files can legitimately define the same command, the index should be keyed by (uri, name) or similar. If not, we should explicitly enforce/document global uniqueness and guard against duplicates rather than silently corrupting the index state.


if len(indexedNames) == 0 {
delete(i.commandsByURI, uri)
return
}

i.commandsByURI[uri] = indexedNames
}

func (i *index) findCommand(name string) (commandInfo, bool) {
i.mu.RLock()
defer i.mu.RUnlock()

command, ok := i.commands[name]

return command, ok
}
Loading
Loading