Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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 @@ -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)

Expand Down
13 changes: 4 additions & 9 deletions internal/lsp/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,17 +86,12 @@ 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 == "" {
commandName := h.parser.extractCommandReference(doc, params.Position)
if commandName == "" {
return nil, nil
}

word := wordUnderCursor(line, &params.Position)
if word == "" {
return nil, nil
}

command := h.parser.findCommand(doc, word)
command := h.parser.findCommand(doc, commandName)
if command == nil {
return nil, nil
}
Expand Down Expand Up @@ -163,7 +158,7 @@ func (s *lspServer) textDocumentDefinition(context *glsp.Context, params *lsp.De
switch p.getPositionType(doc, params.Position) {
case PositionTypeMixins:
return definitionHandler.findMixinsDefinition(doc, params)
case PositionTypeDepends:
case PositionTypeDepends, PositionTypeCommandAlias:
return definitionHandler.findCommandDefinition(doc, params)
default:
return nil, nil
Expand Down
148 changes: 148 additions & 0 deletions internal/lsp/treesitter.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ type PositionType int
const (
PositionTypeMixins PositionType = iota
PositionTypeDepends
PositionTypeCommandAlias
PositionTypeNone
)

Expand Down Expand Up @@ -115,6 +116,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
Expand Down Expand Up @@ -229,6 +232,47 @@ func (p *parser) inDependsPosition(document *string, position lsp.Position) bool
return false
}

func (p *parser) inCommandAliasPosition(document *string, position lsp.Position) bool {
tree, docBytes, err := parseYAMLDocument(document)
if err != nil {
return false
}
defer tree.Release()

query, err := ts.NewQuery(`
(block_mapping_pair
key: (flow_node) @keymerge
value: (flow_node(alias) @alias)
(#eq? @keymerge "<<")
)
`, yamlLanguage)
if err != nil {
return false
}

root := tree.RootNode()
if root == nil {
return false
}

matches := query.Exec(root, yamlLanguage, docBytes)

for {
match, ok := matches.NextMatch()
if !ok {
break
}

for _, capture := range match.Captures {
if capture.Name == "alias" && isCursorWithinNode(capture.Node, position) {
return true
}
}
}

return false
}

func (p *parser) extractFilenameFromMixins(document *string, position lsp.Position) string {
tree, docBytes, err := parseYAMLDocument(document)
if err != nil {
Expand Down Expand Up @@ -275,6 +319,110 @@ 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 != "" {
return commandName
}

return p.extractAliasCommandReference(document, position)
}

func (p *parser) extractDependsCommandReference(document *string, position lsp.Position) string {
tree, docBytes, err := parseYAMLDocument(document)
if err != nil {
return ""
}
defer tree.Release()

query, err := ts.NewQuery(`
(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")
)
`, 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
}

for _, capture := range match.Captures {
if capture.Name == "reference" && isCursorWithinNode(capture.Node, position) {
return capture.Node.Text(docBytes)
}
}
}

return ""
}

func (p *parser) extractAliasCommandReference(document *string, position lsp.Position) string {
tree, docBytes, err := parseYAMLDocument(document)
if err != nil {
return ""
}
defer tree.Release()

query, err := ts.NewQuery(`
(block_mapping_pair
key: (flow_node) @keymerge
value: (flow_node(alias) @reference)
(#eq? @keymerge "<<")
)
`, 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
}

for _, capture := range match.Captures {
if capture.Name != "reference" || !isCursorWithinNode(capture.Node, position) {
continue
}

return strings.TrimPrefix(capture.Node.Text(docBytes), "*")
}
}

return ""
}

type Command struct {
name string
// TODO: maybe range will be more appropriate
Expand Down
110 changes: 110 additions & 0 deletions internal/lsp/treesitter_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -336,6 +368,84 @@ 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:
test: &test
cmd: echo Test
run-test:
<<: *test
cmd: echo Run`

handler := definitionHandler{parser: newParser(logger)}
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 TestMixinsHelpersWithMultipleItems(t *testing.T) {
blockDoc := `shell: bash
mixins:
Expand Down
Loading