Skip to content

Commit 97d54a9

Browse files
hclsyntax: Improve conditional type mismatch errors (somewhat)
For a long time now we've had a very simplistic error message for the case of conditional expression result arms not having the same type, which only works for situations where the two types have differing "friendly names" down in the cty layer. Unfortunately due to the typical complexity of the structural type kinds (object and tuple types) their friendly names are just "object" and "tuple", which tends to lead us to seemingly-incorrect error messages like: The true and false result expressions must have consistent types. The given expressions are object and object, respectively. This then is an attempt to use some more specialized messaging in some of the situations that led to that sort of weird message before. In particular, this handles: - both types are object types but their attributes don't match - both types are tuple types but their elements don't match - both types are the same kind of collection of either object or tuple types which don't match These are the three _shallow_ cases that the previous logic wasn't able to properly describe. This still leaves unaddressed a hopefully-less-common case of nested collections with differing structural types in their depths, but still avoids generating a confusing error message by instead generating a _very vague but still correct_ error message: At least one deeply-nested attribute or element is not compatible across both the 'true' and the 'false' value. My intent here is to make HCL return something precise enough _most of the time_, without letting perfect be the enemy of the good. This will generate some quite obnoxious long messages for particularly complex nested structures, but so far it appears that such values are relatively rare inside conditional expressions and so we'll wait to see what arises in practice before trying to handle those situations more concisely. Ideally I would like to include some actionable feedback that in some cases it can help to explicitly convert ambiguously-typed expressions like "null" or tuples intended to be lists to the intended type, so that the type unification step has more information to infer the author intent. However, HCL itself doesn't have any builtins for such conversions and so today any messaging about that would need to be generated up at the application layer so the application can refer to whatever functions/etc it provides for type conversion. It isn't clear how to do that with the current design, so we'll leave that to be addressed another day.
1 parent 2ef09d1 commit 97d54a9

2 files changed

Lines changed: 263 additions & 7 deletions

File tree

hclsyntax/expression.go

Lines changed: 142 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package hclsyntax
22

33
import (
44
"fmt"
5+
"sort"
56
"sync"
67

78
"github.com/hashicorp/hcl/v2"
@@ -615,12 +616,8 @@ func (e *ConditionalExpr) Value(ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostic
615616
Severity: hcl.DiagError,
616617
Summary: "Inconsistent conditional result types",
617618
Detail: fmt.Sprintf(
618-
// FIXME: Need a helper function for showing natural-language type diffs,
619-
// since this will generate some useless messages in some cases, like
620-
// "These expressions are object and object respectively" if the
621-
// object types don't exactly match.
622-
"The true and false result expressions must have consistent types. The given expressions are %s and %s, respectively.",
623-
trueResult.Type().FriendlyName(), falseResult.Type().FriendlyName(),
619+
"The true and false result expressions must have consistent types. %s.",
620+
describeConditionalTypeMismatch(trueResult.Type(), falseResult.Type()),
624621
),
625622
Subject: hcl.RangeBetween(e.TrueResult.Range(), e.FalseResult.Range()).Ptr(),
626623
Context: &e.SrcRange,
@@ -652,7 +649,7 @@ func (e *ConditionalExpr) Value(ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostic
652649
diags = append(diags, &hcl.Diagnostic{
653650
Severity: hcl.DiagError,
654651
Summary: "Incorrect condition type",
655-
Detail: fmt.Sprintf("The condition expression must be of type bool."),
652+
Detail: "The condition expression must be of type bool.",
656653
Subject: e.Condition.Range().Ptr(),
657654
Context: &e.SrcRange,
658655
Expression: e.Condition,
@@ -712,6 +709,144 @@ func (e *ConditionalExpr) Value(ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostic
712709
}
713710
}
714711

712+
// describeConditionalTypeMismatch makes a best effort to describe the
713+
// difference between types in the true and false arms of a conditional
714+
// expression in a way that would be useful to someone trying to understand
715+
// why their conditional expression isn't valid.
716+
//
717+
// NOTE: This function is only designed to deal with situations
718+
// where trueTy and falseTy are different. Calling it with two equal
719+
// types will produce a nonsense result. This function also only really
720+
// deals with situations that type unification can't resolve, so we should
721+
// call this function only after trying type unification first.
722+
func describeConditionalTypeMismatch(trueTy, falseTy cty.Type) string {
723+
// The main tricky cases here are when both trueTy and falseTy are
724+
// of the same structural type kind, such as both being object types
725+
// or both being tuple types. In that case the "FriendlyName" method
726+
// returns only "object" or "tuple" and so we need to do some more
727+
// work to describe what's different inside them.
728+
729+
switch {
730+
case trueTy.IsObjectType() && falseTy.IsObjectType():
731+
// We'll first gather up the attribute names and sort them. In the
732+
// event that there are multiple attributes that disagree across
733+
// the two types, we'll prefer to report the one that sorts lexically
734+
// least just so that our error message is consistent between
735+
// evaluations.
736+
var trueAttrs, falseAttrs []string
737+
for name := range trueTy.AttributeTypes() {
738+
trueAttrs = append(trueAttrs, name)
739+
}
740+
sort.Strings(trueAttrs)
741+
for name := range falseTy.AttributeTypes() {
742+
falseAttrs = append(falseAttrs, name)
743+
}
744+
sort.Strings(falseAttrs)
745+
746+
for _, name := range trueAttrs {
747+
if !falseTy.HasAttribute(name) {
748+
return fmt.Sprintf("The 'true' value includes object attribute %q, which is absent in the 'false' value", name)
749+
}
750+
trueAty := trueTy.AttributeType(name)
751+
falseAty := falseTy.AttributeType(name)
752+
if !trueAty.Equals(falseAty) {
753+
// For deeply-nested differences this will likely get very
754+
// clunky quickly by nesting these messages inside one another,
755+
// but we'll accept that for now in the interests of producing
756+
// _some_ useful feedback, even if it isn't as concise as
757+
// we'd prefer it to be. Deeply-nested structures in
758+
// conditionals are thankfully not super common.
759+
return fmt.Sprintf(
760+
"Type mismatch for object attribute %q: %s",
761+
name, describeConditionalTypeMismatch(trueAty, falseAty),
762+
)
763+
}
764+
}
765+
for _, name := range falseAttrs {
766+
if !trueTy.HasAttribute(name) {
767+
return fmt.Sprintf("The 'false' value includes object attribute %q, which is absent in the 'true' value", name)
768+
}
769+
// NOTE: We don't need to check the attribute types again, because
770+
// any attribute that both types have in common would already have
771+
// been checked in the previous loop.
772+
}
773+
case trueTy.IsTupleType() && falseTy.IsTupleType():
774+
trueEtys := trueTy.TupleElementTypes()
775+
falseEtys := falseTy.TupleElementTypes()
776+
777+
if trueCount, falseCount := len(trueEtys), len(falseEtys); trueCount != falseCount {
778+
return fmt.Sprintf("The 'true' tuple has length %d, but the 'false' tuple has length %d", trueCount, falseCount)
779+
}
780+
781+
// NOTE: Thanks to the condition above, we know that both tuples are
782+
// of the same length and so they must have some differing types
783+
// instead.
784+
for i := range trueEtys {
785+
trueEty := trueEtys[i]
786+
falseEty := falseEtys[i]
787+
788+
if !trueEty.Equals(falseEty) {
789+
// For deeply-nested differences this will likely get very
790+
// clunky quickly by nesting these messages inside one another,
791+
// but we'll accept that for now in the interests of producing
792+
// _some_ useful feedback, even if it isn't as concise as
793+
// we'd prefer it to be. Deeply-nested structures in
794+
// conditionals are thankfully not super common.
795+
return fmt.Sprintf(
796+
"Type mismatch for tuple element %d: %s",
797+
i, describeConditionalTypeMismatch(trueEty, falseEty),
798+
)
799+
}
800+
}
801+
case trueTy.IsCollectionType() && falseTy.IsCollectionType():
802+
// For this case we're specifically interested in the situation where:
803+
// - both collections are of the same kind, AND
804+
// - the element types of both are either object or tuple types.
805+
// This is just to avoid writing a useless statement like
806+
// "The 'true' value is list of object, but the 'false' value is list of object".
807+
// This still doesn't account for more awkward cases like collections
808+
// of collections of structural types, but we won't let perfect be
809+
// the enemy of the good.
810+
trueEty := trueTy.ElementType()
811+
falseEty := falseTy.ElementType()
812+
if (trueTy.IsListType() && falseTy.IsListType()) || (trueTy.IsMapType() && falseTy.IsMapType()) || (trueTy.IsSetType() && falseTy.IsSetType()) {
813+
if (trueEty.IsObjectType() && falseEty.IsObjectType()) || (trueEty.IsTupleType() && falseEty.IsTupleType()) {
814+
noun := "collection"
815+
switch { // NOTE: We now know that trueTy and falseTy have the same collection kind
816+
case trueTy.IsListType():
817+
noun = "list"
818+
case trueTy.IsSetType():
819+
noun = "set"
820+
case trueTy.IsMapType():
821+
noun = "map"
822+
}
823+
return fmt.Sprintf(
824+
"Mismatched %s element types: %s",
825+
noun, describeConditionalTypeMismatch(trueEty, falseEty),
826+
)
827+
}
828+
}
829+
}
830+
831+
// If we don't manage any more specialized message, we'll just report
832+
// what the two types are.
833+
trueName := trueTy.FriendlyName()
834+
falseName := falseTy.FriendlyName()
835+
if trueName == falseName {
836+
// Absolute last resort for when we have no special rule above but
837+
// we have two types with the same friendly name anyway. This is
838+
// the most vague of all possible messages but is reserved for
839+
// particularly awkward cases, like lists of lists of differing tuple
840+
// types.
841+
return "At least one deeply-nested attribute or element is not compatible across both the 'true' and the 'false' value"
842+
}
843+
return fmt.Sprintf(
844+
"The 'true' value is %s, but the 'false' value is %s",
845+
trueTy.FriendlyName(), falseTy.FriendlyName(),
846+
)
847+
848+
}
849+
715850
func (e *ConditionalExpr) Range() hcl.Range {
716851
return e.SrcRange
717852
}

hclsyntax/expression_test.go

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1892,6 +1892,127 @@ EOT
18921892

18931893
}
18941894

1895+
func TestExpressionErrorMessages(t *testing.T) {
1896+
tests := []struct {
1897+
input string
1898+
ctx *hcl.EvalContext
1899+
wantSummary string
1900+
wantDetail string
1901+
}{
1902+
// Error messages describing inconsistent result types for conditional expressions.
1903+
{
1904+
"true ? 1 : true",
1905+
nil,
1906+
"Inconsistent conditional result types",
1907+
"The true and false result expressions must have consistent types. The 'true' value is number, but the 'false' value is bool.",
1908+
},
1909+
{
1910+
"true ? [1] : [true]",
1911+
nil,
1912+
"Inconsistent conditional result types",
1913+
"The true and false result expressions must have consistent types. Type mismatch for tuple element 0: The 'true' value is number, but the 'false' value is bool.",
1914+
},
1915+
{
1916+
"true ? [1] : [1, true]",
1917+
nil,
1918+
"Inconsistent conditional result types",
1919+
"The true and false result expressions must have consistent types. The 'true' tuple has length 1, but the 'false' tuple has length 2.",
1920+
},
1921+
{
1922+
"true ? { a = 1 } : { a = true }",
1923+
nil,
1924+
"Inconsistent conditional result types",
1925+
"The true and false result expressions must have consistent types. Type mismatch for object attribute \"a\": The 'true' value is number, but the 'false' value is bool.",
1926+
},
1927+
{
1928+
"true ? { a = true, b = 1 } : { a = true }",
1929+
nil,
1930+
"Inconsistent conditional result types",
1931+
"The true and false result expressions must have consistent types. The 'true' value includes object attribute \"b\", which is absent in the 'false' value.",
1932+
},
1933+
{
1934+
"true ? { a = true } : { a = true, b = 1 }",
1935+
nil,
1936+
"Inconsistent conditional result types",
1937+
"The true and false result expressions must have consistent types. The 'false' value includes object attribute \"b\", which is absent in the 'true' value.",
1938+
},
1939+
{
1940+
"true ? listOf1Tuple : listOf0Tuple",
1941+
&hcl.EvalContext{
1942+
Variables: map[string]cty.Value{
1943+
"listOf1Tuple": cty.ListVal([]cty.Value{cty.TupleVal([]cty.Value{cty.True})}),
1944+
"listOf0Tuple": cty.ListVal([]cty.Value{cty.EmptyTupleVal}),
1945+
},
1946+
},
1947+
"Inconsistent conditional result types",
1948+
"The true and false result expressions must have consistent types. Mismatched list element types: The 'true' tuple has length 1, but the 'false' tuple has length 0.",
1949+
},
1950+
{
1951+
"true ? setOf1Tuple : setOf0Tuple",
1952+
&hcl.EvalContext{
1953+
Variables: map[string]cty.Value{
1954+
"setOf1Tuple": cty.SetVal([]cty.Value{cty.TupleVal([]cty.Value{cty.True})}),
1955+
"setOf0Tuple": cty.SetVal([]cty.Value{cty.EmptyTupleVal}),
1956+
},
1957+
},
1958+
"Inconsistent conditional result types",
1959+
"The true and false result expressions must have consistent types. Mismatched set element types: The 'true' tuple has length 1, but the 'false' tuple has length 0.",
1960+
},
1961+
{
1962+
"true ? mapOf1Tuple : mapOf2Tuple",
1963+
&hcl.EvalContext{
1964+
Variables: map[string]cty.Value{
1965+
"mapOf1Tuple": cty.MapVal(map[string]cty.Value{"a": cty.TupleVal([]cty.Value{cty.True})}),
1966+
"mapOf2Tuple": cty.MapVal(map[string]cty.Value{"a": cty.TupleVal([]cty.Value{cty.True, cty.Zero})}),
1967+
},
1968+
},
1969+
"Inconsistent conditional result types",
1970+
"The true and false result expressions must have consistent types. Mismatched map element types: The 'true' tuple has length 1, but the 'false' tuple has length 2.",
1971+
},
1972+
{
1973+
"true ? listOfListOf1Tuple : listOfListOf0Tuple",
1974+
&hcl.EvalContext{
1975+
Variables: map[string]cty.Value{
1976+
"listOfListOf1Tuple": cty.ListVal([]cty.Value{cty.ListVal([]cty.Value{cty.TupleVal([]cty.Value{cty.True})})}),
1977+
"listOfListOf0Tuple": cty.ListVal([]cty.Value{cty.ListVal([]cty.Value{cty.EmptyTupleVal})}),
1978+
},
1979+
},
1980+
"Inconsistent conditional result types",
1981+
// This is our totally non-specific last-resort of an error message,
1982+
// for situations that are too complex for any of our rules to
1983+
// describe coherently.
1984+
"The true and false result expressions must have consistent types. At least one deeply-nested attribute or element is not compatible across both the 'true' and the 'false' value.",
1985+
},
1986+
}
1987+
1988+
for _, test := range tests {
1989+
t.Run(test.input, func(t *testing.T) {
1990+
var diags hcl.Diagnostics
1991+
expr, parseDiags := ParseExpression([]byte(test.input), "", hcl.Pos{Line: 1, Column: 1, Byte: 0})
1992+
diags = append(diags, parseDiags...)
1993+
_, valDiags := expr.Value(test.ctx)
1994+
diags = append(diags, valDiags...)
1995+
1996+
if !diags.HasErrors() {
1997+
t.Fatalf("unexpected success\nwant error:\n%s; %s", test.wantSummary, test.wantDetail)
1998+
}
1999+
2000+
for _, diag := range diags {
2001+
if diag.Severity != hcl.DiagError {
2002+
continue
2003+
}
2004+
if diag.Summary == test.wantSummary && diag.Detail == test.wantDetail {
2005+
// Success! We'll return early to conclude this test case.
2006+
return
2007+
}
2008+
}
2009+
// If we fall out here then we didn't find the diagnostic
2010+
// we were looking for.
2011+
t.Fatalf("missing expected error\ngot:\n%s\n\nwant error:\n%s; %s", diags.Error(), test.wantSummary, test.wantDetail)
2012+
})
2013+
}
2014+
}
2015+
18952016
func TestFunctionCallExprValue(t *testing.T) {
18962017
funcs := map[string]function.Function{
18972018
"length": stdlib.StrlenFunc,

0 commit comments

Comments
 (0)