Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 81 additions & 22 deletions check.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package check

import (
"bytes"
"errors"
"fmt"
"go/token"
"go/types"
Expand All @@ -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...),
}
}

Expand All @@ -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
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand All @@ -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()
Expand All @@ -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:
Expand All @@ -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)
}
Expand All @@ -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)
}
Expand Down
16 changes: 8 additions & 8 deletions check_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
},
},
{
Expand All @@ -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))
},
},
{
Expand All @@ -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))
},
},
{
Expand All @@ -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))
},
},
{
Expand All @@ -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()))
},
},
{
Expand Down Expand Up @@ -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")
},
},
Expand All @@ -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")
},
},
Expand Down Expand Up @@ -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 {
Expand Down
2 changes: 1 addition & 1 deletion cmd/check-templates/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/check-templates/testdata/err_aliased_import.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/check-templates/testdata/err_closure_missing_field.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/check-templates/testdata/err_funcs_chain.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/check-templates/testdata/err_imported_type.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion cmd/check-templates/testdata/err_inline_struct.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Expand Down
7 changes: 6 additions & 1 deletion cmd/check-templates/testdata/err_missing_field.txt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -24,6 +28,7 @@ var (
templates = template.Must(template.ParseFS(source, "*"))
)

// Page represents an example page.
type Page struct {
Title string
}
Expand Down
4 changes: 2 additions & 2 deletions cmd/check-templates/testdata/err_multiple_errors.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 --
Expand Down
2 changes: 1 addition & 1 deletion cmd/check-templates/testdata/err_nested_template.txt
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading