diff --git a/docs/docs/changelog.md b/docs/docs/changelog.md index 9d2d266..978b962 100644 --- a/docs/docs/changelog.md +++ b/docs/docs/changelog.md @@ -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`. +* `[Fixed]` Resolve `go to definition` from command references such as `ref: build` 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) diff --git a/docs/docs/ide_support.md b/docs/docs/ide_support.md index b0a64ae..80d757c 100644 --- a/docs/docs/ide_support.md +++ b/docs/docs/ide_support.md @@ -33,7 +33,7 @@ Lsp support includes: - [x] Goto definition - Navigate to definitions of mixins files - - Navigate to definitions of command from `depends` + - Navigate to definitions of command from `depends`, `ref`, and YAML merge aliases - [x] Completion - Complete commands in depends - [ ] Diagnostics @@ -122,4 +122,3 @@ servers = { }, } ``` - diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 394e6c4..ed5f306 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -227,7 +227,7 @@ func (s *lspServer) textDocumentDefinition(context *glsp.Context, params *lsp.De switch positionType { case PositionTypeMixins: return definitionHandler.findMixinsDefinition(doc, params) - case PositionTypeDepends, PositionTypeCommandAlias: + case PositionTypeDepends, PositionTypeCommandAlias, PositionTypeRef: return definitionHandler.findCommandDefinition(doc, params) default: s.log.Debugf("definition request ignored: unsupported cursor position") diff --git a/internal/lsp/treesitter.go b/internal/lsp/treesitter.go index b3fe5b9..8357366 100644 --- a/internal/lsp/treesitter.go +++ b/internal/lsp/treesitter.go @@ -15,6 +15,7 @@ const ( PositionTypeMixins PositionType = iota PositionTypeDepends PositionTypeCommandAlias + PositionTypeRef PositionTypeNone ) @@ -26,6 +27,8 @@ func (p PositionType) String() string { return "depends" case PositionTypeCommandAlias: return "command_alias" + case PositionTypeRef: + return "ref" default: return "none" } @@ -123,6 +126,8 @@ func (p *parser) getPositionType(document *string, position lsp.Position) Positi return PositionTypeDepends } else if p.inCommandAliasPosition(document, position) { return PositionTypeCommandAlias + } else if p.inRefPosition(document, position) { + return PositionTypeRef } return PositionTypeNone @@ -249,6 +254,20 @@ func (p *parser) inCommandAliasPosition(document *string, position lsp.Position) }) } +func (p *parser) inRefPosition(document *string, position lsp.Position) bool { + return executeYAMLQuery(document, ` + (block_mapping_pair + key: (flow_node) @keyref + value: (flow_node + (plain_scalar + (string_scalar)) @reference) + (#eq? @keyref "ref") + ) + `, func(capture ts.QueryCapture, _ []byte) bool { + return capture.Name == "reference" && isCursorWithinNode(capture.Node, position) + }) +} + func (p *parser) extractFilenameFromMixins(document *string, position lsp.Position) string { tree, docBytes, err := parseYAMLDocument(document) if err != nil { @@ -326,9 +345,14 @@ func (p *parser) extractCommandReference(document *string, position lsp.Position return commandName } - commandName := p.extractAliasCommandReference(document, position) - if commandName != "" { + if commandName := p.extractAliasCommandReference(document, position); commandName != "" { p.log.Debugf("resolved command reference from alias: %q", commandName) + return commandName + } + + commandName := p.extractRefCommandReference(document, position) + if commandName != "" { + p.log.Debugf("resolved command reference from ref: %q", commandName) } return commandName @@ -400,6 +424,29 @@ func (p *parser) extractAliasCommandReference(document *string, position lsp.Pos return commandName } +func (p *parser) extractRefCommandReference(document *string, position lsp.Position) string { + var commandName string + + executeYAMLQuery(document, ` + (block_mapping_pair + key: (flow_node) @keyref + value: (flow_node + (plain_scalar + (string_scalar)) @reference) + (#eq? @keyref "ref") + ) + `, 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) findCommandNameByAnchor(document *string, anchorName string) string { tree, docBytes, err := parseYAMLDocument(document) if err != nil { diff --git a/internal/lsp/treesitter_test.go b/internal/lsp/treesitter_test.go index 52f4124..69e3f2e 100644 --- a/internal/lsp/treesitter_test.go +++ b/internal/lsp/treesitter_test.go @@ -229,6 +229,37 @@ func TestDetectCommandAliasPosition(t *testing.T) { } } +func TestDetectRefPosition(t *testing.T) { + doc := `commands: + build: + cmd: echo Build + deploy: + ref: build + args: --prod` + + tests := []struct { + pos lsp.Position + want bool + }{ + {pos: pos(4, 7), want: false}, + {pos: pos(4, 9), want: true}, + {pos: pos(4, 12), want: true}, + {pos: pos(5, 8), want: false}, + } + + p := newParser(logger) + for i, tt := range tests { + got := p.inRefPosition(&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, 11)); got != PositionTypeRef { + t.Fatalf("expected PositionTypeRef, got %v", got) + } +} + func TestGetCommands(t *testing.T) { doc := `shell: bash mixins: @@ -379,7 +410,9 @@ func TestExtractCommandReference(t *testing.T) { run-test: <<: *test depends: [build, test] - cmd: echo Run` + cmd: echo Run + deploy: + ref: build` tests := []struct { name string @@ -392,6 +425,7 @@ func TestExtractCommandReference(t *testing.T) { {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: ""}, + {name: "ref command value", pos: pos(12, 10), want: "build"}, } p := newParser(logger) @@ -454,6 +488,54 @@ func TestFindCommandDefinitionFromAlias(t *testing.T) { } } +func TestFindCommandDefinitionFromRef(t *testing.T) { + doc := `commands: + build: + cmd: echo Build + deploy: + ref: build + args: --prod` + + idx := newIndex(logger) + idx.IndexDocument("file:///tmp/lets.yaml", doc) + + handler := definitionHandler{ + log: logger, + parser: newParser(logger), + index: idx, + } + params := &lsp.DefinitionParams{ + TextDocumentPositionParams: lsp.TextDocumentPositionParams{ + TextDocument: lsp.TextDocumentIdentifier{URI: "file:///tmp/lets.yaml"}, + Position: pos(4, 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