Files
opentf/internal/lang/lint/object_extra_attrs_test.go
Martin Atkins 9895f54ed0 lint: Include more source context in unused attribute diagnostics
From some more practical testing of this I realized that usually the first
thing I want to know after seeing this warning is what the object literal
was being assigned to and what else was also defined inside it, and so
this sets the diagnostic's "context" to include the whole containing
object literal so that the source snippet in the diagnostic message is more
immediately useful, without having to cross-reference to the source code
in a separate text editor.

Signed-off-by: Martin Atkins <mart@degeneration.co.uk>
2025-09-26 09:08:31 -07:00

417 lines
12 KiB
Go

// Copyright (c) The OpenTofu Authors
// SPDX-License-Identifier: MPL-2.0
// Copyright (c) 2023 HashiCorp, Inc.
// SPDX-License-Identifier: MPL-2.0
package lint
import (
"slices"
"testing"
"github.com/google/go-cmp/cmp"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty-debug/ctydebug"
"github.com/zclconf/go-cty/cty"
"github.com/opentofu/opentofu/internal/tfdiags"
)
func TestDiscardedObjectConstructorAttrs(t *testing.T) {
tests := map[string]struct {
exprSrc string
targetTy cty.Type
want []DiscardedObjectConstructorAttr
}{
// Simple, shallow cases
"unexpected attr for empty object type": {
`{
foo = "bar"
}`,
cty.EmptyObject,
[]DiscardedObjectConstructorAttr{
{
Path: cty.GetAttrPath("foo"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 5, Byte: 6},
End: tfdiags.SourcePos{Line: 2, Column: 8, Byte: 9},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 3, Column: 5, Byte: 22},
},
TargetType: cty.EmptyObject,
},
},
},
"unexpected attr for non-empty object type": {
`{
foo = "bar"
}`,
cty.Object(map[string]cty.Type{
"bar": cty.String,
}),
[]DiscardedObjectConstructorAttr{
{
Path: cty.GetAttrPath("foo"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 5, Byte: 6},
End: tfdiags.SourcePos{Line: 2, Column: 8, Byte: 9},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 3, Column: 5, Byte: 22},
},
TargetType: cty.Object(map[string]cty.Type{
"bar": cty.String,
}),
},
},
},
"empty constructor for empty object type": {
`{}`,
cty.EmptyObject,
nil,
},
"primitive type": {
// This case is irrelevant to this particular lint, so we're just
// testing that it successfully returns nothing rather than doing
// something undesirable like panicking.
`"hello"`,
cty.String,
nil,
},
// Nested in list
"nested in list, tuple-cons": {
`[
{foo = "bar"},
{baz = "beep"},
]`,
cty.List(cty.EmptyObject),
[]DiscardedObjectConstructorAttr{
{
Path: cty.IndexIntPath(0).GetAttr("foo"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 6, Byte: 7},
End: tfdiags.SourcePos{Line: 2, Column: 9, Byte: 10},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 5, Byte: 6},
End: tfdiags.SourcePos{Line: 2, Column: 18, Byte: 19},
},
TargetType: cty.EmptyObject,
},
{
Path: cty.IndexIntPath(1).GetAttr("baz"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 6, Byte: 26},
End: tfdiags.SourcePos{Line: 3, Column: 9, Byte: 29},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 5, Byte: 25},
End: tfdiags.SourcePos{Line: 3, Column: 19, Byte: 39},
},
TargetType: cty.EmptyObject,
},
},
},
"nested in list, tuple-for": {
`[
for x in [] : {
foo = "bar"
}
]`,
cty.List(cty.EmptyObject),
[]DiscardedObjectConstructorAttr{
{
Path: cty.IndexPath(cty.UnknownVal(cty.Number)).GetAttr("foo"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 6, Byte: 27},
End: tfdiags.SourcePos{Line: 3, Column: 9, Byte: 30},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 19, Byte: 20},
End: tfdiags.SourcePos{Line: 4, Column: 6, Byte: 44},
},
TargetType: cty.EmptyObject,
},
},
},
"nested in list, object-for": {
`{
for x in [] : x => {
foo = "bar"
}
}`,
cty.List(cty.EmptyObject),
// Can't define a list value using object-for, so no results here
nil,
},
// Nested in set
"nested in set, tuple-cons": {
`[
{foo = "bar"},
{baz = "beep"},
]`,
cty.Set(cty.EmptyObject),
[]DiscardedObjectConstructorAttr{
{
Path: cty.IndexIntPath(0).GetAttr("foo"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 6, Byte: 7},
End: tfdiags.SourcePos{Line: 2, Column: 9, Byte: 10},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 5, Byte: 6},
End: tfdiags.SourcePos{Line: 2, Column: 18, Byte: 19},
},
TargetType: cty.EmptyObject,
},
{
Path: cty.IndexIntPath(1).GetAttr("baz"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 6, Byte: 26},
End: tfdiags.SourcePos{Line: 3, Column: 9, Byte: 29},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 5, Byte: 25},
End: tfdiags.SourcePos{Line: 3, Column: 19, Byte: 39},
},
TargetType: cty.EmptyObject,
},
},
},
"nested in set, tuple-for": {
`[
for x in [] : {
foo = "bar"
}
]`,
cty.Set(cty.EmptyObject),
[]DiscardedObjectConstructorAttr{
{
Path: cty.IndexPath(cty.UnknownVal(cty.Number)).GetAttr("foo"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 6, Byte: 27},
End: tfdiags.SourcePos{Line: 3, Column: 9, Byte: 30},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 19, Byte: 20},
End: tfdiags.SourcePos{Line: 4, Column: 6, Byte: 44},
},
TargetType: cty.EmptyObject,
},
},
},
"nested in set, object-for": {
`{
for x in [] : x => {
foo = "bar"
}
}`,
cty.Set(cty.EmptyObject),
// Can't define a list value using object-for, so no results here
nil,
},
// Nested in map
"nested in map, object-cons": {
`{
a = {foo = "bar"},
b = {baz = "beep"},
}`,
cty.Map(cty.EmptyObject),
[]DiscardedObjectConstructorAttr{
{
Path: cty.IndexStringPath("a").GetAttr("foo"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 10, Byte: 11},
End: tfdiags.SourcePos{Line: 2, Column: 13, Byte: 14},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 9, Byte: 10},
End: tfdiags.SourcePos{Line: 2, Column: 22, Byte: 23},
},
TargetType: cty.EmptyObject,
},
{
Path: cty.IndexStringPath("b").GetAttr("baz"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 10, Byte: 34},
End: tfdiags.SourcePos{Line: 3, Column: 13, Byte: 37},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 9, Byte: 33},
End: tfdiags.SourcePos{Line: 3, Column: 23, Byte: 47},
},
TargetType: cty.EmptyObject,
},
},
},
"nested in map, object-for": {
`{
for x in [] : x => {
foo = "bar"
}
}`,
cty.Map(cty.EmptyObject),
[]DiscardedObjectConstructorAttr{
{
Path: cty.IndexPath(cty.UnknownVal(cty.String)).GetAttr("foo"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 6, Byte: 32},
End: tfdiags.SourcePos{Line: 3, Column: 9, Byte: 35},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 24, Byte: 25},
End: tfdiags.SourcePos{Line: 4, Column: 6, Byte: 49},
},
TargetType: cty.EmptyObject,
},
},
},
"nested in map, tuple-for": {
`[
for x in [] : {
foo = "bar"
}
]`,
cty.Map(cty.EmptyObject),
// Can't define a map value using tuple-for, so no results here
nil,
},
// Nested in tuple
"nested in tuple, tuple-cons": {
`[
{foo = "bar"},
{baz = "beep"},
]`,
cty.Tuple([]cty.Type{
cty.EmptyObject,
cty.Object(map[string]cty.Type{"not_baz": cty.String}),
}),
[]DiscardedObjectConstructorAttr{
{
Path: cty.IndexIntPath(0).GetAttr("foo"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 6, Byte: 7},
End: tfdiags.SourcePos{Line: 2, Column: 9, Byte: 10},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 5, Byte: 6},
End: tfdiags.SourcePos{Line: 2, Column: 18, Byte: 19},
},
TargetType: cty.EmptyObject,
},
{
Path: cty.IndexIntPath(1).GetAttr("baz"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 6, Byte: 26},
End: tfdiags.SourcePos{Line: 3, Column: 9, Byte: 29},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 5, Byte: 25},
End: tfdiags.SourcePos{Line: 3, Column: 19, Byte: 39},
},
TargetType: cty.Object(map[string]cty.Type{"not_baz": cty.String}),
},
},
},
"nested in tuple, tuple-for": {
`[
for x in [] : {
foo = "bar"
}
]`,
cty.Tuple([]cty.Type{
cty.EmptyObject,
cty.Object(map[string]cty.Type{"not_foo": cty.String}),
}),
// We don't do any recursive analysis of a tuple type defined
// by a tuple-for expression, because we can't correlate the
// dynamic elements of a for expression with the static elements
// of a tuple type.
nil,
},
// Nested in another object
"nested in object-cons": {
`{
a = {
foo = "bar"
}
b = {
baz = "beep"
}
c = {}
}`,
cty.Object(map[string]cty.Type{
"a": cty.EmptyObject,
"b": cty.Object(map[string]cty.Type{"not_baz": cty.String}),
// "c" intentionally omitted; we're testing a mixture of
// both nested and non-nested at the same time.
}),
[]DiscardedObjectConstructorAttr{
{
Path: cty.GetAttrPath("a").GetAttr("foo"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 3, Column: 6, Byte: 17},
End: tfdiags.SourcePos{Line: 3, Column: 9, Byte: 20},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 2, Column: 9, Byte: 10},
End: tfdiags.SourcePos{Line: 4, Column: 6, Byte: 34},
},
TargetType: cty.EmptyObject,
},
{
Path: cty.GetAttrPath("b").GetAttr("baz"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 6, Column: 6, Byte: 50},
End: tfdiags.SourcePos{Line: 6, Column: 9, Byte: 53},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 5, Column: 9, Byte: 43},
End: tfdiags.SourcePos{Line: 7, Column: 6, Byte: 68},
},
TargetType: cty.Object(map[string]cty.Type{"not_baz": cty.String}),
},
{
Path: cty.GetAttrPath("c"),
NameRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 8, Column: 5, Byte: 73},
End: tfdiags.SourcePos{Line: 8, Column: 6, Byte: 74},
},
ContextRange: tfdiags.SourceRange{
Start: tfdiags.SourcePos{Line: 1, Column: 1, Byte: 0},
End: tfdiags.SourcePos{Line: 9, Column: 5, Byte: 84},
},
TargetType: cty.Object(map[string]cty.Type{
"a": cty.EmptyObject,
"b": cty.Object(map[string]cty.Type{"not_baz": cty.String}),
}),
},
},
},
}
for name, test := range tests {
t.Run(name, func(t *testing.T) {
expr, hclDiags := hclsyntax.ParseExpression([]byte(test.exprSrc), "", hcl.InitialPos)
if hclDiags.HasErrors() {
t.Fatal("unexpected syntax errors: " + hclDiags.Error())
}
got := slices.Collect(DiscardedObjectConstructorAttrs(expr, test.targetTy))
if diff := cmp.Diff(test.want, got, ctydebug.CmpOptions); diff != "" {
t.Error("wrong result\n" + diff)
}
})
}
}