diff --git a/check.go b/check.go index 5c48454..81d0dee 100644 --- a/check.go +++ b/check.go @@ -2,6 +2,7 @@ package check import ( "bytes" + "errors" "fmt" "go/token" "go/types" @@ -19,11 +20,10 @@ type Error struct { } func newError(tree *parse.Tree, node parse.Node, message string, args ...any) *Error { - loc, context := tree.ErrorContext(node) return &Error{ Tree: tree, Node: node, - err: fmt.Errorf("type check failed: %s: executing %q at <%s>: %w", loc, tree.Name, context, fmt.Errorf(message, args...)), + err: fmt.Errorf(message, args...), } } @@ -35,14 +35,42 @@ func wrapError(tree *parse.Tree, node parse.Node, err error) *Error { } } +// Error returns the single-line error message. The format is +// +// {file}:{line}:{col}: executing {tree-name} at <{node-text}>: {message} +// +// The leading file:line:col is recognized by terminals and IDEs as a +// jump-to-source location. The message after the location matches the +// shape produced by text/template at runtime. func (e *Error) Error() string { - return e.err.Error() + loc, ctx := e.Tree.ErrorContext(e.Node) + return fmt.Sprintf("%s: executing %q at <%s>: %s", loc, e.Tree.Name, ctx, e.err.Error()) } func (e *Error) Unwrap() error { return e.err } +// VerboseError returns a (possibly) multi-line message. The single-line +// summary returned by Error stays on the first line; if the wrapped error +// implements VerboseErrorer and contributes additional detail, that detail +// is indented on subsequent lines. +func (e *Error) VerboseError() string { + loc, ctx := e.Tree.ErrorContext(e.Node) + prefix := fmt.Sprintf("%s: executing %q at <%s>: ", loc, e.Tree.Name, ctx) + var v VerboseErrorer + if !errors.As(e.err, &v) { + return prefix + e.err.Error() + } + verbose := v.VerboseError() + first, rest, hasRest := strings.Cut(verbose, "\n") + if !hasRest { + return prefix + first + } + indented := strings.ReplaceAll(rest, "\n", "\n ") + return prefix + first + "\n " + indented +} + type Global struct { trees TreeFinder calls CallChecker @@ -79,25 +107,31 @@ func (g *Global) TypeString(typ types.Type) string { return buf.String() } -func (g *Global) formatNotFound(ident string, tp types.Type) string { - var buf strings.Builder - buf.WriteString(ident) - buf.WriteString(" not found on ") - buf.WriteString(g.TypeString(tp)) +// formatNotFoundParts returns both forms of the not-found message: bare +// (without the trailing "; available: ..." or "; no exported fields..." clause) +// and full (with that clause). Verbose error rendering uses the bare form +// because it follows up with a source declaration of the receiver type. +func (g *Global) formatNotFoundParts(ident string, tp types.Type) (bare, full string) { + var b strings.Builder + b.WriteString("field or method ") + b.WriteString(ident) + b.WriteString(" not found on ") + b.WriteString(g.TypeString(tp)) if named, ok := tp.(*types.Named); ok { pos := g.fileSet.Position(named.Obj().Pos()) if pos.IsValid() { - fmt.Fprintf(&buf, " (declared at %s)", pos) + fmt.Fprintf(&b, " (declared at %s)", pos) } } + bare = b.String() + members := g.collectMembers(tp) if len(members) == 0 { - buf.WriteString("; no exported fields or methods") + full = bare + "; no exported fields or methods" } else { - buf.WriteString("; available: ") - buf.WriteString(strings.Join(members, ", ")) + full = bare + "; available: " + strings.Join(members, ", ") } - return buf.String() + return bare, full } func (g *Global) collectMembers(tp types.Type) []string { @@ -500,6 +534,19 @@ func (s *scope) notAFunction(tree *parse.Tree, node parse.Node, args []parse.Nod return nil } +// identErr builds an *Error wrapping an *IdentifierError. The location +// prefix is added by *Error at format time; the IdentifierError carries +// the full type so callers of FormatVerbose can render its structure. +func (s *scope) identErr(tree *parse.Tree, n parse.Node, ident string, tp types.Type, format string, a ...any) *Error { + return wrapError(tree, n, &IdentifierError{ + Identifier: ident, + Type: tp, + Cause: fmt.Errorf(format, a...), + qualifier: s.global.Qualifier, + fset: s.global.fileSet, + }) +} + func (s *scope) checkIdentifiers(tree *parse.Tree, dot types.Type, n parse.Node, idents []string, args []types.Type) (types.Type, error) { x := dot for i, ident := range idents { @@ -515,7 +562,7 @@ func (s *scope) checkIdentifiers(tree *parse.Tree, dot types.Type, n parse.Node, x = xx.Elem() _, err := strconv.Atoi(ident) if err != nil { - return nil, newError(tree, n, `can't evaluate field one in type %s`, s.global.TypeString(xx)) + return nil, s.identErr(tree, n, ident, xx, `can't evaluate field one in type %s`, s.global.TypeString(xx)) } case types.String: x = xx.Elem() @@ -528,11 +575,19 @@ func (s *scope) checkIdentifiers(tree *parse.Tree, dot types.Type, n parse.Node, continue default: if !token.IsExported(ident) { - return nil, newError(tree, n, "field or method %s is not exported", ident) + return nil, s.identErr(tree, n, ident, x, "field or method %s is not exported", ident) } obj, _, _ := types.LookupFieldOrMethod(x, true, s.global.pkg, ident) if obj == nil { - return nil, newError(tree, n, "%s", s.global.formatNotFound(ident, x)) + bare, full := s.global.formatNotFoundParts(ident, x) + return nil, wrapError(tree, n, &IdentifierError{ + Identifier: ident, + Type: x, + Cause: errors.New(full), + bareCause: bare, + qualifier: s.global.Qualifier, + fset: s.global.fileSet, + }) } switch o := obj.(type) { default: @@ -542,18 +597,18 @@ func (s *scope) checkIdentifiers(tree *parse.Tree, dot types.Type, n parse.Node, resultLen := sig.Results().Len() if resultLen < 1 || resultLen > 2 { methodPos := s.global.fileSet.Position(o.Pos()) - return nil, newError(tree, n, "function %s has %d return values; should be 1 or 2: incorrect signature at %s", ident, resultLen, methodPos) + return nil, s.identErr(tree, n, ident, sig, "function %s has %d return values; should be 1 or 2: incorrect signature at %s", ident, resultLen, methodPos) } if resultLen > 1 { methodPos := s.global.fileSet.Position(obj.Pos()) finalResult := sig.Results().At(sig.Results().Len() - 1) errorType := types.Universe.Lookup("error") if !types.Identical(errorType.Type(), finalResult.Type()) { - return nil, newError(tree, n, "invalid function signature for %s: second return value should be error; is %s: incorrect signature at %s", ident, s.global.TypeString(finalResult.Type()), methodPos) + return nil, s.identErr(tree, n, ident, sig, "invalid function signature for %s: second return value should be error; is %s: incorrect signature at %s", ident, s.global.TypeString(finalResult.Type()), methodPos) } } if i == len(idents)-1 { - res, err := checkCallArguments(s.global, sig, args) + res, err := checkCallArguments(s.global, ident, sig, args) if err != nil { return nil, wrapError(tree, n, err) } @@ -562,16 +617,20 @@ func (s *scope) checkIdentifiers(tree *parse.Tree, dot types.Type, n parse.Node, x = sig.Results().At(0).Type() } if _, ok := x.(*types.Signature); ok && i < len(idents)-1 { - return nil, newError(tree, n, "identifier chain not supported for type %s", s.global.TypeString(x)) + return nil, s.identErr(tree, n, ident, x, "identifier chain not supported for type %s", s.global.TypeString(x)) } } } if len(args) > 0 { sig, ok := x.(*types.Signature) if !ok { - return nil, newError(tree, n, "expected method or function") + return nil, s.identErr(tree, n, "", x, "expected method or function") + } + var name string + if len(idents) > 0 { + name = idents[len(idents)-1] } - tp, err := checkCallArguments(s.global, sig, args) + tp, err := checkCallArguments(s.global, name, sig, args) if err != nil { return nil, wrapError(tree, n, err) } diff --git a/check_test.go b/check_test.go index 53e7232..410a645 100644 --- a/check_test.go +++ b/check_test.go @@ -129,7 +129,7 @@ func TestTree(t *testing.T) { require.NotNil(t, method) methodPos := testPkg.Fset.Position(method.Pos()) - require.EqualError(t, err, fmt.Sprintf(`type check failed: template:1:2: executing "template" at <.Method>: function Method has 0 return values; should be 1 or 2: incorrect signature at %s`, methodPos)) + require.EqualError(t, err, fmt.Sprintf(`template:1:2: executing "template" at <.Method>: function Method has 0 return values; should be 1 or 2: incorrect signature at %s`, methodPos)) }, }, { @@ -151,7 +151,7 @@ func TestTree(t *testing.T) { require.NotNil(t, method) methodPos := testPkg.Fset.Position(method.Pos()) - require.EqualError(t, err, fmt.Sprintf(`type check failed: template:1:2: executing "template" at <.Method>: invalid function signature for Method: second return value should be error; is int: incorrect signature at %s`, methodPos)) + require.EqualError(t, err, fmt.Sprintf(`template:1:2: executing "template" at <.Method>: invalid function signature for Method: second return value should be error; is int: incorrect signature at %s`, methodPos)) }, }, { @@ -163,7 +163,7 @@ func TestTree(t *testing.T) { require.NotNil(t, method) methodPos := testPkg.Fset.Position(method.Pos()) - require.EqualError(t, err, fmt.Sprintf(`type check failed: template:1:2: executing "template" at <.Method>: function Method has 3 return values; should be 1 or 2: incorrect signature at %s`, methodPos)) + require.EqualError(t, err, fmt.Sprintf(`template:1:2: executing "template" at <.Method>: function Method has 3 return values; should be 1 or 2: incorrect signature at %s`, methodPos)) }, }, { @@ -182,7 +182,7 @@ func TestTree(t *testing.T) { require.NotNil(t, m2) methodPos := testPkg.Fset.Position(m2.Pos()) - require.EqualError(t, err, fmt.Sprintf(`type check failed: template:1:9: executing "template" at <.Method.Method>: function Method has 0 return values; should be 1 or 2: incorrect signature at %s`, methodPos)) + require.EqualError(t, err, fmt.Sprintf(`template:1:9: executing "template" at <.Method.Method>: function Method has 0 return values; should be 1 or 2: incorrect signature at %s`, methodPos)) }, }, { @@ -209,7 +209,7 @@ func TestTree(t *testing.T) { Error: func(t *testing.T, err, _ error, tp types.Type) { fn, _, _ := types.LookupFieldOrMethod(tp, true, testPkg.Types, "Func") require.NotNil(t, fn) - require.ErrorContains(t, err, fmt.Sprintf(`type check failed: template:1:7: executing "template" at <.Func.Method>: identifier chain not supported for type %s`, fn.Type())) + require.ErrorContains(t, err, fmt.Sprintf(`template:1:7: executing "template" at <.Func.Method>: identifier chain not supported for type %s`, fn.Type())) }, }, { @@ -728,7 +728,7 @@ func TestTree(t *testing.T) { Data: nil, Error: func(t *testing.T, checkErr, execErr error, tp types.Type) { assert.NoError(t, execErr) - require.ErrorContains(t, checkErr, `type check failed: template:1:8: executing "template" at <.Unknown>: Unknown not found on untyped nil`) + require.ErrorContains(t, checkErr, `template:1:8: executing "template" at <.Unknown>: field or method Unknown not found on untyped nil`) require.ErrorContains(t, checkErr, "no exported fields or methods") }, }, @@ -738,7 +738,7 @@ func TestTree(t *testing.T) { Data: nil, Error: func(t *testing.T, checkErr, execErr error, tp types.Type) { assert.NoError(t, execErr) - require.ErrorContains(t, checkErr, `type check failed: template:1:7: executing "template" at <.Unknown>: Unknown not found on untyped nil`) + require.ErrorContains(t, checkErr, `template:1:7: executing "template" at <.Unknown>: field or method Unknown not found on untyped nil`) require.ErrorContains(t, checkErr, "no exported fields or methods") }, }, @@ -860,7 +860,7 @@ func find[T any](t *testing.T, list []T, match func(p T) bool) T { func convertTextExecError(t *testing.T, err error) string { require.Error(t, err) - return "type check failed:" + strings.TrimPrefix(err.Error(), "template:") + return strings.TrimPrefix(err.Error(), "template: ") } func treeTestRowType(t *testing.T, p *packages.Package, ttRows *ast.CompositeLit, name string) types.Type { diff --git a/cmd/check-templates/main.go b/cmd/check-templates/main.go index 951be1b..9fc07fe 100644 --- a/cmd/check-templates/main.go +++ b/cmd/check-templates/main.go @@ -83,7 +83,7 @@ func run(dir string, args []string, stdout, stderr io.Writer) int { loc, _ := t.ErrorContext(node) writeCall(parseLocation(loc), t.Name, tp) }); err != nil { - _, _ = fmt.Fprintln(stderr, err) + _, _ = fmt.Fprintln(stderr, check.FormatVerbose(err)) exitCode = 1 } } diff --git a/cmd/check-templates/testdata/err_additional_parsefs_missing_field.txt b/cmd/check-templates/testdata/err_additional_parsefs_missing_field.txt index 100c55e..83e7335 100644 --- a/cmd/check-templates/testdata/err_additional_parsefs_missing_field.txt +++ b/cmd/check-templates/testdata/err_additional_parsefs_missing_field.txt @@ -2,7 +2,7 @@ # has a field that doesn't exist on the data type. ! check-templates -stderr 'type check failed:.*about\.gohtml:1:5: executing "about\.gohtml" at <\.Missing>: Missing not found on example\.com/app\.Page' +stderr 'about\.gohtml:1:5: executing "about\.gohtml" at <\.Missing>: field or method Missing not found on example\.com/app\.Page' -- go.mod -- module example.com/app diff --git a/cmd/check-templates/testdata/err_aliased_import.txt b/cmd/check-templates/testdata/err_aliased_import.txt index cf4adc8..7864c9f 100644 --- a/cmd/check-templates/testdata/err_aliased_import.txt +++ b/cmd/check-templates/testdata/err_aliased_import.txt @@ -1,7 +1,7 @@ # Template import with a non-standard alias should still detect errors. ! check-templates -stderr 'type check failed:.*index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: Missing not found on example\.com/app\.Page' +stderr 'index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: field or method Missing not found on example\.com/app\.Page' -- go.mod -- module example.com/app diff --git a/cmd/check-templates/testdata/err_closure_missing_field.txt b/cmd/check-templates/testdata/err_closure_missing_field.txt index 3c01045..ff703d1 100644 --- a/cmd/check-templates/testdata/err_closure_missing_field.txt +++ b/cmd/check-templates/testdata/err_closure_missing_field.txt @@ -1,7 +1,7 @@ # Template parsed in outer function, closure calls ExecuteTemplate with missing field. ! check-templates -stderr 'type check failed:.*index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: Missing not found on example\.com/app\.Page' +stderr 'index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: field or method Missing not found on example\.com/app\.Page' -- go.mod -- module example.com/app diff --git a/cmd/check-templates/testdata/err_funcs_chain.txt b/cmd/check-templates/testdata/err_funcs_chain.txt index c14f893..6b4ff6b 100644 --- a/cmd/check-templates/testdata/err_funcs_chain.txt +++ b/cmd/check-templates/testdata/err_funcs_chain.txt @@ -2,7 +2,7 @@ # report errors for missing fields. ! check-templates -stderr 'type check failed:.*index\.gohtml:1:2: executing "index\.gohtml" at <\.Missing>: Missing not found on example\.com/app\.Page' +stderr 'index\.gohtml:1:2: executing "index\.gohtml" at <\.Missing>: field or method Missing not found on example\.com/app\.Page' -- go.mod -- module example.com/app diff --git a/cmd/check-templates/testdata/err_imported_type.txt b/cmd/check-templates/testdata/err_imported_type.txt index a9a3a60..d84e3cb 100644 --- a/cmd/check-templates/testdata/err_imported_type.txt +++ b/cmd/check-templates/testdata/err_imported_type.txt @@ -1,7 +1,7 @@ # Types imported from other packages should report errors for missing fields. ! check-templates -stderr 'type check failed:.*index\.gohtml:1:2: executing "index\.gohtml" at <\.Missing>: Missing not found on example\.com/app/internal/model\.Page' +stderr 'index\.gohtml:1:2: executing "index\.gohtml" at <\.Missing>: field or method Missing not found on example\.com/app/internal/model\.Page' -- go.mod -- module example.com/app diff --git a/cmd/check-templates/testdata/err_inline_struct.txt b/cmd/check-templates/testdata/err_inline_struct.txt index bb5fe87..e091b17 100644 --- a/cmd/check-templates/testdata/err_inline_struct.txt +++ b/cmd/check-templates/testdata/err_inline_struct.txt @@ -1,7 +1,7 @@ # Inline anonymous struct types should report errors for missing fields. ! check-templates -stderr 'type check failed:.*index\.gohtml:1:2: executing "index\.gohtml" at <\.Missing>: Missing not found on struct\{Title string\}' +stderr 'index\.gohtml:1:2: executing "index\.gohtml" at <\.Missing>: field or method Missing not found on struct\{Title string\}' -- go.mod -- module example.com/app diff --git a/cmd/check-templates/testdata/err_local_var_missing_field.txt b/cmd/check-templates/testdata/err_local_var_missing_field.txt index bde48d4..8b5e0d4 100644 --- a/cmd/check-templates/testdata/err_local_var_missing_field.txt +++ b/cmd/check-templates/testdata/err_local_var_missing_field.txt @@ -1,7 +1,7 @@ # Template defined as local variable reports errors for missing fields. ! check-templates -stderr 'type check failed:.*index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: Missing not found on example\.com/app\.Page' +stderr 'index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: field or method Missing not found on example\.com/app\.Page' -- go.mod -- module example.com/app diff --git a/cmd/check-templates/testdata/err_missing_field.txt b/cmd/check-templates/testdata/err_missing_field.txt index c5b9e13..1f30346 100644 --- a/cmd/check-templates/testdata/err_missing_field.txt +++ b/cmd/check-templates/testdata/err_missing_field.txt @@ -1,7 +1,11 @@ # Template check fails when a field does not exist on the data type. ! check-templates -stderr 'type check failed:.*index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: Missing not found on example\.com/app\.Page' +stderr 'index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: field or method Missing not found on example\.com/app\.Page' +# Verbose output renders the type's source declaration, including its godoc comment. +stderr '// Page represents an example page.' +stderr 'type Page struct \{' +stderr '\tTitle string' -- go.mod -- module example.com/app @@ -24,6 +28,7 @@ var ( templates = template.Must(template.ParseFS(source, "*")) ) +// Page represents an example page. type Page struct { Title string } diff --git a/cmd/check-templates/testdata/err_multiple_errors.txt b/cmd/check-templates/testdata/err_multiple_errors.txt index 63e1a5f..5fcdd43 100644 --- a/cmd/check-templates/testdata/err_multiple_errors.txt +++ b/cmd/check-templates/testdata/err_multiple_errors.txt @@ -1,8 +1,8 @@ # Multiple ExecuteTemplate calls can each report errors. ! check-templates -stderr 'type check failed:.*index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: Missing not found on example\.com/app\.IndexPage' -stderr 'type check failed:.*about\.gohtml:1:5: executing "about\.gohtml" at <\.Unknown>: Unknown not found on example\.com/app\.AboutPage' +stderr 'index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: field or method Missing not found on example\.com/app\.IndexPage' +stderr 'about\.gohtml:1:5: executing "about\.gohtml" at <\.Unknown>: field or method Unknown not found on example\.com/app\.AboutPage' -- go.mod -- module example.com/app diff --git a/cmd/check-templates/testdata/err_multiple_template_vars.txt b/cmd/check-templates/testdata/err_multiple_template_vars.txt index 7dd7062..8c54ad6 100644 --- a/cmd/check-templates/testdata/err_multiple_template_vars.txt +++ b/cmd/check-templates/testdata/err_multiple_template_vars.txt @@ -2,7 +2,7 @@ # call should produce an error. ! check-templates -stderr 'type check failed:.*about\.gohtml:1:2: executing "about\.gohtml" at <\.Name>: Name not found on example\.com/app\.IndexPage' +stderr 'about\.gohtml:1:2: executing "about\.gohtml" at <\.Name>: field or method Name not found on example\.com/app\.IndexPage' ! stderr 'Title not found' -- go.mod -- diff --git a/cmd/check-templates/testdata/err_nested_template.txt b/cmd/check-templates/testdata/err_nested_template.txt index de654b4..b17a9bc 100644 --- a/cmd/check-templates/testdata/err_nested_template.txt +++ b/cmd/check-templates/testdata/err_nested_template.txt @@ -1,7 +1,7 @@ # A nested template call should check the invoked template against the data type. ! check-templates -stderr 'type check failed:.*header\.gohtml:1:6: executing "header\.gohtml" at <\.Missing>: Missing not found on example\.com/app\.Page' +stderr 'header\.gohtml:1:6: executing "header\.gohtml" at <\.Missing>: field or method Missing not found on example\.com/app\.Page' -- go.mod -- module example.com/app diff --git a/cmd/check-templates/testdata/err_shadowed_var.txt b/cmd/check-templates/testdata/err_shadowed_var.txt index 479f1e8..8148440 100644 --- a/cmd/check-templates/testdata/err_shadowed_var.txt +++ b/cmd/check-templates/testdata/err_shadowed_var.txt @@ -5,7 +5,7 @@ # data type to the inner call should produce an error only for that call. ! check-templates -stderr 'type check failed:.*about\.gohtml:1:5: executing "about\.gohtml" at <\.Name>: Name not found on example\.com/app\.IndexPage' +stderr 'about\.gohtml:1:5: executing "about\.gohtml" at <\.Name>: field or method Name not found on example\.com/app\.IndexPage' ! stderr 'Title not found' -- go.mod -- diff --git a/cmd/check-templates/testdata/err_text_template.txt b/cmd/check-templates/testdata/err_text_template.txt index 7e6e187..4536bb0 100644 --- a/cmd/check-templates/testdata/err_text_template.txt +++ b/cmd/check-templates/testdata/err_text_template.txt @@ -1,7 +1,7 @@ # text/template should be checked the same as html/template. ! check-templates -stderr 'type check failed:.*index\.gotmpl:1:2: executing "index\.gotmpl" at <\.Missing>: Missing not found on example\.com/app\.Page' +stderr 'index\.gotmpl:1:2: executing "index\.gotmpl" at <\.Missing>: field or method Missing not found on example\.com/app\.Page' -- go.mod -- module example.com/app diff --git a/error_verbose.go b/error_verbose.go new file mode 100644 index 0000000..da7a46d --- /dev/null +++ b/error_verbose.go @@ -0,0 +1,269 @@ +package check + +import ( + "bytes" + "errors" + "fmt" + "go/ast" + "go/parser" + "go/printer" + "go/token" + "go/types" + "os" + "strings" +) + +// VerboseErrorer is implemented by errors that can render a multi-line +// description including full type or signature information. +type VerboseErrorer interface { + error + VerboseError() string +} + +// CallError is returned when a call to a function or method fails type +// checking. It carries the signature being called and the observed argument +// types so the verbose render can show what was expected vs. what was passed. +type CallError struct { + // Name is the function or method name. May be empty (e.g. for built-in + // "call" or for anonymous signatures). + Name string + + // Signature is the signature being called. + Signature *types.Signature + + // ArgTypes are the actual argument types, in order. + ArgTypes []types.Type + + // Cause is the short, single-line message returned by Error. + Cause error + + qualifier types.Qualifier +} + +func (e *CallError) Error() string { + if e == nil || e.Cause == nil { + return "" + } + return e.Cause.Error() +} + +func (e *CallError) Unwrap() error { return e.Cause } + +// VerboseError returns a multi-line message that includes the full signature +// and the types of the arguments that were passed. +func (e *CallError) VerboseError() string { + if e == nil { + return "" + } + var b strings.Builder + b.WriteString(e.Error()) + if e.Signature != nil { + b.WriteString("\n signature: ") + if e.Name != "" { + b.WriteString(e.Name) + } else { + b.WriteString("func") + } + var sigBuf bytes.Buffer + types.WriteSignature(&sigBuf, e.Signature, e.qualifier) + b.WriteString(sigBuf.String()) + } + if len(e.ArgTypes) > 0 { + b.WriteString("\n arguments:") + for i, at := range e.ArgTypes { + fmt.Fprintf(&b, "\n [%d] %s", i, formatType(at, e.qualifier)) + } + } + return b.String() +} + +// IdentifierError is returned when a field, method, or identifier lookup +// fails during template type checking. It carries the type that was being +// navigated so the verbose render can show its structure. +type IdentifierError struct { + // Identifier is the field, method, or variable name being looked up. + Identifier string + + // Type is the type the identifier was being looked up on (or the + // signature when reporting a signature-shape problem). + Type types.Type + + // Cause is the short, single-line message returned by Error. + Cause error + + // bareCause, when non-empty, replaces Cause.Error() in the verbose + // rendering. It is used by the not-found path to omit the + // "; available: ..." clause from the verbose output, since the + // rendered source declaration already enumerates available members. + bareCause string + + qualifier types.Qualifier + fset *token.FileSet +} + +func (e *IdentifierError) Error() string { + if e == nil || e.Cause == nil { + return "" + } + return e.Cause.Error() +} + +func (e *IdentifierError) Unwrap() error { return e.Cause } + +// VerboseError returns a multi-line message that, when the type is named +// and its source declaration is reachable, includes the type's Go source +// (with its godoc comment) following the short summary. For non-named +// types it falls back to printing the type and (when distinct) its +// underlying form. +func (e *IdentifierError) VerboseError() string { + if e == nil { + return "" + } + short := e.Error() + if e.bareCause != "" { + short = e.bareCause + } + + var b strings.Builder + b.WriteString(short) + + if src := renderTypeSource(e.Type, e.fset); src != "" { + b.WriteString("\n\n") + b.WriteString(src) + return b.String() + } + + if e.Type != nil { + typeStr := formatType(e.Type, e.qualifier) + b.WriteString("\n type: ") + b.WriteString(typeStr) + if u := e.Type.Underlying(); u != nil && u != e.Type { + underlyingStr := formatType(u, e.qualifier) + if underlyingStr != typeStr { + b.WriteString("\n underlying: ") + b.WriteString(underlyingStr) + } + } + } + return b.String() +} + +// renderTypeSource attempts to render the Go source declaration of a +// named type, including any leading godoc comment. It reads the source +// file containing the type's declaration and prints the enclosing +// GenDecl with go/printer. Returns "" when the type is not named, has +// no valid declaration position, or the file cannot be read or parsed. +func renderTypeSource(t types.Type, fset *token.FileSet) string { + if t == nil || fset == nil { + return "" + } + named, ok := t.(*types.Named) + if !ok { + return "" + } + obj := named.Obj() + if obj == nil { + return "" + } + pos := obj.Pos() + if !pos.IsValid() { + return "" + } + tokFile := fset.File(pos) + if tokFile == nil { + return "" + } + + src, err := os.ReadFile(tokFile.Name()) + if err != nil { + return "" + } + parseFset := token.NewFileSet() + parsed, err := parser.ParseFile(parseFset, tokFile.Name(), src, parser.ParseComments) + if err != nil { + return "" + } + + target := obj.Name() + for _, decl := range parsed.Decls { + gd, ok := decl.(*ast.GenDecl) + if !ok || gd.Tok != token.TYPE { + continue + } + for _, spec := range gd.Specs { + ts, ok := spec.(*ast.TypeSpec) + if !ok || ts.Name == nil || ts.Name.Name != target { + continue + } + single := *gd + single.Specs = []ast.Spec{ts} + single.Lparen = token.NoPos + single.Rparen = token.NoPos + if single.Doc == nil && ts.Doc != nil { + single.Doc = ts.Doc + } + var buf bytes.Buffer + if err := printer.Fprint(&buf, parseFset, &single); err != nil { + return "" + } + return strings.TrimRight(buf.String(), "\n") + } + } + return "" +} + +func formatType(t types.Type, qf types.Qualifier) string { + if t == nil { + return "" + } + var buf bytes.Buffer + types.WriteType(&buf, t, qf) + return buf.String() +} + +// FormatVerbose renders err in verbose form. For each leaf in an +// errors.Join tree (or the err itself when not joined), FormatVerbose +// prefers a leaf's VerboseError method when available and falls back to +// Error otherwise. Multiple verbose blocks are separated by a blank line. +// +// FormatVerbose returns err.Error() unchanged when no error in the tree +// implements VerboseErrorer. +func FormatVerbose(err error) string { + if err == nil { + return "" + } + leaves := flattenJoined(err) + parts := make([]string, len(leaves)) + var anyVerbose bool + for i, leaf := range leaves { + var v VerboseErrorer + if errors.As(leaf, &v) { + parts[i] = v.VerboseError() + anyVerbose = true + } else { + parts[i] = leaf.Error() + } + } + if !anyVerbose { + return err.Error() + } + return strings.Join(parts, "\n\n") +} + +// flattenJoined walks errors.Join trees (errors that expose Unwrap() []error) +// and returns each non-joined leaf in the order they appear. Errors that do +// not multi-unwrap are returned as-is. +func flattenJoined(err error) []error { + type multi interface{ Unwrap() []error } + if m, ok := err.(multi); ok { + var out []error + for _, e := range m.Unwrap() { + if e == nil { + continue + } + out = append(out, flattenJoined(e)...) + } + return out + } + return []error{err} +} diff --git a/error_verbose_internal_test.go b/error_verbose_internal_test.go new file mode 100644 index 0000000..07573a4 --- /dev/null +++ b/error_verbose_internal_test.go @@ -0,0 +1,82 @@ +package check + +import ( + "go/ast" + "go/parser" + "go/token" + "go/types" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" +) + +// TestRenderTypeSource_IncludesGodoc loads a real on-disk Go source file +// and confirms that the rendered declaration contains the type's leading +// godoc comment. +func TestRenderTypeSource_IncludesGodoc(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "page.go") + src := `package app + +// Page is the home page model. +// +// More text on the second line. +type Page struct { + Title string +} +` + require.NoError(t, os.WriteFile(path, []byte(src), 0o600)) + + fset := token.NewFileSet() + parsed, err := parser.ParseFile(fset, path, src, parser.ParseComments) + require.NoError(t, err) + + conf := types.Config{} + pkg, err := conf.Check("app", fset, []*ast.File{parsed}, nil) + require.NoError(t, err) + + pageType := pkg.Scope().Lookup("Page").Type() + + got := renderTypeSource(pageType, fset) + require.Contains(t, got, "// Page is the home page model.") + require.Contains(t, got, "// More text on the second line.") + require.Contains(t, got, "type Page struct {") + require.Contains(t, got, "Title string") + require.NotContains(t, got, "(\n", "single-spec declarations should not be wrapped in parens") +} + +func TestRenderTypeSource_NoGodoc(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "page.go") + src := `package app + +type Page struct { + Title string +} +` + require.NoError(t, os.WriteFile(path, []byte(src), 0o600)) + + fset := token.NewFileSet() + parsed, err := parser.ParseFile(fset, path, src, parser.ParseComments) + require.NoError(t, err) + + conf := types.Config{} + pkg, err := conf.Check("app", fset, []*ast.File{parsed}, nil) + require.NoError(t, err) + + pageType := pkg.Scope().Lookup("Page").Type() + + got := renderTypeSource(pageType, fset) + require.Contains(t, got, "type Page struct {") + require.Contains(t, got, "Title string") +} + +func TestRenderTypeSource_NotNamed(t *testing.T) { + require.Equal(t, "", renderTypeSource(types.Universe.Lookup("int").Type(), token.NewFileSet())) +} + +func TestRenderTypeSource_NilFset(t *testing.T) { + require.Equal(t, "", renderTypeSource(nil, nil)) +} diff --git a/error_verbose_test.go b/error_verbose_test.go new file mode 100644 index 0000000..b794cd3 --- /dev/null +++ b/error_verbose_test.go @@ -0,0 +1,165 @@ +package check_test + +import ( + "errors" + "fmt" + "go/token" + "go/types" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/typelate/check" +) + +func TestCallError_VerboseError(t *testing.T) { + // build sig: func(int, string) string + intType := types.Universe.Lookup("int").Type() + stringType := types.Universe.Lookup("string").Type() + params := types.NewTuple( + types.NewVar(token.NoPos, nil, "n", intType), + types.NewVar(token.NoPos, nil, "s", stringType), + ) + results := types.NewTuple(types.NewVar(token.NoPos, nil, "", stringType)) + sig := types.NewSignatureType(nil, nil, nil, params, results, false) + + e := &check.CallError{ + Name: "Greet", + Signature: sig, + ArgTypes: []types.Type{stringType, intType}, + Cause: fmt.Errorf("argument 0 has type string expected int"), + } + + require.Equal(t, "argument 0 has type string expected int", e.Error()) + + verbose := e.VerboseError() + require.Contains(t, verbose, "argument 0 has type string expected int") + require.Contains(t, verbose, "signature: Greet(n int, s string) string") + require.Contains(t, verbose, "[0] string") + require.Contains(t, verbose, "[1] int") + require.True(t, strings.Count(verbose, "\n") > 0, "verbose error should have multiple lines") +} + +func TestCallError_VerboseError_AnonymousName(t *testing.T) { + intType := types.Universe.Lookup("int").Type() + params := types.NewTuple(types.NewVar(token.NoPos, nil, "", intType)) + results := types.NewTuple(types.NewVar(token.NoPos, nil, "", intType)) + sig := types.NewSignatureType(nil, nil, nil, params, results, false) + + e := &check.CallError{ + Signature: sig, + ArgTypes: []types.Type{intType}, + Cause: fmt.Errorf("wrong number of args expected 1 but got 1"), + } + verbose := e.VerboseError() + require.Contains(t, verbose, "signature: func(int) int") +} + +func TestCallError_Unwrap(t *testing.T) { + cause := fmt.Errorf("boom") + e := &check.CallError{Cause: cause} + require.True(t, errors.Is(e, cause)) +} + +func TestIdentifierError_VerboseError_NamedType(t *testing.T) { + // build a Named type wrapping struct{ Field string } + stringType := types.Universe.Lookup("string").Type() + field := types.NewField(token.NoPos, nil, "Field", stringType, false) + st := types.NewStruct([]*types.Var{field}, []string{""}) + tn := types.NewTypeName(token.NoPos, nil, "Bar", nil) + named := types.NewNamed(tn, st, nil) + + e := &check.IdentifierError{ + Identifier: "Missing", + Type: named, + Cause: fmt.Errorf("Missing not found on Bar"), + } + + require.Equal(t, "Missing not found on Bar", e.Error()) + + verbose := e.VerboseError() + require.Contains(t, verbose, "Missing not found on Bar") + require.Contains(t, verbose, "type: Bar") + require.Contains(t, verbose, "underlying: struct{Field string}") +} + +func TestIdentifierError_VerboseError_BasicType(t *testing.T) { + // for a basic type (int), underlying == itself, so no underlying line. + e := &check.IdentifierError{ + Identifier: "Foo", + Type: types.Universe.Lookup("int").Type(), + Cause: fmt.Errorf("Foo not found on int"), + } + verbose := e.VerboseError() + require.Contains(t, verbose, "type: int") + require.NotContains(t, verbose, "underlying:") +} + +func TestIdentifierError_Unwrap(t *testing.T) { + cause := fmt.Errorf("boom") + e := &check.IdentifierError{Cause: cause} + require.True(t, errors.Is(e, cause)) +} + +func TestFormatVerbose_NoVerboseFallsBackToError(t *testing.T) { + err := fmt.Errorf("plain") + require.Equal(t, "plain", check.FormatVerbose(err)) +} + +func TestFormatVerbose_NilReturnsEmpty(t *testing.T) { + require.Equal(t, "", check.FormatVerbose(nil)) +} + +func TestFormatVerbose_PrefersVerboseLeaf(t *testing.T) { + stringType := types.Universe.Lookup("string").Type() + intType := types.Universe.Lookup("int").Type() + sig := types.NewSignatureType(nil, nil, nil, + types.NewTuple(types.NewVar(token.NoPos, nil, "x", intType)), + types.NewTuple(types.NewVar(token.NoPos, nil, "", stringType)), + false) + + e := &check.CallError{ + Name: "F", + Signature: sig, + ArgTypes: []types.Type{stringType}, + Cause: fmt.Errorf("argument 0 has type string expected int"), + } + out := check.FormatVerbose(e) + require.Contains(t, out, "signature: F(x int) string") +} + +func TestFormatVerbose_JoinedErrors(t *testing.T) { + stringType := types.Universe.Lookup("string").Type() + intType := types.Universe.Lookup("int").Type() + sig := types.NewSignatureType(nil, nil, nil, + types.NewTuple(types.NewVar(token.NoPos, nil, "x", intType)), + types.NewTuple(types.NewVar(token.NoPos, nil, "", stringType)), + false) + + a := &check.CallError{ + Name: "A", + Signature: sig, + ArgTypes: []types.Type{stringType}, + Cause: fmt.Errorf("first failure"), + } + b := &check.IdentifierError{ + Identifier: "Missing", + Type: intType, + Cause: fmt.Errorf("second failure"), + } + joined := errors.Join(a, b) + out := check.FormatVerbose(joined) + + require.Contains(t, out, "first failure") + require.Contains(t, out, "signature: A(x int) string") + require.Contains(t, out, "second failure") + require.Contains(t, out, "type: int") + // Two verbose blocks separated by a blank line. + require.Contains(t, out, "\n\n") +} + +// Confirm CallError and IdentifierError satisfy the VerboseErrorer interface +// at compile time. (Tests fail to build if they don't.) +var _ check.VerboseErrorer = (*check.CallError)(nil) +var _ check.VerboseErrorer = (*check.IdentifierError)(nil) diff --git a/example_test.go b/example_test.go index ffc6afa..f9ff53f 100644 --- a/example_test.go +++ b/example_test.go @@ -102,6 +102,6 @@ func ExampleExecute() { fmt.Printf("template %q type-check passed\n", templateName) } } - // Output: type check failed: example:3:3: executing "unknown field" at <.UnknownField>: UnknownField not found on github.com/typelate/check_test.Person; available: Name string + // Output: example:3:3: executing "unknown field" at <.UnknownField>: field or method UnknownField not found on github.com/typelate/check_test.Person; available: Name string // template "known field" type-check passed } diff --git a/func.go b/func.go index 7591412..180d3d1 100644 --- a/func.go +++ b/func.go @@ -65,63 +65,66 @@ func (functions Functions) CheckCall(global *Global, funcIdent string, argNodes } else if resultLen > 2 { return nil, fmt.Errorf("function %s has too many results", funcIdent) } - return checkCallArguments(global, fn, argTypes) + return checkCallArguments(global, funcIdent, fn, argTypes) } -func checkCallArguments(global *Global, fn *types.Signature, args []types.Type) (types.Type, error) { - if exp, got := fn.Params().Len(), len(args); !fn.Variadic() && exp != got { - return nil, fmt.Errorf("wrong number of args expected %d but got %d", exp, got) +func checkCallArguments(global *Global, name string, fn *types.Signature, args []types.Type) (types.Type, error) { + callErr := func(format string, a ...any) *CallError { + return &CallError{ + Name: name, + Signature: fn, + ArgTypes: args, + Cause: fmt.Errorf(format, a...), + qualifier: global.Qualifier, + } } - expNumFixed := fn.Params().Len() + + expNum := fn.Params().Len() isVar := fn.Variadic() + expFixed := expNum if isVar { - expNumFixed-- - } - got := len(args) - for i := 0; i < expNumFixed; i++ { - if i >= len(args) { - return nil, fmt.Errorf("wrong number of args expected %d but got %d", expNumFixed, got) - } - pt := fn.Params().At(i).Type() - at := args[i] - assignable := types.AssignableTo(at, pt) - if !assignable { - if ptr, ok := at.Underlying().(*types.Pointer); ok { - if types.AssignableTo(ptr.Elem(), pt) { - return pt, nil - } - } - if ptr, ok := pt.Underlying().(*types.Pointer); ok { - if types.AssignableTo(at, ptr.Elem()) { - return pt, nil - } - } - return nil, fmt.Errorf("argument %d has type %s expected %s", i, global.TypeString(at), global.TypeString(pt)) + expFixed-- + } + + switch { + case !isVar && expNum != len(args): + return nil, callErr("wrong number of args expected %d but got %d", expNum, len(args)) + case isVar && len(args) < expFixed: + return nil, callErr("wrong number of args expected at least %d but got %d", expFixed, len(args)) + } + + for i := 0; i < expFixed; i++ { + if err := checkArgAssignable(global, callErr, i, fn.Params().At(i).Type(), args[i]); err != nil { + return nil, err } } if isVar { - pt := fn.Params().At(fn.Params().Len() - 1).Type().(*types.Slice).Elem() - for i := expNumFixed; i < len(args); i++ { - at := args[i] - assignable := types.AssignableTo(at, pt) - if !assignable { - if ptr, ok := at.Underlying().(*types.Pointer); ok { - if types.AssignableTo(ptr.Elem(), pt) { - return pt, nil - } - } - if ptr, ok := pt.Underlying().(*types.Pointer); ok { - if types.AssignableTo(at, ptr.Elem()) { - return pt, nil - } - } - return nil, fmt.Errorf("argument %d has type %s expected %s", i, global.TypeString(at), global.TypeString(pt)) + elem := fn.Params().At(expNum - 1).Type().(*types.Slice).Elem() + for i := expFixed; i < len(args); i++ { + if err := checkArgAssignable(global, callErr, i, elem, args[i]); err != nil { + return nil, err } } } return fn.Results().At(0).Type(), nil } +// checkArgAssignable returns nil when at is assignable to pt, allowing one +// level of pointer auto-deref or auto-address (matching template runtime +// semantics). Returns a *CallError built via callErr on mismatch. +func checkArgAssignable(global *Global, callErr func(format string, a ...any) *CallError, i int, pt, at types.Type) error { + if types.AssignableTo(at, pt) { + return nil + } + if ptr, ok := at.Underlying().(*types.Pointer); ok && types.AssignableTo(ptr.Elem(), pt) { + return nil + } + if ptr, ok := pt.Underlying().(*types.Pointer); ok && types.AssignableTo(at, ptr.Elem()) { + return nil + } + return callErr("argument %d has type %s expected %s", i, global.TypeString(at), global.TypeString(pt)) +} + func findPackage(pkg *types.Package, path string) (*types.Package, bool) { if pkg == nil { return nil, false @@ -142,6 +145,9 @@ func builtInCheck(global *Global, funcIdent string, nodes []parse.Node, argTypes case "attrescaper": return types.Universe.Lookup("string").Type(), nil case "len": + if len(argTypes) < 1 { + return nil, fmt.Errorf("built-in len expects 1 argument got %d", len(argTypes)) + } switch x := argTypes[0].Underlying().(type) { default: return nil, fmt.Errorf("built-in len expects the first argument to be an array, slice, map, or string got %s", global.TypeString(x)) @@ -156,7 +162,7 @@ func builtInCheck(global *Global, funcIdent string, nodes []parse.Node, argTypes return types.Universe.Lookup("int").Type(), nil case "slice": if l := len(argTypes); l < 1 || l > 4 { - return nil, fmt.Errorf("built-in slice expects at least 1 and no more than 3 arguments got %d", len(argTypes)) + return nil, fmt.Errorf("built-in slice expects between 1 and 4 arguments got %d", len(argTypes)) } for i := 1; i < len(nodes); i++ { if n, ok := nodes[i].(*parse.NumberNode); ok && n.Int64 < 0 { @@ -181,7 +187,7 @@ func builtInCheck(global *Global, funcIdent string, nodes []parse.Node, argTypes } case "and", "or": if len(argTypes) < 1 { - return nil, fmt.Errorf("built-in eq expects at least two arguments got %d", len(argTypes)) + return nil, fmt.Errorf("built-in %s expects at least one argument got %d", funcIdent, len(argTypes)) } first := argTypes[0] for _, a := range argTypes[1:] { @@ -192,7 +198,7 @@ func builtInCheck(global *Global, funcIdent string, nodes []parse.Node, argTypes return first, nil case "eq", "ge", "gt", "le", "lt", "ne": if len(argTypes) < 2 { - return nil, fmt.Errorf("built-in eq expects at least two arguments got %d", len(argTypes)) + return nil, fmt.Errorf("built-in %s expects at least two arguments got %d", funcIdent, len(argTypes)) } return types.Universe.Lookup("bool").Type(), nil case "call": @@ -203,13 +209,16 @@ func builtInCheck(global *Global, funcIdent string, nodes []parse.Node, argTypes if !ok { return nil, fmt.Errorf("call expected a function signature") } - return checkCallArguments(global, sig, argTypes[1:]) + return checkCallArguments(global, "", sig, argTypes[1:]) case "not": if len(argTypes) < 1 { return nil, fmt.Errorf("built-in not expects at least one argument") } return types.Universe.Lookup("bool").Type(), nil case "index": + if len(argTypes) < 1 { + return nil, fmt.Errorf("built-in index expects at least 1 argument got %d", len(argTypes)) + } result := argTypes[0] for i := 1; i < len(argTypes); i++ { at := argTypes[i]