-
Notifications
You must be signed in to change notification settings - Fork 602
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
hcl: Allow individual diagnostics to carry extra information
The primary goal of the diagnostics design in HCL is to return high-quality diagnostics messages primarily for human consumption, and so their regular structure is only machine-processable in a general sense where we treat all diagnostics as subject to the same processing. A few times now we've ended up wanting to carry some additional optional contextual information along with the diagnostic, for example so that a more advanced diagnostics renderer might optionally annotate a diagnostic with extra notes to help the reader debug. We got pretty far with our previous extension of hcl.Diagnostic to include the Expression and EvalContext fields, which allow an advanced diagnostic renderer to offer hints about what values contributed to the expression that failed, but some context is even more specific than that, or is defined by the application itself and therefore not appropriate to model directly here in HCL. As a pragmatic compromise then, here we introduce one more field Extra to hcl.Diagnostic, which comes with a documented convention of placing into it situation-specific values that implement particular interfaces, and therefore a diagnostics renderer or other consumer can potentially "sniff" this field for particular interfaces it knows about and treat them in a special way if present. Since there is only one field here that might end up being asked to capture multiple extra values as the call stack unwinds, there is also a simple predefined protocol for "unwrapping" extra values in order to find nested implementations within. For callers that are prepared to require Go 1.18, the helper function hcl.DiagnosticExtra provides a type-assertion-like mechanism for sniffing for a particular interface type while automatically respecting the nesting protocol. For the moment that function lives behind a build constraint so that callers which are not yet ready to use Go 1.18 can continue to use other parts of HCL, and can implement a non-generic equivalent of this function within their own codebase if absolutely necessary. As an initial example to demonstrate the idea I've also implemented some extra information for error diagnostics returned from FunctionCallExpr, which gives the name of the function being called and, if the diagnostic is describing an error returned by the function itself, a direct reference to the raw error value returned from the function call. I anticipate a diagnostic renderer sniffing for hclsyntax.FunctionCallDiagExtra to see if a particular diagnostic is related to a function call, and if so to include additional context about the signature of that function in the diagnostic messages (by correlating with the function in the EvalContext functions table). For example: While calling: join(separator, list) An example application-specific "extra value" could be for Terraform to annotate diagnostics that relate to situations where an unknown value is invalid, or where a "sensitive" value (a Terraform-specific value mark) is invalid, so that the diagnostic renderer can avoid distracting users with "red herring" commentary about unknown or sensitive values unless they seem likely to be relevant to the error being printed.
- Loading branch information
1 parent
986b881
commit 88ecd13
Showing
5 changed files
with
237 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,39 @@ | ||
//go:build go1.18 | ||
// +build go1.18 | ||
|
||
package hcl | ||
|
||
// This file contains additional diagnostics-related symbols that use the | ||
// Go 1.18 type parameters syntax and would therefore be incompatible with | ||
// Go 1.17 and earlier. | ||
|
||
// DiagnosticExtra attempts to retrieve an "extra value" of type T from the | ||
// given diagnostic, if either the diag.Extra field directly contains a value | ||
// of that type or the value implements DiagnosticExtraUnwrapper and directly | ||
// or indirectly returns a value of that type. | ||
// | ||
// Type T should typically be an interface type, so that code which generates | ||
// diagnostics can potentially return different implementations of the same | ||
// interface dynamically as needed. | ||
// | ||
// If a value of type T is found, returns that value and true to indicate | ||
// success. Otherwise, returns the zero value of T and false to indicate | ||
// failure. | ||
func DiagnosticExtra[T any](diag *Diagnostic) (T, bool) { | ||
extra := diag.Extra | ||
var zero T | ||
|
||
for { | ||
if ret, ok := extra.(T); ok { | ||
return ret, true | ||
} | ||
|
||
if unwrap, ok := extra.(DiagnosticExtraUnwrapper); ok { | ||
// If our "extra" implements DiagnosticExtraUnwrapper then we'll | ||
// unwrap one level and try this again. | ||
extra = unwrap.UnwrapDiagnosticExtra() | ||
} else { | ||
return zero, false | ||
} | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,102 @@ | ||
//go:build go1.18 | ||
// +build go1.18 | ||
|
||
package hclsyntax | ||
|
||
import ( | ||
"fmt" | ||
"testing" | ||
|
||
"github.com/hashicorp/hcl/v2" | ||
"github.com/zclconf/go-cty/cty" | ||
"github.com/zclconf/go-cty/cty/function" | ||
) | ||
|
||
// This file contains some additional tests that only make sense when using | ||
// a Go compiler which supports type parameters (Go 1.18 or later). | ||
|
||
func TestExpressionDiagnosticExtra(t *testing.T) { | ||
tests := []struct { | ||
input string | ||
ctx *hcl.EvalContext | ||
assert func(t *testing.T, diags hcl.Diagnostics) | ||
}{ | ||
// Error messages describing inconsistent result types for conditional expressions. | ||
{ | ||
"boop()", | ||
&hcl.EvalContext{ | ||
Functions: map[string]function.Function{ | ||
"boop": function.New(&function.Spec{ | ||
Type: function.StaticReturnType(cty.String), | ||
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { | ||
return cty.DynamicVal, fmt.Errorf("the expected error") | ||
}, | ||
}), | ||
}, | ||
}, | ||
func(t *testing.T, diags hcl.Diagnostics) { | ||
try := func(diags hcl.Diagnostics) { | ||
t.Helper() | ||
for _, diag := range diags { | ||
extra, ok := hcl.DiagnosticExtra[FunctionCallDiagExtra](diag) | ||
if !ok { | ||
continue | ||
} | ||
|
||
if got, want := extra.CalledFunctionName(), "boop"; got != want { | ||
t.Errorf("wrong called function name %q; want %q", got, want) | ||
} | ||
err := extra.FunctionCallError() | ||
if err == nil { | ||
t.Fatal("FunctionCallError returned nil") | ||
} | ||
if got, want := err.Error(), "the expected error"; got != want { | ||
t.Errorf("wrong error message\ngot: %q\nwant: %q", got, want) | ||
} | ||
|
||
return | ||
} | ||
t.Fatalf("None of the returned diagnostics implement FunctionCallDiagError\n%s", diags.Error()) | ||
} | ||
|
||
t.Run("unwrapped", func(t *testing.T) { | ||
try(diags) | ||
}) | ||
|
||
// It should also work if we wrap up the "extras" in wrapper types. | ||
for _, diag := range diags { | ||
diag.Extra = diagnosticExtraWrapper{diag.Extra} | ||
} | ||
t.Run("wrapped", func(t *testing.T) { | ||
try(diags) | ||
}) | ||
}, | ||
}, | ||
} | ||
|
||
for _, test := range tests { | ||
t.Run(test.input, func(t *testing.T) { | ||
var diags hcl.Diagnostics | ||
expr, parseDiags := ParseExpression([]byte(test.input), "", hcl.Pos{Line: 1, Column: 1, Byte: 0}) | ||
diags = append(diags, parseDiags...) | ||
_, valDiags := expr.Value(test.ctx) | ||
diags = append(diags, valDiags...) | ||
|
||
if !diags.HasErrors() { | ||
t.Fatal("unexpected success") | ||
} | ||
|
||
test.assert(t, diags) | ||
}) | ||
} | ||
} | ||
|
||
type diagnosticExtraWrapper struct { | ||
wrapped interface{} | ||
} | ||
|
||
var _ hcl.DiagnosticExtraUnwrapper = diagnosticExtraWrapper{} | ||
|
||
func (w diagnosticExtraWrapper) UnwrapDiagnosticExtra() interface{} { | ||
return w.wrapped | ||
} |