Skip to content

Commit cfc3f6f

Browse files
authored
feat: rename, references, and go-to-definition for locals (#141)
* feat: add rename support for locals, dependency labels, and include labels Add LSP textDocument/rename and textDocument/prepareRename for three kinds of identifiers in Terragrunt configs: - Local variables (`locals { name = ... }` and `local.name` references) - Dependency block labels (`dependency "name" {}` and `dependency.name.outputs.X`) - Include block labels (`include "name" {}` and `include.name.X`) Rename is module-aware: edits propagate across all `.hcl` files in the same folder, honoring unsaved editor buffers via `s.Configs`. Stack and values files are excluded. Implementation is AST-based via `hclsyntax.VisitAll` over `ScopeTraversalExpr` and `IndexedAST.Locals`/`Body.Blocks` for definitions. Prepare-rename returns a range covering only the bare identifier (placeholder excludes `local.` prefix and label quotes). New names are validated against the HCL identifier grammar. * feat: add textDocument/references for locals, dependency labels, and include labels Reuse the AST walker and target-resolution introduced for rename to power find-all-references on the same three identifier kinds. Honors the LSP `includeDeclaration` flag. The `rename.Occurrence` type gains an `IsDefinition` flag so references can filter out the declaration site when the client requests references only. * feat: extend go-to-definition to local, dependency, and include traversal references `textDocument/definition` previously only fired when the cursor was on an `include "X" {}` label or a `dependency "X" { config_path = ... }` attribute. Extend it to also fire on traversal references: - `local.X` jumps to the `X = ...` declaration in the locals block (same file or any sibling .hcl file in the module folder). - `include.X.Y` jumps to the file referenced by `include "X" { path = ... }` (existing path-resolution behavior, now reachable from references too). - `dependency.X.outputs.Y` jumps to the dependent unit's `terragrunt.hcl` (existing path-resolution behavior, now reachable from references too). Implementation reuses `ast.MinReferenceTraversalLen` and the rename package's `FindAllOccurrences` for the local-declaration lookup. The Unit-only file-type guard is replaced with `canRename` so go-to-definition also works on auxiliary HCL files (e.g., `common.hcl`). * chore: defer include-block support to a follow-up PR Remove rename, references, and go-to-definition support for include block labels and `include.X.Y` traversal references. Keeping this PR focused on locals and dependency labels. Include support has additional complexity worth landing in a dedicated PR: - The Terragrunt parser emits "Unsupported attribute" / "Unknown variable" diagnostics for `include.X` traversals at LS time, which need a separate diagnostic-filter change in `internal/tg/parse.go`. - `find_in_parent_folders` failures cascade into multiple diagnostic kinds, expanding the filter scope. - Include label rename also requires updating the `path` attribute that references the parent file, which is out of scope for the symmetric identifier-rename pattern this PR ships. * chore: defer dependency-label support to a follow-up PR Remove rename, references, and go-to-definition support for `dependency "X" {}` block labels and `dependency.X.outputs.Y` traversal references. Keeping this PR focused on locals. Existing dependency support (cursor on `dependency` block label or on `config_path` attribute jumping to the dependent unit) is unaffected and continues to work via `ast.GetNodeDependencyLabel`. * chore: drop dependency-specific test from shared walker tests * chore: fix stale doc comment and use t.Context() in tests - traversalDefinitionTarget now only handles `local`; comment updated. - Switch test code from context.Background() to t.Context() so the context is automatically canceled when the test ends. * removing sibling references Signed-off-by: Diogenes Fernandes <diofeher@gmail.com> * bump to v0.0.5 Signed-off-by: Diogenes Fernandes <diofeher@gmail.com> --------- Signed-off-by: Diogenes Fernandes <diofeher@gmail.com>
1 parent d152cc8 commit cfc3f6f

15 files changed

Lines changed: 1211 additions & 3 deletions

File tree

internal/ast/walk.go

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
package ast
2+
3+
import (
4+
"github.com/hashicorp/hcl/v2"
5+
"github.com/hashicorp/hcl/v2/hclsyntax"
6+
)
7+
8+
// MinReferenceTraversalLen is the minimum number of steps a ScopeTraversalExpr
9+
// must have to be a `<root>.<name>` reference (one root + one attribute).
10+
const MinReferenceTraversalLen = 2
11+
12+
// ReferenceVisitor is invoked for each `<root>.<name>` reference found.
13+
// r is the source range of the attribute step (just `<name>`, not the root).
14+
type ReferenceVisitor func(expr *hclsyntax.ScopeTraversalExpr, r hcl.Range)
15+
16+
// WalkReferences walks body and invokes visitor for each ScopeTraversalExpr
17+
// whose first traversal step is a TraverseRoot named root and whose second
18+
// step is a TraverseAttr named name.
19+
func WalkReferences(body *hclsyntax.Body, root, name string, visitor ReferenceVisitor) {
20+
if body == nil {
21+
return
22+
}
23+
24+
_ = hclsyntax.VisitAll(body, func(node hclsyntax.Node) hcl.Diagnostics {
25+
expr, ok := node.(*hclsyntax.ScopeTraversalExpr)
26+
if !ok {
27+
return nil
28+
}
29+
30+
if len(expr.Traversal) < MinReferenceTraversalLen {
31+
return nil
32+
}
33+
34+
rootStep, ok := expr.Traversal[0].(hcl.TraverseRoot)
35+
if !ok || rootStep.Name != root {
36+
return nil
37+
}
38+
39+
attrStep, ok := expr.Traversal[1].(hcl.TraverseAttr)
40+
if !ok || attrStep.Name != name {
41+
return nil
42+
}
43+
44+
visitor(expr, TraverseAttrIdentRange(attrStep))
45+
46+
return nil
47+
})
48+
}
49+
50+
// TraverseAttrIdentRange returns the range of the attribute identifier alone,
51+
// excluding the leading dot included in TraverseAttr.SrcRange.
52+
func TraverseAttrIdentRange(step hcl.TraverseAttr) hcl.Range {
53+
r := step.SrcRange
54+
r.Start.Column++
55+
r.Start.Byte++
56+
57+
return r
58+
}

internal/ast/walk_test.go

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
package ast_test
2+
3+
import (
4+
"terragrunt-ls/internal/ast"
5+
"testing"
6+
7+
"github.com/hashicorp/hcl/v2"
8+
"github.com/hashicorp/hcl/v2/hclsyntax"
9+
"github.com/stretchr/testify/assert"
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestWalkReferences(t *testing.T) {
14+
t.Parallel()
15+
16+
tc := []struct {
17+
name string
18+
contents string
19+
root string
20+
ident string
21+
expected []hcl.Range
22+
}{
23+
{
24+
name: "single local reference",
25+
contents: `locals {
26+
foo = "bar"
27+
}
28+
29+
inputs = {
30+
v = local.foo
31+
}
32+
`,
33+
root: "local",
34+
ident: "foo",
35+
expected: []hcl.Range{
36+
{Start: hcl.Pos{Line: 6, Column: 13}, End: hcl.Pos{Line: 6, Column: 16}},
37+
},
38+
},
39+
{
40+
name: "multiple references and unrelated traversals",
41+
contents: `inputs = {
42+
a = local.foo
43+
b = local.bar
44+
c = local.foo
45+
d = path.module
46+
}
47+
`,
48+
root: "local",
49+
ident: "foo",
50+
expected: []hcl.Range{
51+
{Start: hcl.Pos{Line: 2, Column: 13}, End: hcl.Pos{Line: 2, Column: 16}},
52+
{Start: hcl.Pos{Line: 4, Column: 13}, End: hcl.Pos{Line: 4, Column: 16}},
53+
},
54+
},
55+
{
56+
name: "no matches",
57+
contents: `inputs = {
58+
v = local.bar
59+
}
60+
`,
61+
root: "local",
62+
ident: "foo",
63+
expected: nil,
64+
},
65+
}
66+
67+
for _, tt := range tc {
68+
t.Run(tt.name, func(t *testing.T) {
69+
t.Parallel()
70+
71+
iast, err := ast.ParseHCLFile("test.hcl", []byte(tt.contents))
72+
require.NoError(t, err)
73+
require.NotNil(t, iast.HCLFile)
74+
75+
body, ok := iast.HCLFile.Body.(*hclsyntax.Body)
76+
require.True(t, ok)
77+
78+
var got []hcl.Range
79+
ast.WalkReferences(body, tt.root, tt.ident, func(_ *hclsyntax.ScopeTraversalExpr, r hcl.Range) {
80+
got = append(got, r)
81+
})
82+
83+
require.Len(t, got, len(tt.expected))
84+
for i := range tt.expected {
85+
assert.Equal(t, tt.expected[i].Start.Line, got[i].Start.Line, "start line %d", i)
86+
assert.Equal(t, tt.expected[i].Start.Column, got[i].Start.Column, "start col %d", i)
87+
assert.Equal(t, tt.expected[i].End.Line, got[i].End.Line, "end line %d", i)
88+
assert.Equal(t, tt.expected[i].End.Column, got[i].End.Column, "end col %d", i)
89+
}
90+
})
91+
}
92+
}

internal/lsp/initialize.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,8 +28,12 @@ func NewInitializeResponse(id int) InitializeResponse {
2828
TextDocumentSync: 1,
2929
HoverProvider: true,
3030
DefinitionProvider: true,
31+
ReferencesProvider: true,
3132
CompletionProvider: &protocol.CompletionOptions{},
3233
DocumentFormattingProvider: true,
34+
RenameProvider: &protocol.RenameOptions{
35+
PrepareProvider: true,
36+
},
3337
},
3438
ServerInfo: &protocol.ServerInfo{
3539
Name: name,

internal/lsp/rename.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
package lsp
2+
3+
import "go.lsp.dev/protocol"
4+
5+
type PrepareRenameRequest struct {
6+
Request
7+
Params protocol.PrepareRenameParams `json:"params"`
8+
}
9+
10+
// PrepareRenameResult mirrors the LSP `{ range, placeholder }` response shape.
11+
type PrepareRenameResult struct {
12+
Placeholder string `json:"placeholder"`
13+
Range protocol.Range `json:"range"`
14+
}
15+
16+
type PrepareRenameResponse struct {
17+
Result *PrepareRenameResult `json:"result"`
18+
Response
19+
}
20+
21+
type RenameRequest struct {
22+
Params protocol.RenameParams `json:"params"`
23+
Request
24+
}
25+
26+
type RenameResponse struct {
27+
Result *protocol.WorkspaceEdit `json:"result"`
28+
Response
29+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
package lsp
2+
3+
import "go.lsp.dev/protocol"
4+
5+
type ReferencesRequest struct {
6+
Params protocol.ReferenceParams `json:"params"`
7+
Request
8+
}
9+
10+
type ReferencesResponse struct {
11+
Response
12+
Result []protocol.Location `json:"result"`
13+
}

internal/tg/definition/definition.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,18 @@ import (
77
"terragrunt-ls/internal/logger"
88
"terragrunt-ls/internal/tg/store"
99

10+
"github.com/hashicorp/hcl/v2"
11+
"github.com/hashicorp/hcl/v2/hclsyntax"
1012
"go.lsp.dev/protocol"
1113
)
1214

1315
const (
16+
// DefinitionContextLocal is the context for a local variable definition.
17+
// This means that the user is trying to find the definition of a `local.X`
18+
// reference, which resolves to a `locals { X = ... }` declaration in the
19+
// current file or a sibling file in the same module folder.
20+
DefinitionContextLocal = "local"
21+
1422
// DefinitionContextInclude is the context for an include definition.
1523
// This means that the user is trying to find the definition of an include.
1624
DefinitionContextInclude = "include"
@@ -46,7 +54,38 @@ func GetDefinitionTargetWithContext(l logger.Logger, store store.Store, position
4654
return dep, DefinitionContextDependency
4755
}
4856

57+
if expr, ok := node.Node.(*hclsyntax.ScopeTraversalExpr); ok {
58+
if name, context, ok := traversalDefinitionTarget(expr); ok {
59+
l.Debug("Found traversal target", "name", name, "context", context)
60+
return name, context
61+
}
62+
}
63+
4964
l.Debug("No definition found at", "line", position.Line, "character", position.Character)
5065

5166
return "", DefinitionContextNull
5267
}
68+
69+
// traversalDefinitionTarget extracts a (name, context) pair from a
70+
// `local.<name>` traversal.
71+
func traversalDefinitionTarget(expr *hclsyntax.ScopeTraversalExpr) (string, string, bool) {
72+
if len(expr.Traversal) < ast.MinReferenceTraversalLen {
73+
return "", "", false
74+
}
75+
76+
rootStep, ok := expr.Traversal[0].(hcl.TraverseRoot)
77+
if !ok {
78+
return "", "", false
79+
}
80+
81+
attrStep, ok := expr.Traversal[1].(hcl.TraverseAttr)
82+
if !ok {
83+
return "", "", false
84+
}
85+
86+
if rootStep.Name == "local" {
87+
return attrStep.Name, DefinitionContextLocal, true
88+
}
89+
90+
return "", "", false
91+
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// Package references provides the logic for finding all references of an
2+
// identifier within a Terragrunt unit.
3+
package references
4+
5+
import (
6+
"terragrunt-ls/internal/logger"
7+
"terragrunt-ls/internal/tg/rename"
8+
"terragrunt-ls/internal/tg/store"
9+
10+
"go.lsp.dev/protocol"
11+
"go.lsp.dev/uri"
12+
)
13+
14+
// GetReferences returns LSP locations for every reference (and optionally the
15+
// declaration) of the renameable symbol at position. Returns nil if the cursor
16+
// is not on a renameable identifier.
17+
func GetReferences(l logger.Logger, st store.Store, position protocol.Position, file string, includeDeclaration bool) []protocol.Location {
18+
target := rename.GetRenameTarget(l, st, position)
19+
if target.Context == rename.RenameContextNull {
20+
return nil
21+
}
22+
23+
occurrences := rename.FindAllOccurrences(target, file, st)
24+
if len(occurrences) == 0 {
25+
return nil
26+
}
27+
28+
locations := make([]protocol.Location, 0, len(occurrences))
29+
30+
for _, occ := range occurrences {
31+
if occ.IsDefinition && !includeDeclaration {
32+
continue
33+
}
34+
35+
locations = append(locations, protocol.Location{
36+
URI: uri.File(occ.File),
37+
Range: occ.Range,
38+
})
39+
}
40+
41+
return locations
42+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
package references_test
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
"github.com/stretchr/testify/require"
9+
"go.lsp.dev/protocol"
10+
"go.lsp.dev/uri"
11+
12+
"terragrunt-ls/internal/testutils"
13+
"terragrunt-ls/internal/tg"
14+
"terragrunt-ls/internal/tg/references"
15+
)
16+
17+
func TestGetReferences(t *testing.T) {
18+
t.Parallel()
19+
20+
tmpDir := t.TempDir()
21+
22+
content := `locals {
23+
shared = "value"
24+
}
25+
26+
inputs = {
27+
v = local.shared
28+
}
29+
`
30+
_, err := testutils.CreateFile(tmpDir, "terragrunt.hcl", content)
31+
require.NoError(t, err)
32+
33+
tgPath := filepath.Join(tmpDir, "terragrunt.hcl")
34+
35+
l := testutils.NewTestLogger(t)
36+
s := tg.NewState()
37+
s.OpenDocument(t.Context(), l, uri.File(tgPath), content)
38+
39+
t.Run("includes declaration when requested", func(t *testing.T) {
40+
t.Parallel()
41+
42+
locs := references.GetReferences(l, s.Configs[tgPath], protocol.Position{Line: 5, Character: 14}, tgPath, true)
43+
require.Len(t, locs, 2, "definition + reference")
44+
45+
for _, loc := range locs {
46+
assert.Equal(t, uri.File(tgPath), loc.URI)
47+
}
48+
})
49+
50+
t.Run("excludes declaration when requested", func(t *testing.T) {
51+
t.Parallel()
52+
53+
locs := references.GetReferences(l, s.Configs[tgPath], protocol.Position{Line: 5, Character: 14}, tgPath, false)
54+
require.Len(t, locs, 1, "only the reference, not the definition")
55+
56+
assert.Equal(t, uri.File(tgPath), locs[0].URI)
57+
})
58+
59+
t.Run("returns nil for non-renameable position", func(t *testing.T) {
60+
t.Parallel()
61+
62+
locs := references.GetReferences(l, s.Configs[tgPath], protocol.Position{Line: 0, Character: 0}, tgPath, true)
63+
assert.Nil(t, locs)
64+
})
65+
}

0 commit comments

Comments
 (0)