From 388a2757cb13bfa8fe6f21498cd1e2e93c071744 Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Tue, 5 May 2026 10:40:09 -0700 Subject: [PATCH 1/3] feat: add VerboseError for richer call/identifier errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Introduce CallError and IdentifierError carrying types.Signature and types.Type so consumers can render multi-line diagnostics that include the full function signature, the navigated type, and — for named types — the original source declaration with its godoc. Existing Error() output is preserved; the new path activates via FormatVerbose, which the CLI now uses. Also: - unify *Error formatting so newError and wrapError both produce the location prefix (previously only newError did) - fix checkCallArguments returning the parameter type instead of the function's result on the pointer-deref fallback - fix copy-paste "built-in eq..." messages on and/or and comparison builtins, off-by-one in slice arg-count message, and panics on empty argTypes for len/slice/index Assisted-by: Claude:claude-opus-4-7 gopls staticcheck --- check.go | 95 +++++-- cmd/check-templates/main.go | 2 +- .../testdata/err_missing_field.txt | 5 + error_verbose.go | 269 ++++++++++++++++++ error_verbose_internal_test.go | 82 ++++++ error_verbose_test.go | 165 +++++++++++ func.go | 103 ++++--- 7 files changed, 651 insertions(+), 70 deletions(-) create mode 100644 error_verbose.go create mode 100644 error_verbose_internal_test.go create mode 100644 error_verbose_test.go diff --git a/check.go b/check.go index 5c48454..891126e 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...), } } @@ -36,13 +36,34 @@ func wrapError(tree *parse.Tree, node parse.Node, err error) *Error { } func (e *Error) Error() string { - return e.err.Error() + loc, ctx := e.Tree.ErrorContext(e.Node) + return fmt.Sprintf("type check failed: %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("type check failed: %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 +100,30 @@ 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(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 +526,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 +554,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 +567,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 +589,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 +609,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/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_missing_field.txt b/cmd/check-templates/testdata/err_missing_field.txt index c5b9e13..ad84ac7 100644 --- a/cmd/check-templates/testdata/err_missing_field.txt +++ b/cmd/check-templates/testdata/err_missing_field.txt @@ -2,6 +2,10 @@ ! check-templates stderr 'type check failed:.*index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: 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/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/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] From 63c4792e697c917e663bacc75d0aed9b59d50e96 Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Tue, 5 May 2026 10:44:37 -0700 Subject: [PATCH 2/3] refactor: drop "type check failed:" prefix so errors start with file:line:col Errors now begin directly with the location, matching the convention gopls and the Go compiler use. Terminals and IDEs treat the leading file:line:col as a clickable jump-to-source target. The remainder of the line keeps the same shape produced by text/template at runtime (executing %q at : msg), which also simplifies the runtime-error comparison helper in check_test.go. Updated affected EqualError/ErrorContains/Output assertions and scripttest patterns. Assisted-by: Claude:claude-opus-4-7 gopls staticcheck --- check.go | 11 +++++++++-- check_test.go | 16 ++++++++-------- .../err_additional_parsefs_missing_field.txt | 2 +- .../testdata/err_aliased_import.txt | 2 +- .../testdata/err_closure_missing_field.txt | 2 +- cmd/check-templates/testdata/err_funcs_chain.txt | 2 +- .../testdata/err_imported_type.txt | 2 +- .../testdata/err_inline_struct.txt | 2 +- .../testdata/err_local_var_missing_field.txt | 2 +- .../testdata/err_missing_field.txt | 2 +- .../testdata/err_multiple_errors.txt | 4 ++-- .../testdata/err_multiple_template_vars.txt | 2 +- .../testdata/err_nested_template.txt | 2 +- .../testdata/err_shadowed_var.txt | 2 +- .../testdata/err_text_template.txt | 2 +- example_test.go | 2 +- 16 files changed, 32 insertions(+), 25 deletions(-) diff --git a/check.go b/check.go index 891126e..3ef06b1 100644 --- a/check.go +++ b/check.go @@ -35,9 +35,16 @@ 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 { loc, ctx := e.Tree.ErrorContext(e.Node) - return fmt.Sprintf("type check failed: %s: executing %q at <%s>: %s", loc, e.Tree.Name, ctx, e.err.Error()) + return fmt.Sprintf("%s: executing %q at <%s>: %s", loc, e.Tree.Name, ctx, e.err.Error()) } func (e *Error) Unwrap() error { @@ -50,7 +57,7 @@ func (e *Error) Unwrap() error { // is indented on subsequent lines. func (e *Error) VerboseError() string { loc, ctx := e.Tree.ErrorContext(e.Node) - prefix := fmt.Sprintf("type check failed: %s: executing %q at <%s>: ", loc, e.Tree.Name, ctx) + 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() diff --git a/check_test.go b/check_test.go index 53e7232..a156b07 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>: 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>: 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/testdata/err_additional_parsefs_missing_field.txt b/cmd/check-templates/testdata/err_additional_parsefs_missing_field.txt index 100c55e..3a64fbc 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>: 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..1d260d8 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>: 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..b146277 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>: 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..28e79bc 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>: 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..21f6d18 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>: 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..6458775 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>: 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..fb77b2a 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>: 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 ad84ac7..9243f39 100644 --- a/cmd/check-templates/testdata/err_missing_field.txt +++ b/cmd/check-templates/testdata/err_missing_field.txt @@ -1,7 +1,7 @@ # 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>: 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 \{' diff --git a/cmd/check-templates/testdata/err_multiple_errors.txt b/cmd/check-templates/testdata/err_multiple_errors.txt index 63e1a5f..2879087 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>: Missing not found on example\.com/app\.IndexPage' +stderr 'about\.gohtml:1:5: executing "about\.gohtml" at <\.Unknown>: 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..87bdfa7 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>: 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..fe322c2 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>: 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..4a8e837 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>: 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..56177bd 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>: Missing not found on example\.com/app\.Page' -- go.mod -- module example.com/app diff --git a/example_test.go b/example_test.go index ffc6afa..ce4eafb 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>: UnknownField not found on github.com/typelate/check_test.Person; available: Name string // template "known field" type-check passed } From 9040db56b2904c3caeff00f7ae7022502e7f0bfe Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Wed, 6 May 2026 09:30:15 -0700 Subject: [PATCH 3/3] refactor: prefix not-found message with "field or method" Templates only ever look up fields or methods through .Foo chains, so making that explicit in the error message clarifies what the resolver was attempting and matches Go's own diagnostic phrasing. Assisted-by: Claude:claude-opus-4-7 gopls staticcheck --- check.go | 1 + check_test.go | 4 ++-- .../testdata/err_additional_parsefs_missing_field.txt | 2 +- cmd/check-templates/testdata/err_aliased_import.txt | 2 +- cmd/check-templates/testdata/err_closure_missing_field.txt | 2 +- cmd/check-templates/testdata/err_funcs_chain.txt | 2 +- cmd/check-templates/testdata/err_imported_type.txt | 2 +- cmd/check-templates/testdata/err_inline_struct.txt | 2 +- cmd/check-templates/testdata/err_local_var_missing_field.txt | 2 +- cmd/check-templates/testdata/err_missing_field.txt | 2 +- cmd/check-templates/testdata/err_multiple_errors.txt | 4 ++-- cmd/check-templates/testdata/err_multiple_template_vars.txt | 2 +- cmd/check-templates/testdata/err_nested_template.txt | 2 +- cmd/check-templates/testdata/err_shadowed_var.txt | 2 +- cmd/check-templates/testdata/err_text_template.txt | 2 +- example_test.go | 2 +- 16 files changed, 18 insertions(+), 17 deletions(-) diff --git a/check.go b/check.go index 3ef06b1..81d0dee 100644 --- a/check.go +++ b/check.go @@ -113,6 +113,7 @@ func (g *Global) TypeString(typ types.Type) string { // 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)) diff --git a/check_test.go b/check_test.go index a156b07..410a645 100644 --- a/check_test.go +++ b/check_test.go @@ -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, `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, `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") }, }, 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 3a64fbc..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 '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 1d260d8..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 '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 b146277..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 '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 28e79bc..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 '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 21f6d18..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 '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 6458775..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 '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 fb77b2a..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 '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 9243f39..1f30346 100644 --- a/cmd/check-templates/testdata/err_missing_field.txt +++ b/cmd/check-templates/testdata/err_missing_field.txt @@ -1,7 +1,7 @@ # Template check fails when a field does not exist on the data type. ! check-templates -stderr '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 \{' diff --git a/cmd/check-templates/testdata/err_multiple_errors.txt b/cmd/check-templates/testdata/err_multiple_errors.txt index 2879087..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 'index\.gohtml:1:6: executing "index\.gohtml" at <\.Missing>: Missing not found on example\.com/app\.IndexPage' -stderr '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 87bdfa7..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 '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 fe322c2..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 '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 4a8e837..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 '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 56177bd..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 '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/example_test.go b/example_test.go index ce4eafb..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: 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 }