diff --git a/decoder/expr_reference.go b/decoder/expr_reference.go index 4be97bd9..4c87a1a7 100644 --- a/decoder/expr_reference.go +++ b/decoder/expr_reference.go @@ -1,40 +1,12 @@ package decoder import ( - "context" - - "github.com/hashicorp/hcl-lang/lang" - "github.com/hashicorp/hcl-lang/reference" "github.com/hashicorp/hcl-lang/schema" "github.com/hashicorp/hcl/v2" ) type Reference struct { - expr hcl.Expression - cons schema.Reference -} - -func (ref Reference) CompletionAtPos(ctx context.Context, pos hcl.Pos) []lang.Candidate { - // TODO - return nil -} - -func (ref Reference) HoverAtPos(ctx context.Context, pos hcl.Pos) *lang.HoverData { - // TODO - return nil -} - -func (ref Reference) SemanticTokens(ctx context.Context) []lang.SemanticToken { - // TODO - return nil -} - -func (ref Reference) ReferenceOrigins(ctx context.Context, allowSelfRefs bool) reference.Origins { - // TODO - return nil -} - -func (ref Reference) ReferenceTargets(ctx context.Context, targetCtx *TargetContext) reference.Targets { - // TODO - return nil + expr hcl.Expression + cons schema.Reference + pathCtx *PathContext } diff --git a/decoder/expr_reference_completion.go b/decoder/expr_reference_completion.go new file mode 100644 index 00000000..02a06a2e --- /dev/null +++ b/decoder/expr_reference_completion.go @@ -0,0 +1,99 @@ +package decoder + +import ( + "context" + + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +func (ref Reference) CompletionAtPos(ctx context.Context, pos hcl.Pos) []lang.Candidate { + if ref.cons.Address != nil { + // no candidates if traversal itself is addressable + return []lang.Candidate{} + } + + if ref.pathCtx.ReferenceTargets == nil { + return []lang.Candidate{} + } + + file := ref.pathCtx.Files[ref.expr.Range().Filename] + rootBody, ok := file.Body.(*hclsyntax.Body) + if !ok { + return []lang.Candidate{} + } + + outerBodyRng := rootBody.Range() + // Find outer block body range to allow filtering + // of references pointing back to the same block + outerBlock := rootBody.OutermostBlockAtPos(pos) + if outerBlock != nil { + ob := outerBlock.Body.(*hclsyntax.Body) + outerBodyRng = ob.Range() + } + + if isEmptyExpression(ref.expr) { + editRng := hcl.Range{ + Filename: ref.expr.Range().Filename, + Start: pos, + End: pos, + } + candidates := make([]lang.Candidate, 0) + ref.pathCtx.ReferenceTargets.MatchWalk(ctx, ref.cons, "", outerBodyRng, editRng, func(target reference.Target) error { + address := target.Address(ctx, editRng.Start).String() + + candidates = append(candidates, lang.Candidate{ + Label: address, + Detail: target.FriendlyName(), + Description: target.Description, + Kind: lang.TraversalCandidateKind, + TextEdit: lang.TextEdit{ + NewText: address, + Snippet: address, + Range: editRng, + }, + }) + return nil + }) + return candidates + } + + eType, ok := ref.expr.(*hclsyntax.ScopeTraversalExpr) + if !ok { + return []lang.Candidate{} + } + + editRng := eType.Range() + if !editRng.ContainsPos(pos) { + // account for trailing character(s) which doesn't appear in AST + // such as dot, opening bracket etc. + editRng.End = pos + } + prefixRng := hcl.Range{ + Filename: eType.Range().Filename, + Start: eType.Range().Start, + End: pos, + } + prefix := string(prefixRng.SliceBytes(file.Bytes)) + + candidates := make([]lang.Candidate, 0) + ref.pathCtx.ReferenceTargets.MatchWalk(ctx, ref.cons, prefix, outerBodyRng, editRng, func(target reference.Target) error { + address := target.Address(ctx, editRng.Start).String() + + candidates = append(candidates, lang.Candidate{ + Label: address, + Detail: target.FriendlyName(), + Description: target.Description, + Kind: lang.TraversalCandidateKind, + TextEdit: lang.TextEdit{ + NewText: address, + Snippet: address, + Range: editRng, + }, + }) + return nil + }) + return candidates +} diff --git a/decoder/expr_reference_completion_test.go b/decoder/expr_reference_completion_test.go new file mode 100644 index 00000000..ed3a9f80 --- /dev/null +++ b/decoder/expr_reference_completion_test.go @@ -0,0 +1,286 @@ +package decoder + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +func TestCompletionAtPos_exprReference(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + refTargets reference.Targets + cfg string + pos hcl.Pos + expectedCandidates lang.Candidates + }{ + { + "no expression", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Type: cty.String, + }, + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "bar"}, + }, + Type: cty.Number, + }, + }, + `attr = `, + hcl.Pos{Line: 1, Column: 8, Byte: 7}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "local.foo", + Detail: "string", + Kind: lang.TraversalCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "local.foo", + Snippet: "local.foo", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + }, + }, + }, + }), + }, + { + "matching prefix", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.Number, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Type: cty.String, + }, + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "bar"}, + }, + Type: cty.Number, + }, + { + Addr: lang.Address{ + lang.RootStep{Name: "data"}, + lang.AttrStep{Name: "bar"}, + }, + Type: cty.Number, + }, + }, + `attr = local`, + hcl.Pos{Line: 1, Column: 13, Byte: 12}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "local.bar", + Detail: "number", + Kind: lang.TraversalCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "local.bar", + Snippet: "local.bar", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + }, + }, + }, + }), + }, + { + "matching prefix in the middle", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Type: cty.String, + }, + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "bar"}, + }, + Type: cty.Number, + }, + { + Addr: lang.Address{ + lang.RootStep{Name: "data"}, + lang.AttrStep{Name: "bar"}, + }, + Type: cty.Number, + }, + }, + `attr = local`, + hcl.Pos{Line: 1, Column: 11, Byte: 10}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "local.foo", + Detail: "string", + Kind: lang.TraversalCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "local.foo", + Snippet: "local.foo", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + }, + }, + }, + }), + }, + { + "matching prefix after trailing dot", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Type: cty.String, + }, + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "bar"}, + }, + Type: cty.Number, + }, + { + Addr: lang.Address{ + lang.RootStep{Name: "data"}, + lang.AttrStep{Name: "bar"}, + }, + Type: cty.Number, + }, + }, + `attr = local.`, + hcl.Pos{Line: 1, Column: 14, Byte: 13}, + lang.CompleteCandidates([]lang.Candidate{ + { + Label: "local.foo", + Detail: "string", + Kind: lang.TraversalCandidateKind, + TextEdit: lang.TextEdit{ + NewText: "local.foo", + Snippet: "local.foo", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 14, Byte: 13}, + }, + }, + }, + }), + }, + { + "mismatching prefix", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.Number, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Type: cty.String, + }, + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "bar"}, + }, + Type: cty.Number, + }, + { + Addr: lang.Address{ + lang.RootStep{Name: "data"}, + lang.AttrStep{Name: "bar"}, + }, + Type: cty.Number, + }, + }, + `attr = x`, + hcl.Pos{Line: 1, Column: 9, Byte: 8}, + lang.CompleteCandidates([]lang.Candidate{}), + }, + } + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, _ := hclsyntax.ParseConfig([]byte(tc.cfg), "test.tf", hcl.InitialPos) + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf": f, + }, + ReferenceTargets: tc.refTargets, + }) + + ctx := context.Background() + candidates, err := d.CandidatesAtPos(ctx, "test.tf", tc.pos) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedCandidates, candidates); diff != "" { + t.Fatalf("unexpected candidates: %s", diff) + } + }) + } +} diff --git a/decoder/expr_reference_hover.go b/decoder/expr_reference_hover.go new file mode 100644 index 00000000..e0caa6ba --- /dev/null +++ b/decoder/expr_reference_hover.go @@ -0,0 +1,46 @@ +package decoder + +import ( + "context" + + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" +) + +func (ref Reference) HoverAtPos(ctx context.Context, pos hcl.Pos) *lang.HoverData { + eType, ok := ref.expr.(*hclsyntax.ScopeTraversalExpr) + if !ok { + return nil + } + + origins, ok := ref.pathCtx.ReferenceOrigins.AtPos(eType.Range().Filename, pos) + if !ok { + return nil + } + + for _, origin := range origins { + matchableOrigin, ok := origin.(reference.MatchableOrigin) + if !ok { + continue + } + targets, ok := ref.pathCtx.ReferenceTargets.Match(matchableOrigin) + if !ok { + // target not found + continue + } + + // TODO: Reflect additional found targets here? + + content, err := hoverContentForReferenceTarget(ctx, targets[0], pos) + if err == nil { + return &lang.HoverData{ + Content: lang.Markdown(content), + Range: eType.Range(), + } + } + } + + return nil +} diff --git a/decoder/expr_reference_hover_test.go b/decoder/expr_reference_hover_test.go new file mode 100644 index 00000000..55c51c7a --- /dev/null +++ b/decoder/expr_reference_hover_test.go @@ -0,0 +1,248 @@ +package decoder + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +func TestHoverAtPos_exprReference(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + refOrigins reference.Origins + refTargets reference.Targets + cfg string + pos hcl.Pos + expectedHoverData *lang.HoverData + }{ + { + "unknown origin", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + }, + }, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "d"}, + lang.AttrStep{Name: "fx"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Type: cty.String, + RangePtr: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 17}, + End: hcl.Pos{Line: 2, Column: 13, Byte: 29}, + }, + }, + }, + `attr = l.ca+d.fx +foo = "noot" +`, + hcl.Pos{Line: 1, Column: 10, Byte: 9}, + nil, + }, + { + "matching origin no target", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + }, + }, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + reference.Targets{}, + `attr = local.foo +foo = "noot" +`, + hcl.Pos{Line: 1, Column: 12, Byte: 11}, + nil, + }, + { + "matching origin and target", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + }, + }, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Type: cty.String, + RangePtr: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 17}, + End: hcl.Pos{Line: 2, Column: 13, Byte: 29}, + }, + }, + }, + `attr = local.foo +foo = "noot" +`, + hcl.Pos{Line: 1, Column: 12, Byte: 11}, + &lang.HoverData{ + Content: lang.Markdown("`local.foo`\n_string_"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + }, + }, + { + "matching origin and target inside set", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Set{ + Elem: schema.OneOf{ + schema.Reference{OfScopeId: lang.ScopeId("one")}, + schema.Reference{OfScopeId: lang.ScopeId("two")}, + schema.Reference{OfScopeId: lang.ScopeId("three")}, + }, + }, + }, + }, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + lang.AttrStep{Name: "bar"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + Constraints: reference.OriginConstraints{ + { + OfScopeId: lang.ScopeId("two"), + }, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + lang.AttrStep{Name: "bar"}, + }, + ScopeId: lang.ScopeId("two"), + RangePtr: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 19}, + End: hcl.Pos{Line: 2, Column: 13, Byte: 31}, + }, + }, + }, + `attr = [ foo.bar ] +foo = "noot" +`, + hcl.Pos{Line: 1, Column: 12, Byte: 11}, + &lang.HoverData{ + Content: lang.Markdown("`foo.bar` reference"), + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + }, + }, + } + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, _ := hclsyntax.ParseConfig([]byte(tc.cfg), "test.tf", hcl.InitialPos) + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf": f, + }, + ReferenceOrigins: tc.refOrigins, + ReferenceTargets: tc.refTargets, + }) + + ctx := context.Background() + hoverData, err := d.HoverAtPos(ctx, "test.tf", tc.pos) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedHoverData, hoverData); diff != "" { + t.Fatalf("unexpected hover data: %s", diff) + } + }) + } +} diff --git a/decoder/expr_reference_ref_origins.go b/decoder/expr_reference_ref_origins.go new file mode 100644 index 00000000..fd53b4be --- /dev/null +++ b/decoder/expr_reference_ref_origins.go @@ -0,0 +1,84 @@ +package decoder + +import ( + "context" + + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/json" + "github.com/zclconf/go-cty/cty" +) + +func (ref Reference) ReferenceOrigins(ctx context.Context, allowSelfRefs bool) reference.Origins { + // deal with native HCL syntax first + te, ok := ref.expr.(*hclsyntax.ScopeTraversalExpr) + if ok { + origin, ok := reference.TraversalToLocalOrigin(te.Traversal, ref.cons, allowSelfRefs) + if ok { + return reference.Origins{origin} + } + } + + if json.IsJSONExpression(ref.expr) { + // Given the limited AST/API access to JSON we can only + // guess whether the expression has exactly a single traversal + vars := ref.expr.Variables() + if len(vars) == 1 { + tRange := vars[0].SourceRange() + expectedExprRange := hcl.Range{ + Filename: tRange.Filename, + Start: hcl.Pos{ + Line: tRange.Start.Line, + // account for "${ + Column: tRange.Start.Column - 3, + Byte: tRange.Start.Byte - 3, + }, + End: hcl.Pos{ + Line: tRange.End.Line, + // account for }" + Column: tRange.End.Column + 2, + Byte: tRange.End.Byte + 2, + }, + } + + if rangesEqual(expectedExprRange, ref.expr.Range()) { + origin, ok := reference.TraversalToLocalOrigin(vars[0], ref.cons, allowSelfRefs) + if ok { + return reference.Origins{origin} + } + } + } + + // Account for "legacy" string syntax which is still + // in use by Terraform to date in this context. + val, diags := ref.expr.Value(nil) + if diags.HasErrors() { + return reference.Origins{} + } + if val.Type() != cty.String { + return reference.Origins{} + } + startPos := hcl.Pos{ + Line: ref.expr.Range().Start.Line, + // Account for the leading double quote + Column: ref.expr.Range().Start.Column + 1, + Byte: ref.expr.Range().Start.Byte + 1, + } + + traversal, diags := hclsyntax.ParseTraversalAbs([]byte(val.AsString()), ref.expr.Range().Filename, startPos) + if diags.HasErrors() { + return reference.Origins{} + } + origin, ok := reference.TraversalToLocalOrigin(traversal, ref.cons, allowSelfRefs) + if ok { + return reference.Origins{origin} + } + } + + return reference.Origins{} +} + +func rangesEqual(first, second hcl.Range) bool { + return posEqual(first.Start, second.Start) && posEqual(first.End, second.End) +} diff --git a/decoder/expr_reference_ref_origins_test.go b/decoder/expr_reference_ref_origins_test.go new file mode 100644 index 00000000..abcc6f65 --- /dev/null +++ b/decoder/expr_reference_ref_origins_test.go @@ -0,0 +1,371 @@ +package decoder + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/json" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" +) + +func TestCollectRefOrigins_exprReference_hcl(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + cfg string + expectedRefOrigins reference.Origins + }{ + { + "no traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = "foo"`, + reference.Origins{}, + }, + { + "wrapped traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = "${foo}"`, + reference.Origins{}, + }, + { + "traversal with string", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = "${foo}-bar"`, + reference.Origins{}, + }, + { + "simple traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = foo`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 11, Byte: 10}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + }, + { + "traversal with index steps", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = one.two["key"].attr[0]`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "one"}, + lang.AttrStep{Name: "two"}, + lang.IndexStep{Key: cty.StringVal("key")}, + lang.AttrStep{Name: "attr"}, + lang.IndexStep{Key: cty.NumberIntVal(0)}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 30, Byte: 29}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + }, + { + "simple traversal - scope and type", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + OfScopeId: lang.ScopeId("foobar"), + }, + IsOptional: true, + }, + }, + `attr = foo`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 11, Byte: 10}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + OfScopeId: lang.ScopeId("foobar"), + }, + }, + }, + }, + }, + { + "string which happens to match address", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = "foo"`, + reference.Origins{ + // This should only work in JSON + }, + }, + } + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, diags := hclsyntax.ParseConfig([]byte(tc.cfg), "test.tf", hcl.InitialPos) + if len(diags) > 0 { + t.Error(diags) + } + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf": f, + }, + }) + + origins, err := d.CollectReferenceOrigins() + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedRefOrigins, origins, ctydebug.CmpOptions); diff != "" { + t.Fatalf("unexpected origins: %s", diff) + } + }) + } +} + +func TestCollectRefOrigins_exprReference_json(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + cfg string + expectedRefOrigins reference.Origins + }{ + { + "no traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": 422}`, + reference.Origins{}, + }, + { + "traversal with string", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": "${foo}-bar"}`, + reference.Origins{}, + }, + { + "simple traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": "${foo}"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + End: hcl.Pos{Line: 1, Column: 16, Byte: 15}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + }, + { + "traversal with numeric index steps", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": "${one.two[42].attr[0]}"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "one"}, + lang.AttrStep{Name: "two"}, + lang.IndexStep{Key: cty.NumberIntVal(42)}, + lang.AttrStep{Name: "attr"}, + lang.IndexStep{Key: cty.NumberIntVal(0)}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + End: hcl.Pos{Line: 1, Column: 32, Byte: 31}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + }, + { + "traversal with string index steps", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": "${one.two[\"key\"].attr[\"foo\"]}"}`, + reference.Origins{ + // HCL misreports traversals' range w/ string keys in JSON + // See https://github.com/hashicorp/hcl/issues/598 + }, + }, + { // Terraform uses this in most places where it expects references only + "legacy style string", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": "foo.bar"}`, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + lang.AttrStep{Name: "bar"}, + }, + Range: hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 11, Byte: 10}, + End: hcl.Pos{Line: 1, Column: 18, Byte: 17}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + }, + } + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, diags := json.ParseWithStartPos([]byte(tc.cfg), "test.tf.json", hcl.InitialPos) + if len(diags) > 0 { + t.Error(diags) + } + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf.json": f, + }, + }) + + origins, err := d.CollectReferenceOrigins() + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedRefOrigins, origins, ctydebug.CmpOptions); diff != "" { + t.Fatalf("unexpected origins: %s", diff) + } + }) + } +} diff --git a/decoder/expr_reference_ref_targets.go b/decoder/expr_reference_ref_targets.go new file mode 100644 index 00000000..2fdcb559 --- /dev/null +++ b/decoder/expr_reference_ref_targets.go @@ -0,0 +1,80 @@ +package decoder + +import ( + "context" + + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/json" +) + +func (ref Reference) ReferenceTargets(ctx context.Context, _ *TargetContext) reference.Targets { + if ref.cons.Address == nil { + return reference.Targets{} + } + + // deal with native HCL syntax first + eType, ok := ref.expr.(*hclsyntax.ScopeTraversalExpr) + if ok { + addr, err := lang.TraversalToAddress(eType.Traversal) + if err != nil { + return reference.Targets{} + } + + return reference.Targets{ + reference.Target{ + Addr: addr, + ScopeId: ref.cons.Address.ScopeId, + RangePtr: eType.SrcRange.Ptr(), + Name: ref.cons.Name, + }, + } + } + + if json.IsJSONExpression(ref.expr) { + // Given the limited AST/API access to JSON we can only + // guess whether the expression has exactly a single traversal + + vars := ref.expr.Variables() + if len(vars) != 1 { + return reference.Targets{} + } + + tRange := vars[0].SourceRange() + expectedExprRange := hcl.Range{ + Filename: tRange.Filename, + Start: hcl.Pos{ + Line: tRange.Start.Line, + // account for "${ + Column: tRange.Start.Column - 3, + Byte: tRange.Start.Byte - 3, + }, + End: hcl.Pos{ + Line: tRange.End.Line, + // account for }" + Column: tRange.End.Column + 2, + Byte: tRange.End.Byte + 2, + }, + } + + if rangesEqual(expectedExprRange, ref.expr.Range()) { + addr, err := lang.TraversalToAddress(vars[0]) + if err != nil { + return reference.Targets{} + } + + return reference.Targets{ + reference.Target{ + Addr: addr, + ScopeId: ref.cons.Address.ScopeId, + RangePtr: vars[0].SourceRange().Ptr(), + Name: ref.cons.Name, + }, + } + } + } + + return reference.Targets{} +} diff --git a/decoder/expr_reference_ref_targets_test.go b/decoder/expr_reference_ref_targets_test.go new file mode 100644 index 00000000..adf56e2a --- /dev/null +++ b/decoder/expr_reference_ref_targets_test.go @@ -0,0 +1,251 @@ +package decoder + +import ( + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/hashicorp/hcl/v2/json" + "github.com/zclconf/go-cty-debug/ctydebug" + "github.com/zclconf/go-cty/cty" +) + +func TestCollectRefTargets_exprReference_hcl(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + cfg string + expectedRefTargets reference.Targets + }{ + { + "no traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + Address: &schema.ReferenceAddrSchema{ + ScopeId: lang.ScopeId("foo"), + }, + }, + IsOptional: true, + }, + }, + `attr = "foo"`, + reference.Targets{}, + }, + { + "wrapped traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + Address: &schema.ReferenceAddrSchema{ + ScopeId: lang.ScopeId("foo"), + }, + }, + IsOptional: true, + }, + }, + `attr = "${foo}"`, + reference.Targets{}, + }, + { + "traversal with string", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + Address: &schema.ReferenceAddrSchema{ + ScopeId: lang.ScopeId("foo"), + }, + }, + IsOptional: true, + }, + }, + `attr = "${foo}-bar"`, + reference.Targets{}, + }, + { + "non-addressable traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `attr = foo`, + reference.Targets{}, + }, + { + "addressable traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + Address: &schema.ReferenceAddrSchema{ + ScopeId: lang.ScopeId("foobar"), + }, + Name: "custom name", + }, + IsOptional: true, + }, + }, + `attr = foo`, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + }, + ScopeId: lang.ScopeId("foobar"), + RangePtr: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 11, Byte: 10}, + }, + Name: "custom name", + }, + }, + }, + } + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, diags := hclsyntax.ParseConfig([]byte(tc.cfg), "test.tf", hcl.InitialPos) + if len(diags) > 0 { + t.Error(diags) + } + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf": f, + }, + }) + + targets, err := d.CollectReferenceTargets() + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedRefTargets, targets, ctydebug.CmpOptions); diff != "" { + t.Fatalf("unexpected targets: %s", diff) + } + }) + } +} + +func TestCollectRefTargets_exprReference_json(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + cfg string + expectedRefTargets reference.Targets + }{ + { + "no traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + Address: &schema.ReferenceAddrSchema{ + ScopeId: lang.ScopeId("foo"), + }, + }, + IsOptional: true, + }, + }, + `{"attr": "foo"}`, + reference.Targets{}, + }, + { + "traversal with string", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + Address: &schema.ReferenceAddrSchema{ + ScopeId: lang.ScopeId("foo"), + }, + }, + IsOptional: true, + }, + }, + `{"attr": "${foo}-bar"}`, + reference.Targets{}, + }, + { + "non-addressable traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + IsOptional: true, + }, + }, + `{"attr": "${foo}"}`, + reference.Targets{}, + }, + { + "addressable traversal", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + Address: &schema.ReferenceAddrSchema{ + ScopeId: lang.ScopeId("foobar"), + }, + Name: "custom name", + }, + IsOptional: true, + }, + }, + `{"attr": "${foo}"}`, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + }, + ScopeId: lang.ScopeId("foobar"), + RangePtr: &hcl.Range{ + Filename: "test.tf.json", + Start: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + End: hcl.Pos{Line: 1, Column: 16, Byte: 15}, + }, + Name: "custom name", + }, + }, + }, + } + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, diags := json.ParseWithStartPos([]byte(tc.cfg), "test.tf.json", hcl.InitialPos) + if len(diags) > 0 { + t.Error(diags) + } + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf.json": f, + }, + }) + + targets, err := d.CollectReferenceTargets() + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedRefTargets, targets, ctydebug.CmpOptions); diff != "" { + t.Fatalf("unexpected targets: %s", diff) + } + }) + } +} diff --git a/decoder/expr_reference_semtok.go b/decoder/expr_reference_semtok.go new file mode 100644 index 00000000..b2f59d14 --- /dev/null +++ b/decoder/expr_reference_semtok.go @@ -0,0 +1,110 @@ +package decoder + +import ( + "context" + + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +func (ref Reference) SemanticTokens(ctx context.Context) []lang.SemanticToken { + eType, ok := ref.expr.(*hclsyntax.ScopeTraversalExpr) + if !ok { + return []lang.SemanticToken{} + } + + pos := ref.expr.Range().Start + origins, ok := ref.pathCtx.ReferenceOrigins.AtPos(eType.Range().Filename, pos) + if !ok { + return []lang.SemanticToken{} + } + + for _, origin := range origins { + matchableOrigin, ok := origin.(reference.MatchableOrigin) + if !ok { + continue + } + _, ok = ref.pathCtx.ReferenceTargets.Match(matchableOrigin) + if !ok { + // target not found + continue + } + + return semanticTokensForTraversal(eType.Traversal) + } + + return []lang.SemanticToken{} +} + +func semanticTokensForTraversal(traversal hcl.Traversal) []lang.SemanticToken { + tokens := make([]lang.SemanticToken, 0) + + for _, t := range traversal { + // TODO: Add meaning to each step/token? + // This would require declaring the meaning in schema.AddrStep + // and exposing it via lang.AddressStep + // See https://github.com/hashicorp/vscode-terraform/issues/574 + + switch ts := t.(type) { + case hcl.TraverseRoot: + tokens = append(tokens, lang.SemanticToken{ + Type: lang.TokenTraversalStep, + Modifiers: []lang.SemanticTokenModifier{}, + Range: t.SourceRange(), + }) + case hcl.TraverseAttr: + rng := t.SourceRange() + tokens = append(tokens, lang.SemanticToken{ + Type: lang.TokenTraversalStep, + Modifiers: []lang.SemanticTokenModifier{}, + Range: hcl.Range{ + Filename: rng.Filename, + // omit the initial '.' + Start: hcl.Pos{ + Line: rng.Start.Line, + Column: rng.Start.Column + 1, + Byte: rng.Start.Byte + 1, + }, + End: rng.End, + }, + }) + case hcl.TraverseIndex: + // for index steps we only report + // what's inside brackets + rng := t.SourceRange() + idxRange := hcl.Range{ + Filename: rng.Filename, + Start: hcl.Pos{ + Line: rng.Start.Line, + Column: rng.Start.Column + 1, + Byte: rng.Start.Byte + 1, + }, + End: hcl.Pos{ + Line: rng.End.Line, + Column: rng.End.Column - 1, + Byte: rng.End.Byte - 1, + }, + } + + if ts.Key.Type() == cty.String { + tokens = append(tokens, lang.SemanticToken{ + Type: lang.TokenMapKey, + Modifiers: []lang.SemanticTokenModifier{}, + Range: idxRange, + }) + } + if ts.Key.Type() == cty.Number { + tokens = append(tokens, lang.SemanticToken{ + Type: lang.TokenNumber, + Modifiers: []lang.SemanticTokenModifier{}, + Range: idxRange, + }) + } + } + } + + return tokens +} diff --git a/decoder/expr_reference_semtok_test.go b/decoder/expr_reference_semtok_test.go new file mode 100644 index 00000000..752e794e --- /dev/null +++ b/decoder/expr_reference_semtok_test.go @@ -0,0 +1,475 @@ +package decoder + +import ( + "context" + "fmt" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/hcl-lang/lang" + "github.com/hashicorp/hcl-lang/reference" + "github.com/hashicorp/hcl-lang/schema" + "github.com/hashicorp/hcl/v2" + "github.com/hashicorp/hcl/v2/hclsyntax" + "github.com/zclconf/go-cty/cty" +) + +func TestSemanticTokens_exprReference(t *testing.T) { + testCases := []struct { + testName string + attrSchema map[string]*schema.AttributeSchema + refOrigins reference.Origins + refTargets reference.Targets + cfg string + expectedSemanticTokens []lang.SemanticToken + }{ + { + "unknown origin", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + }, + }, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Type: cty.String, + RangePtr: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 17}, + End: hcl.Pos{Line: 2, Column: 13, Byte: 29}, + }, + }, + }, + `attr = local.foox +foo = "noot" +`, + []lang.SemanticToken{ + { + Type: lang.TokenAttrName, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 5, Byte: 4}, + }, + }, + }, + }, + { + "matching origin with no target", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + }, + }, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + reference.Targets{}, + `attr = local.foo +foo = "noot" +`, + []lang.SemanticToken{ + { + Type: lang.TokenAttrName, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 5, Byte: 4}, + }, + }, + }, + }, + { + "matching origin and target", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + }, + }, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + }, + Type: cty.String, + RangePtr: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 17}, + End: hcl.Pos{Line: 2, Column: 13, Byte: 29}, + }, + }, + }, + `attr = local.foo +foo = "noot" +`, + []lang.SemanticToken{ + { + Type: lang.TokenAttrName, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 5, Byte: 4}, + }, + }, + { + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + }, + }, + { + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 14, Byte: 13}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + }, + }, + }, + { + "matching reference with numerical index", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + }, + }, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + lang.IndexStep{Key: cty.NumberIntVal(42)}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 21, Byte: 20}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + lang.IndexStep{Key: cty.NumberIntVal(42)}, + }, + Type: cty.String, + RangePtr: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 21}, + End: hcl.Pos{Line: 2, Column: 13, Byte: 33}, + }, + }, + }, + `attr = local.foo[42] +foo = "noot" +`, + []lang.SemanticToken{ + { + Type: lang.TokenAttrName, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 5, Byte: 4}, + }, + }, + { + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + }, + }, + { + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 14, Byte: 13}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + }, + { + Type: lang.TokenNumber, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 18, Byte: 17}, + End: hcl.Pos{Line: 1, Column: 20, Byte: 19}, + }, + }, + }, + }, + { + "matching reference with string index", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Reference{ + OfType: cty.String, + }, + }, + }, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + lang.IndexStep{Key: cty.StringVal("bar")}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 24, Byte: 23}, + }, + Constraints: reference.OriginConstraints{ + { + OfType: cty.String, + }, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "local"}, + lang.AttrStep{Name: "foo"}, + lang.IndexStep{Key: cty.StringVal("bar")}, + }, + Type: cty.String, + RangePtr: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 24}, + End: hcl.Pos{Line: 2, Column: 13, Byte: 36}, + }, + }, + }, + `attr = local.foo["bar"] +foo = "noot" +`, + []lang.SemanticToken{ + { + Type: lang.TokenAttrName, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 5, Byte: 4}, + }, + }, + { + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 8, Byte: 7}, + End: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + }, + }, + { + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 14, Byte: 13}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + }, + { + Type: lang.TokenMapKey, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 18, Byte: 17}, + End: hcl.Pos{Line: 1, Column: 23, Byte: 22}, + }, + }, + }, + }, + { + "matching origin and target inside set", + map[string]*schema.AttributeSchema{ + "attr": { + Constraint: schema.Set{ + Elem: schema.OneOf{ + schema.Reference{OfScopeId: lang.ScopeId("one")}, + schema.Reference{OfScopeId: lang.ScopeId("two")}, + schema.Reference{OfScopeId: lang.ScopeId("three")}, + }, + }, + }, + }, + reference.Origins{ + reference.LocalOrigin{ + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + lang.AttrStep{Name: "bar"}, + }, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + Constraints: reference.OriginConstraints{ + { + OfScopeId: lang.ScopeId("two"), + }, + }, + }, + }, + reference.Targets{ + { + Addr: lang.Address{ + lang.RootStep{Name: "foo"}, + lang.AttrStep{Name: "bar"}, + }, + ScopeId: lang.ScopeId("two"), + RangePtr: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 2, Column: 1, Byte: 19}, + End: hcl.Pos{Line: 2, Column: 13, Byte: 31}, + }, + }, + }, + `attr = [ foo.bar ] +foo = "noot" +`, + []lang.SemanticToken{ + { + Type: lang.TokenAttrName, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 5, Byte: 4}, + }, + }, + { + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 10, Byte: 9}, + End: hcl.Pos{Line: 1, Column: 13, Byte: 12}, + }, + }, + { + Type: lang.TokenTraversalStep, + Modifiers: lang.SemanticTokenModifiers{}, + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 14, Byte: 13}, + End: hcl.Pos{Line: 1, Column: 17, Byte: 16}, + }, + }, + }, + }, + } + for i, tc := range testCases { + t.Run(fmt.Sprintf("%d-%s", i, tc.testName), func(t *testing.T) { + bodySchema := &schema.BodySchema{ + Attributes: tc.attrSchema, + } + + f, _ := hclsyntax.ParseConfig([]byte(tc.cfg), "test.tf", hcl.InitialPos) + d := testPathDecoder(t, &PathContext{ + Schema: bodySchema, + Files: map[string]*hcl.File{ + "test.tf": f, + }, + ReferenceOrigins: tc.refOrigins, + ReferenceTargets: tc.refTargets, + }) + + ctx := context.Background() + tokens, err := d.SemanticTokensInFile(ctx, "test.tf") + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(tc.expectedSemanticTokens, tokens); diff != "" { + t.Fatalf("unexpected tokens: %s", diff) + } + }) + } +} diff --git a/decoder/expression.go b/decoder/expression.go index 7f268b56..2c5e252e 100644 --- a/decoder/expression.go +++ b/decoder/expression.go @@ -127,8 +127,9 @@ func newExpression(pathContext *PathContext, expr hcl.Expression, cons schema.Co } case schema.Reference: return Reference{ - expr: expr, - cons: c, + expr: expr, + cons: c, + pathCtx: pathContext, } case schema.List: return List{ diff --git a/reference/target.go b/reference/target.go index 1723a077..e9ba897b 100644 --- a/reference/target.go +++ b/reference/target.go @@ -137,6 +137,10 @@ func (r Target) TargetRange() (hcl.Range, bool) { return *r.RangePtr, true } +func (target Target) MatchesConstraint(ref schema.Reference) bool { + return target.MatchesScopeId(ref.OfScopeId) && target.ConformsToType(ref.OfType) +} + func (ref Target) LegacyMatchesConstraint(te schema.TraversalExpr) bool { return ref.MatchesScopeId(te.OfScopeId) && ref.ConformsToType(te.OfType) } diff --git a/reference/targets.go b/reference/targets.go index 373e5bc8..18cffbe4 100644 --- a/reference/targets.go +++ b/reference/targets.go @@ -85,6 +85,91 @@ func (refs Targets) LegacyMatchWalk(ctx context.Context, te schema.TraversalExpr } } +func (targets Targets) MatchWalk(ctx context.Context, ref schema.Reference, prefix string, outermostBodyRng, originRng hcl.Range, f TargetWalkFunc) { + for _, target := range targets { + if localTargetMatches(ctx, target, ref, prefix, outermostBodyRng, originRng) || + absTargetMatches(ctx, target, ref, prefix, outermostBodyRng, originRng) { + f(target) + continue + } + + target.NestedTargets.MatchWalk(ctx, ref, prefix, outermostBodyRng, originRng, f) + } +} + +func localTargetMatches(ctx context.Context, target Target, ref schema.Reference, prefix string, outermostBodyRng, originRng hcl.Range) bool { + if len(target.LocalAddr) > 0 && strings.HasPrefix(target.LocalAddr.String(), prefix) { + // reject self references if not enabled + if !schema.ActiveSelfRefsFromContext(ctx) && target.LocalAddr[0].String() == "self" { + return false + } + + hasNestedMatches := target.NestedTargets.containsMatch(ctx, ref, prefix, outermostBodyRng, originRng) + + // Avoid suggesting cyclical reference to the same attribute + // unless it has nested matches - i.e. still consider reference + // to the outside block/body as valid. + // + // For example, block { foo = self } where "self" refers to the "block" + // is considered valid. The use case this is important for is + // Terraform's self references inside nested block such as "connection". + if target.RangePtr != nil && !hasNestedMatches { + if rangeOverlaps(*target.RangePtr, originRng) { + return false + } + // We compare line in case the (incomplete) attribute + // ends w/ whitespace which wouldn't be included in the range + if target.RangePtr.Filename == originRng.Filename && + target.RangePtr.End.Line == originRng.Start.Line { + return false + } + } + + // Reject origins which are outside the targetable range + if target.TargetableFromRangePtr != nil && !rangeOverlaps(*target.TargetableFromRangePtr, originRng) { + return false + } + + if target.MatchesConstraint(ref) || hasNestedMatches { + return true + } + } + + return false +} + +func absTargetMatches(ctx context.Context, target Target, ref schema.Reference, prefix string, outermostBodyRng, originRng hcl.Range) bool { + if len(target.Addr) > 0 && strings.HasPrefix(target.Addr.String(), prefix) { + // Reject references to block's own fields from within the body + if referenceTargetIsInRange(target, outermostBodyRng) { + return false + } + + if target.MatchesConstraint(ref) || target.NestedTargets.containsMatch(ctx, ref, prefix, outermostBodyRng, originRng) { + return true + } + } + return false +} + +func (targets Targets) containsMatch(ctx context.Context, ref schema.Reference, prefix string, outermostBodyRng, originRng hcl.Range) bool { + for _, target := range targets { + if localTargetMatches(ctx, target, ref, prefix, outermostBodyRng, originRng) { + return true + } + if absTargetMatches(ctx, target, ref, prefix, outermostBodyRng, originRng) { + return true + } + + if len(target.NestedTargets) > 0 { + if match := target.NestedTargets.containsMatch(ctx, ref, prefix, outermostBodyRng, originRng); match { + return true + } + } + } + return false +} + func legacyLocalTargetMatches(ctx context.Context, target Target, te schema.TraversalExpr, prefix string, outermostBodyRng, originRng hcl.Range) bool { if len(target.LocalAddr) > 0 && strings.HasPrefix(target.LocalAddr.String(), prefix) { // reject self references if not enabled diff --git a/reference/traversal.go b/reference/traversal.go index 4811baa0..173a42e4 100644 --- a/reference/traversal.go +++ b/reference/traversal.go @@ -6,6 +6,41 @@ import ( "github.com/hashicorp/hcl/v2" ) +func TraversalToLocalOrigin(traversal hcl.Traversal, cons schema.Reference, allowSelfRefs bool) (LocalOrigin, bool) { + // traversal should not be relative here, since the input to this + // function `expr.Variables()` only returns absolute traversals + if !traversal.IsRelative() && traversal.RootName() == "self" && !allowSelfRefs { + // Only if a block allows the usage of self.* we create a origin, + // else just continue + return LocalOrigin{}, false + } + + addr, err := lang.TraversalToAddress(traversal) + if err != nil { + return LocalOrigin{}, false + } + + return LocalOrigin{ + Addr: addr, + Range: traversal.SourceRange(), + Constraints: refrenceConstraintToOriginConstraints(cons), + }, true +} + +func refrenceConstraintToOriginConstraints(cons schema.Reference) OriginConstraints { + if cons.Address != nil { + // skip traversals which are targets by themselves (not origins) + return OriginConstraints{} + } + + return []OriginConstraint{ + { + OfType: cons.OfType, + OfScopeId: cons.OfScopeId, + }, + } +} + func LegacyTraversalsToLocalOrigins(traversals []hcl.Traversal, tes schema.TraversalExprs, allowSelfRefs bool) Origins { origins := make(Origins, 0) for _, traversal := range traversals { diff --git a/schema/constraint_reference.go b/schema/constraint_reference.go index f89af200..2d181f00 100644 --- a/schema/constraint_reference.go +++ b/schema/constraint_reference.go @@ -67,8 +67,11 @@ func (ref Reference) Copy() Constraint { } func (ref Reference) EmptyCompletionData(ctx context.Context, nextPlaceholder int, nestingLevel int) CompletionData { - // TODO - return CompletionData{} + return CompletionData{ + NewText: "", + Snippet: "", + TriggerSuggest: true, + } } func (ref Reference) Validate() error {