diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 561211b3..56e0aff6 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -18,6 +18,7 @@ title: Changelog * `[Added]` Add `lets self doc` command to open the online documentation in a browser. * `[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`. ## [0.0.59](https://github.com/lets-cli/lets/releases/tag/v0.0.59) diff --git a/internal/cli/cli.go b/internal/cli/cli.go index b7d5a688..1783adf9 100644 --- a/internal/cli/cli.go +++ b/internal/cli/cli.go @@ -136,6 +136,7 @@ func Main(version string, buildDate string) int { if errors.As(err, &depErr) { executor.PrintDependencyTree(depErr, os.Stderr) log.Errorf("%s", depErr.FailureMessage()) + return getExitCode(err, 1) } diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index d53ad04d..7882fa51 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -68,15 +68,19 @@ func (h *definitionHandler) findMixinsDefinition(doc *string, params *lsp.Defini filename := h.parser.extractFilenameFromMixins(doc, params.Position) if filename == "" { + h.parser.log.Debugf("no mixin filename resolved at %s:%d:%d", path, params.Position.Line, params.Position.Character) return nil, nil } absFilename := replacePathFilename(path, filename) if !util.FileExists(absFilename) { + h.parser.log.Debugf("mixin target does not exist: %s", absFilename) return nil, nil } + h.parser.log.Debugf("resolved mixin definition %q -> %s", filename, absFilename) + return []lsp.Location{ { URI: pathToURI(absFilename), @@ -86,21 +90,28 @@ func (h *definitionHandler) findMixinsDefinition(doc *string, params *lsp.Defini } func (h *definitionHandler) findCommandDefinition(doc *string, params *lsp.DefinitionParams) (any, error) { - line := getLine(doc, params.Position.Line) - if line == "" { - return nil, nil - } + path := normalizePath(params.TextDocument.URI) - word := wordUnderCursor(line, ¶ms.Position) - if word == "" { + 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) return nil, nil } - command := h.parser.findCommand(doc, word) + command := h.parser.findCommand(doc, commandName) if command == nil { + h.parser.log.Debugf("command reference %q did not match any local command", commandName) return nil, nil } + h.parser.log.Debugf( + "resolved command definition %q -> %s:%d:%d", + commandName, + path, + command.position.Line, + command.position.Character, + ) + // TODO: theoretically we can have multiple commands with the same name if we have mixins return []lsp.Location{ { @@ -159,13 +170,22 @@ func (s *lspServer) textDocumentDefinition(context *glsp.Context, params *lsp.De doc := s.storage.GetDocument(params.TextDocument.URI) p := newParser(s.log) - - switch p.getPositionType(doc, params.Position) { + positionType := p.getPositionType(doc, params.Position) + s.log.Debugf( + "definition request uri=%s line=%d char=%d type=%s", + normalizePath(params.TextDocument.URI), + params.Position.Line, + params.Position.Character, + positionType, + ) + + switch positionType { case PositionTypeMixins: return definitionHandler.findMixinsDefinition(doc, params) - case PositionTypeDepends: + case PositionTypeDepends, PositionTypeCommandAlias: return definitionHandler.findCommandDefinition(doc, params) default: + s.log.Debugf("definition request ignored: unsupported cursor position") return nil, nil } } diff --git a/internal/lsp/server.go b/internal/lsp/server.go index f60d6a1f..873f0002 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -3,6 +3,7 @@ package lsp import ( "context" + "github.com/lets-cli/lets/internal/env" "github.com/tliron/commonlog" _ "github.com/tliron/commonlog/simple" lsp "github.com/tliron/glsp/protocol_3_16" @@ -24,8 +25,22 @@ func (s *lspServer) Run() error { return s.server.RunStdio() } +func lspLogVerbosity() int { + verbosity := 1 + + defer func() { + _ = recover() + }() + + if env.DebugLevel() > 0 { + verbosity = 2 + } + + return verbosity +} + func Run(ctx context.Context, version string) error { - commonlog.Configure(1, nil) + commonlog.Configure(lspLogVerbosity(), nil) logger := commonlog.GetLogger(lsName) logger.Infof("Lets LSP server starting %s", version) diff --git a/internal/lsp/treesitter.go b/internal/lsp/treesitter.go index 81630b42..537f0f7e 100644 --- a/internal/lsp/treesitter.go +++ b/internal/lsp/treesitter.go @@ -14,9 +14,23 @@ type PositionType int const ( PositionTypeMixins PositionType = iota PositionTypeDepends + PositionTypeCommandAlias PositionTypeNone ) +func (p PositionType) String() string { + switch p { + case PositionTypeMixins: + return "mixins" + case PositionTypeDepends: + return "depends" + case PositionTypeCommandAlias: + return "command_alias" + default: + return "none" + } +} + var yamlLanguage = grammars.YamlLanguage() func isCursorWithinNode(node *ts.Node, pos lsp.Position) bool { @@ -57,47 +71,39 @@ func parseYAMLDocument(document *string) (*ts.Tree, []byte, error) { return tree, docBytes, nil } -func getLine(document *string, line uint32) string { - lines := strings.Split(*document, "\n") - if line >= uint32(len(lines)) { - return "" +func executeYAMLQuery(document *string, queryText string, visit func(capture ts.QueryCapture, docBytes []byte) bool) bool { + tree, docBytes, err := parseYAMLDocument(document) + if err != nil { + return false } + defer tree.Release() - return lines[line] -} - -// position. -func wordUnderCursor(text string, position *lsp.Position) string { - if len(text) == 0 { - return "" + query, err := ts.NewQuery(queryText, yamlLanguage) + if err != nil { + return false } - character := position.Character - - if character >= uint32(len(text)) { - return "" + root := tree.RootNode() + if root == nil { + return false } - if text[character] == ' ' { - return "" - } + matches := query.Exec(root, yamlLanguage, docBytes) - // Find word boundaries - start := position.Character - for start > 0 && isWordChar(text[start-1]) { - start-- - } + for { + match, ok := matches.NextMatch() + if !ok { + break + } - end := position.Character - for end < uint32(len(text)) && isWordChar(text[end]) { - end++ + for _, capture := range match.Captures { + if visit(capture, docBytes) { + return true + } + } } - return text[start:end] -} - -func isWordChar(c byte) bool { - return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_' || c == '-' + return false } type parser struct { @@ -115,6 +121,8 @@ func (p *parser) getPositionType(document *string, position lsp.Position) Positi return PositionTypeMixins } else if p.inDependsPosition(document, position) { return PositionTypeDepends + } else if p.inCommandAliasPosition(document, position) { + return PositionTypeCommandAlias } return PositionTypeNone @@ -229,6 +237,18 @@ func (p *parser) inDependsPosition(document *string, position lsp.Position) bool return false } +func (p *parser) inCommandAliasPosition(document *string, position lsp.Position) bool { + return executeYAMLQuery(document, ` + (block_mapping_pair + key: (flow_node) @keymerge + value: (flow_node(alias) @alias) + (#eq? @keymerge "<<") + ) + `, func(capture ts.QueryCapture, _ []byte) bool { + return capture.Name == "alias" && isCursorWithinNode(capture.Node, position) + }) +} + func (p *parser) extractFilenameFromMixins(document *string, position lsp.Position) string { tree, docBytes, err := parseYAMLDocument(document) if err != nil { @@ -275,6 +295,147 @@ func (p *parser) extractFilenameFromMixins(document *string, position lsp.Positi return "" } +func (p *parser) extractCommandReference(document *string, position lsp.Position) string { + if commandName := p.extractDependsCommandReference(document, position); commandName != "" { + p.log.Debugf("resolved command reference from depends: %q", commandName) + return commandName + } + + commandName := p.extractAliasCommandReference(document, position) + if commandName != "" { + p.log.Debugf("resolved command reference from alias: %q", commandName) + } + + return commandName +} + +func (p *parser) extractDependsCommandReference(document *string, position lsp.Position) string { + var commandName string + + executeYAMLQuery(document, ` + (block_mapping_pair + key: (flow_node) @keydepends + value: [ + (flow_node + (flow_sequence + (flow_node + (plain_scalar + (string_scalar)) @reference))) + (block_node + (block_sequence + (block_sequence_item + (flow_node + (plain_scalar + (string_scalar)) @reference)))) + ] + (#eq? @keydepends "depends") + ) + `, func(capture ts.QueryCapture, docBytes []byte) bool { + if capture.Name == "reference" && isCursorWithinNode(capture.Node, position) { + commandName = capture.Node.Text(docBytes) + return true + } + + return false + }) + + return commandName +} + +func (p *parser) extractAliasCommandReference(document *string, position lsp.Position) string { + var anchorName string + + executeYAMLQuery(document, ` + (block_mapping_pair + key: (flow_node) @keymerge + value: (flow_node(alias) @reference) + (#eq? @keymerge "<<") + ) + `, func(capture ts.QueryCapture, docBytes []byte) bool { + if capture.Name == "reference" && isCursorWithinNode(capture.Node, position) { + anchorName = strings.TrimPrefix(capture.Node.Text(docBytes), "*") + return true + } + + return false + }) + + if anchorName == "" { + return "" + } + + commandName := p.findCommandNameByAnchor(document, anchorName) + if commandName == "" { + p.log.Debugf("alias anchor %q did not match any local command anchor", anchorName) + return "" + } + + p.log.Debugf("resolved alias anchor %q to command %q", anchorName, commandName) + + return commandName +} + +func (p *parser) findCommandNameByAnchor(document *string, anchorName string) string { + tree, docBytes, err := parseYAMLDocument(document) + if err != nil { + return "" + } + defer tree.Release() + + query, err := ts.NewQuery(` + (block_mapping_pair + key: (flow_node(plain_scalar(string_scalar)) @commands) + value: (block_node + (block_mapping + (block_mapping_pair + key: (flow_node + (plain_scalar + (string_scalar)) @cmd_key) + value: (block_node + (anchor + (anchor_name) @anchor_name))))) + (#eq? @commands "commands") + ) + `, yamlLanguage) + if err != nil { + return "" + } + + root := tree.RootNode() + if root == nil { + return "" + } + + matches := query.Exec(root, yamlLanguage, docBytes) + + for { + match, ok := matches.NextMatch() + if !ok { + break + } + + var ( + commandName string + matchedAnchor string + ) + + for _, capture := range match.Captures { + switch capture.Name { + case "cmd_key": + commandName = capture.Node.Text(docBytes) + case "anchor_name": + matchedAnchor = capture.Node.Text(docBytes) + } + } + + if matchedAnchor == anchorName { + return commandName + } + } + + return "" +} + type Command struct { name string // TODO: maybe range will be more appropriate diff --git a/internal/lsp/treesitter_test.go b/internal/lsp/treesitter_test.go index 516abd09..20e8da5d 100644 --- a/internal/lsp/treesitter_test.go +++ b/internal/lsp/treesitter_test.go @@ -197,6 +197,38 @@ commands: } } +func TestDetectCommandAliasPosition(t *testing.T) { + doc := `commands: + test: &test + cmd: echo Test + run-test: + <<: *test + cmd: echo Run` + + tests := []struct { + pos lsp.Position + want bool + }{ + {pos: pos(4, 7), want: false}, + {pos: pos(4, 8), want: true}, + {pos: pos(4, 10), want: true}, + {pos: pos(4, 13), want: true}, + {pos: pos(5, 8), want: false}, + } + + p := newParser(logger) + for i, tt := range tests { + got := p.inCommandAliasPosition(&doc, tt.pos) + if got != tt.want { + t.Errorf("case %d: expected %v, actual %v", i, tt.want, got) + } + } + + if got := p.getPositionType(&doc, pos(4, 10)); got != PositionTypeCommandAlias { + t.Fatalf("expected PositionTypeCommandAlias, got %v", got) + } +} + func TestGetCommands(t *testing.T) { doc := `shell: bash mixins: @@ -336,6 +368,105 @@ commands: } } +func TestExtractCommandReference(t *testing.T) { + doc := `commands: + build: + cmd: echo Build + test: &test + depends: + - build + cmd: echo Test + run-test: + <<: *test + depends: [build, test] + cmd: echo Run` + + tests := []struct { + name string + pos lsp.Position + want string + }{ + {name: "block depends item", pos: pos(5, 8), want: "build"}, + {name: "merge alias star", pos: pos(8, 8), want: "test"}, + {name: "merge alias name", pos: pos(8, 10), want: "test"}, + {name: "flow depends first item", pos: pos(9, 14), want: "build"}, + {name: "flow depends second item", pos: pos(9, 21), want: "test"}, + {name: "outside reference", pos: pos(10, 10), want: ""}, + } + + p := newParser(logger) + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := p.extractCommandReference(&doc, tt.pos) + if got != tt.want { + t.Fatalf("extractCommandReference() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestFindCommandDefinitionFromAlias(t *testing.T) { + doc := `commands: + publish-docs: &docs + work_dir: docs + cmd: npm run doc:deploy + run-docs: + <<: *docs + cmd: npm start` + + handler := definitionHandler{parser: newParser(logger)} + params := &lsp.DefinitionParams{ + TextDocumentPositionParams: lsp.TextDocumentPositionParams{ + TextDocument: lsp.TextDocumentIdentifier{URI: "file:///tmp/lets.yaml"}, + Position: pos(5, 10), + }, + } + + got, err := handler.findCommandDefinition(&doc, params) + if err != nil { + t.Fatalf("findCommandDefinition() error = %v", err) + } + + locations, ok := got.([]lsp.Location) + if !ok { + t.Fatalf("findCommandDefinition() type = %T, want []lsp.Location", got) + } + + want := []lsp.Location{ + { + URI: "file:///tmp/lets.yaml", + Range: lsp.Range{ + Start: pos(1, 2), + End: pos(1, 2), + }, + }, + } + + if !reflect.DeepEqual(locations, want) { + t.Fatalf("findCommandDefinition() = %#v, want %#v", locations, want) + } +} + +func TestFindCommandNameByAnchor(t *testing.T) { + doc := `commands: + publish-docs: &docs + work_dir: docs + cmd: npm run doc:deploy + run-docs: + <<: *docs + cmd: npm start` + + p := newParser(logger) + + if got := p.findCommandNameByAnchor(&doc, "docs"); got != "publish-docs" { + t.Fatalf("findCommandNameByAnchor() = %q, want %q", got, "publish-docs") + } + + if got := p.findCommandNameByAnchor(&doc, "missing"); got != "" { + t.Fatalf("findCommandNameByAnchor() = %q, want empty", got) + } +} + func TestMixinsHelpersWithMultipleItems(t *testing.T) { blockDoc := `shell: bash mixins: @@ -563,28 +694,3 @@ commands: t.Fatalf("extractDependsValues() = %#v, want %#v", got, want) } } - -func TestWordUnderCursor(t *testing.T) { - tests := []struct { - line string - position lsp.Position - want string - }{ - {"test word here", lsp.Position{Character: 0}, "test"}, - {"test word here", lsp.Position{Character: 2}, "test"}, - {"test word here", lsp.Position{Character: 5}, "word"}, - {"test word here", lsp.Position{Character: 10}, "here"}, - {"test-word_123", lsp.Position{Character: 5}, "test-word_123"}, - {"", lsp.Position{Character: 0}, ""}, - {"test", lsp.Position{Character: 10}, ""}, - {"test word", lsp.Position{Character: 4}, ""}, - {" test ", lsp.Position{Character: 3}, "test"}, - } - - for i, tt := range tests { - got := wordUnderCursor(tt.line, &tt.position) - if got != tt.want { - t.Errorf("case %d: got %q, want %q", i, got, tt.want) - } - } -}