From 8ad5d438d798ec31ad44c5d80b456e534318686a Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Sat, 7 Feb 2026 20:31:21 -0800 Subject: [PATCH 1/3] feat: add -C (change dir) and -v (verbose) flags --- README.md | 4 +- cmd/check-templates/main.go | 29 +++++++++++-- .../testdata/err_change_dir.txt | 29 +++++++++++++ .../testdata/pass_change_dir.txt | 29 +++++++++++++ cmd/check-templates/testdata/pass_verbose.txt | 39 +++++++++++++++++ .../testdata/pass_verbose_nested.txt | 43 +++++++++++++++++++ 6 files changed, 168 insertions(+), 5 deletions(-) create mode 100644 cmd/check-templates/testdata/err_change_dir.txt create mode 100644 cmd/check-templates/testdata/pass_change_dir.txt create mode 100644 cmd/check-templates/testdata/pass_verbose.txt create mode 100644 cmd/check-templates/testdata/pass_verbose_nested.txt diff --git a/README.md b/README.md index bf577a1..116d53b 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ See [example_test.go](./example_test.go) for a working example. Originally built as part of [`muxt`](https://github.com/crhntr/muxt), this package also powers the `muxt check` CLI command. If you only need command-line checks, use `muxt check` directly. Unlike `muxt`, which requires templates to be defined as global variables, this package lets you map templates to data parameters more flexibly (at the cost of some verbosity). +If all your calls of `ExecuteTemplate` use a string literal for the template name and a static type parameter, you can use `go tool check-templates` by installing `go get -tool github.com/typelate/check/cmd/check-templates`. + For a more robust and easier-to-configure alternative, consider [jba/templatecheck](https://github.com/jba/templatecheck). ## Key Types and Functions @@ -42,7 +44,7 @@ For a more robust and easier-to-configure alternative, consider [jba/templateche Currently, default functions do not differentiate between `text/template` and `html/template`. 3. **Third-party template packages** - Compatibility with specialized template libraries (e.g. [safehtml](https://pkg.go.dev/github.com/google/safehtml)) has not been fully tested. + Compatibility with specialized template libraries (e.g. [safehtml](https://pkg.go.dev/github.com/google/safehtml)) has no implementation. 4. **Runtime-only errors** `Execute` checks static type consistency but cannot detect runtime conditions such as out-of-range indexes. diff --git a/cmd/check-templates/main.go b/cmd/check-templates/main.go index c26b571..1bc00d0 100644 --- a/cmd/check-templates/main.go +++ b/cmd/check-templates/main.go @@ -1,6 +1,7 @@ package main import ( + "flag" "fmt" "go/ast" "go/token" @@ -24,9 +25,21 @@ func main() { } func run(dir string, args []string, stdout, stderr io.Writer) int { + var ( + verbose bool + ) + + flagSet := flag.NewFlagSet("check-templates", flag.ContinueOnError) + flagSet.BoolVar(&verbose, "v", false, "show all calls") + flagSet.StringVar(&dir, "C", dir, "change directory") + if err := flagSet.Parse(args); err != nil { + _, _ = fmt.Fprintln(stderr, err) + return 1 + } + loadArgs := []string{"."} - if len(args) > 0 { - loadArgs = args + if args := flagSet.Args(); len(args) > 0 { + loadArgs = flagSet.Args() } fset := token.NewFileSet() @@ -48,9 +61,17 @@ func run(dir string, args []string, stdout, stderr io.Writer) int { exitCode = 1 } if err := check.Package(pkg, func(node *ast.CallExpr, t *parse.Tree, tp types.Type) { - + if !verbose { + return + } + pos := fset.Position(node.Pos()) + _, _ = fmt.Fprintf(stdout, "%s\t%q\t%s\n", pos, t.Name, tp) }, func(node *parse.TemplateNode, t *parse.Tree, tp types.Type) { - + if !verbose { + return + } + pos, _ := t.ErrorContext(node) + _, _ = fmt.Fprintf(stdout, "%s\t%q\t%s\n", pos, t.Name, tp) }); err != nil { _, _ = fmt.Fprintln(stderr, err) exitCode = 1 diff --git a/cmd/check-templates/testdata/err_change_dir.txt b/cmd/check-templates/testdata/err_change_dir.txt new file mode 100644 index 0000000..0ad7e1b --- /dev/null +++ b/cmd/check-templates/testdata/err_change_dir.txt @@ -0,0 +1,29 @@ +# The -C flag changes directory; type errors in that directory are still caught. + +! check-templates -C $WORK/subdir +stderr 'Missing not found on example\.com/app\.Page' + +-- subdir/go.mod -- +module example.com/app + +go 1.25.0 +-- subdir/main.go -- +package main + +import ( + "fmt" + "html/template" + "net/http" +) + +var templates = template.Must(template.New("page").Parse(`

{{.Missing}}

`)) + +type Page struct { + Title string +} + +func handle(w http.ResponseWriter, r *http.Request) { + _ = templates.ExecuteTemplate(w, "page", Page{Title: "Home"}) +} + +var _ = fmt.Sprint diff --git a/cmd/check-templates/testdata/pass_change_dir.txt b/cmd/check-templates/testdata/pass_change_dir.txt new file mode 100644 index 0000000..8d90c8f --- /dev/null +++ b/cmd/check-templates/testdata/pass_change_dir.txt @@ -0,0 +1,29 @@ +# The -C flag changes the working directory before loading packages. + +check-templates -C $WORK/subdir +! stderr . + +-- subdir/go.mod -- +module example.com/app + +go 1.25.0 +-- subdir/main.go -- +package main + +import ( + "fmt" + "html/template" + "net/http" +) + +var templates = template.Must(template.New("greeting").Parse(`

Hello, {{.Name}}!

`)) + +type Greeting struct { + Name string +} + +func handle(w http.ResponseWriter, r *http.Request) { + _ = templates.ExecuteTemplate(w, "greeting", Greeting{Name: "World"}) +} + +var _ = fmt.Sprint diff --git a/cmd/check-templates/testdata/pass_verbose.txt b/cmd/check-templates/testdata/pass_verbose.txt new file mode 100644 index 0000000..256c743 --- /dev/null +++ b/cmd/check-templates/testdata/pass_verbose.txt @@ -0,0 +1,39 @@ +# The -v flag lists each ExecuteTemplate call with position, template name, and data type. + +check-templates -v +stdout 'main\.go:.*"index\.gohtml"\texample\.com/app\.Page' +! stderr . + +-- go.mod -- +module example.com/app + +go 1.25.0 +-- main.go -- +package main + +import ( + "embed" + "fmt" + "html/template" + "net/http" +) + +var ( + //go:embed *.gohtml + source embed.FS + + templates = template.Must(template.ParseFS(source, "*")) +) + +type Page struct { + Title string +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + _ = templates.ExecuteTemplate(w, "index.gohtml", Page{Title: "Home"}) +} + +var _ = fmt.Sprint + +-- index.gohtml -- +

{{.Title}}

diff --git a/cmd/check-templates/testdata/pass_verbose_nested.txt b/cmd/check-templates/testdata/pass_verbose_nested.txt new file mode 100644 index 0000000..c03d5db --- /dev/null +++ b/cmd/check-templates/testdata/pass_verbose_nested.txt @@ -0,0 +1,43 @@ +# The -v flag also lists nested {{template}} calls. + +check-templates -v +stdout 'main\.go:.*"index\.gohtml"\texample\.com/app\.Page' +stdout 'index\.gohtml:1:.*"index\.gohtml"\texample\.com/app\.Page' +! stderr . + +-- go.mod -- +module example.com/app + +go 1.25.0 +-- main.go -- +package main + +import ( + "embed" + "fmt" + "html/template" + "io" +) + +var ( + //go:embed *.gohtml + source embed.FS + + templates = template.Must(template.ParseFS(source, "*")) +) + +type Page struct { + Title string +} + +func render() { + _ = templates.ExecuteTemplate(io.Discard, "index.gohtml", Page{}) +} + +var _ = fmt.Sprint + +-- index.gohtml -- +{{template "header.gohtml" .}} +

body

+-- header.gohtml -- +

{{.Title}}

From eab0a8345ca42cdf057617fe453cc2f15b260659 Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:47:55 -0800 Subject: [PATCH 2/3] feat: add jsonl output format --- cmd/check-templates/main.go | 79 ++++++++++++++++--- .../testdata/err_output_unsupported.txt | 29 +++++++ .../testdata/pass_output_jsonl.txt | 43 ++++++++++ 3 files changed, 140 insertions(+), 11 deletions(-) create mode 100644 cmd/check-templates/testdata/err_output_unsupported.txt create mode 100644 cmd/check-templates/testdata/pass_output_jsonl.txt diff --git a/cmd/check-templates/main.go b/cmd/check-templates/main.go index 1bc00d0..951be1b 100644 --- a/cmd/check-templates/main.go +++ b/cmd/check-templates/main.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "flag" "fmt" "go/ast" @@ -9,6 +10,8 @@ import ( "io" "log" "os" + "strconv" + "strings" "text/template/parse" "golang.org/x/tools/go/packages" @@ -26,17 +29,30 @@ func main() { func run(dir string, args []string, stdout, stderr io.Writer) int { var ( - verbose bool + verbose bool + outputFormat string ) flagSet := flag.NewFlagSet("check-templates", flag.ContinueOnError) flagSet.BoolVar(&verbose, "v", false, "show all calls") flagSet.StringVar(&dir, "C", dir, "change directory") + flagSet.StringVar(&outputFormat, "o", "tsv", "output format: tsv or jsonl") if err := flagSet.Parse(args); err != nil { _, _ = fmt.Fprintln(stderr, err) return 1 } + switch outputFormat { + case "tsv", "jsonl": + default: + _, _ = fmt.Fprintf(stderr, "unsupported output format: %s\n", outputFormat) + return 1 + } + if !verbose { + stdout = io.Discard + } + writeCall := writeCallFunc(outputFormat, stdout) + loadArgs := []string{"."} if args := flagSet.Args(); len(args) > 0 { loadArgs = flagSet.Args() @@ -54,6 +70,7 @@ func run(dir string, args []string, stdout, stderr io.Writer) int { _, _ = fmt.Fprintf(stderr, "failed to load packages: %v\n", err) return 1 } + exitCode := 0 for _, pkg := range pkgs { for _, e := range pkg.Errors { @@ -61,17 +78,10 @@ func run(dir string, args []string, stdout, stderr io.Writer) int { exitCode = 1 } if err := check.Package(pkg, func(node *ast.CallExpr, t *parse.Tree, tp types.Type) { - if !verbose { - return - } - pos := fset.Position(node.Pos()) - _, _ = fmt.Fprintf(stdout, "%s\t%q\t%s\n", pos, t.Name, tp) + writeCall(fset.Position(node.Pos()), t.Name, tp) }, func(node *parse.TemplateNode, t *parse.Tree, tp types.Type) { - if !verbose { - return - } - pos, _ := t.ErrorContext(node) - _, _ = fmt.Fprintf(stdout, "%s\t%q\t%s\n", pos, t.Name, tp) + loc, _ := t.ErrorContext(node) + writeCall(parseLocation(loc), t.Name, tp) }); err != nil { _, _ = fmt.Fprintln(stderr, err) exitCode = 1 @@ -79,3 +89,50 @@ func run(dir string, args []string, stdout, stderr io.Writer) int { } return exitCode } + +type callRecord struct { + Filename string `json:"filename"` + Line int `json:"line"` + Column int `json:"column"` + Offset int `json:"offset"` + TemplateName string `json:"template_name"` + DataType string `json:"data_type"` +} + +func writeCallFunc(outputFormat string, stdout io.Writer) func(pos token.Position, templateName string, dataType types.Type) { + switch outputFormat { + case "jsonl": + enc := json.NewEncoder(stdout) + return func(pos token.Position, templateName string, dataType types.Type) { + _ = enc.Encode(callRecord{ + Filename: pos.Filename, + Line: pos.Line, + Column: pos.Column, + Offset: pos.Offset, + TemplateName: templateName, + DataType: dataType.String(), + }) + } + default: + return func(pos token.Position, templateName string, dataType types.Type) { + _, _ = fmt.Fprintf(stdout, "%s\t%q\t%s\n", pos, templateName, dataType) + } + } +} + +// parseLocation parses a "filename:line:col" string into a token.Position. +func parseLocation(loc string) token.Position { + // ErrorContext returns "filename:line:col" format. + // The filename may contain colons (e.g., Windows paths), so split from the right. + var pos token.Position + if i := strings.LastIndex(loc, ":"); i >= 0 { + pos.Column, _ = strconv.Atoi(loc[i+1:]) + loc = loc[:i] + } + if i := strings.LastIndex(loc, ":"); i >= 0 { + pos.Line, _ = strconv.Atoi(loc[i+1:]) + loc = loc[:i] + } + pos.Filename = loc + return pos +} diff --git a/cmd/check-templates/testdata/err_output_unsupported.txt b/cmd/check-templates/testdata/err_output_unsupported.txt new file mode 100644 index 0000000..f9addd1 --- /dev/null +++ b/cmd/check-templates/testdata/err_output_unsupported.txt @@ -0,0 +1,29 @@ +# An unsupported -o format produces an error. + +! check-templates -o xml +stderr 'unsupported output format: xml' + +-- go.mod -- +module example.com/app + +go 1.25.0 +-- main.go -- +package main + +import ( + "fmt" + "html/template" + "net/http" +) + +var templates = template.Must(template.New("page").Parse(`

{{.Title}}

`)) + +type Page struct { + Title string +} + +func handle(w http.ResponseWriter, r *http.Request) { + _ = templates.ExecuteTemplate(w, "page", Page{Title: "Home"}) +} + +var _ = fmt.Sprint diff --git a/cmd/check-templates/testdata/pass_output_jsonl.txt b/cmd/check-templates/testdata/pass_output_jsonl.txt new file mode 100644 index 0000000..103853c --- /dev/null +++ b/cmd/check-templates/testdata/pass_output_jsonl.txt @@ -0,0 +1,43 @@ +# The -o jsonl flag outputs each call as a JSON object per line. + +check-templates -v -o jsonl +stdout '"filename":".*main\.go"' +stdout '"template_name":"index\.gohtml"' +stdout '"data_type":"example\.com/app\.Page"' +stdout '"line":' +stdout '"column":' +! stderr . + +-- go.mod -- +module example.com/app + +go 1.25.0 +-- main.go -- +package main + +import ( + "embed" + "fmt" + "html/template" + "net/http" +) + +var ( + //go:embed *.gohtml + source embed.FS + + templates = template.Must(template.ParseFS(source, "*")) +) + +type Page struct { + Title string +} + +func handleIndex(w http.ResponseWriter, r *http.Request) { + _ = templates.ExecuteTemplate(w, "index.gohtml", Page{Title: "Home"}) +} + +var _ = fmt.Sprint + +-- index.gohtml -- +

{{.Title}}

From 9de3049354c7fdb540b9dc39ea8b90cfafa312c6 Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Sat, 7 Feb 2026 23:53:50 -0800 Subject: [PATCH 3/3] chore: improve readme --- README.md | 59 +++++++++++++++++++------------------------------------ 1 file changed, 20 insertions(+), 39 deletions(-) diff --git a/README.md b/README.md index 116d53b..c837f08 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,33 @@ # Check [![Go Reference](https://pkg.go.dev/badge/github.com/typelate/check.svg)](https://pkg.go.dev/github.com/typelate/check) -**Check** is a Go library for statically type-checking `text/template` and `html/template`. It helps catch template/type mismatches early, making refactoring safer when changing types or templates. +**Check** is a Go library for statically type-checking `text/template` and `html/template`. It catches template/type mismatches early, making refactoring safer when changing types or templates. -To use it, call `Execute` and provide: -- a `types.Type` for the template’s data (`.`), and -- the template’s `parse.Tree`. +## `check-templates` CLI -See [example_test.go](./example_test.go) for a working example. +If all your `ExecuteTemplate` calls use a string literal for the template name and a static type for the data argument, you can use the CLI directly: -Originally built as part of [`muxt`](https://github.com/crhntr/muxt), this package also powers the `muxt check` CLI command. If you only need command-line checks, use `muxt check` directly. -Unlike `muxt`, which requires templates to be defined as global variables, this package lets you map templates to data parameters more flexibly (at the cost of some verbosity). +```sh +go get -tool github.com/typelate/check/cmd/check-templates +go tool check-templates ./... +``` -If all your calls of `ExecuteTemplate` use a string literal for the template name and a static type parameter, you can use `go tool check-templates` by installing `go get -tool github.com/typelate/check/cmd/check-templates`. +Flags: +- `-v` — list each call with position, template name, and data type +- `-C dir` — change working directory before loading packages +- `-o format` — output format: `tsv` (default) or `jsonl` -For a more robust and easier-to-configure alternative, consider [jba/templatecheck](https://github.com/jba/templatecheck). +## Library usage -## Key Types and Functions +Call `Execute` with a `types.Type` for the template's data (`.`) and the template's `parse.Tree`. See [example_test.go](./example_test.go) for a working example. -* **`Global`** - Holds type and template resolution state. Constructed with `NewGlobal`. +## Related projects -* **`Execute`** - Entry point to validate a template tree against a given `types.Type`. - -* **`TreeFinder` / `FindTreeFunc`** - Resolves other templates by name (wrapping `Template.Lookup`). - -* **`Functions`** - A set of callable template functions. Implements `CallChecker`. - - * Use `DefaultFunctions(pkg *types.Package)` to get the standard built-ins. - * Extend with `Functions.Add`. - -* **`CallChecker`** - Interface for validating function calls within templates. +- [`muxt`](https://github.com/typelate/muxt) — builds on this library to type-check templates wired to HTTP handlers. If you only need command-line checks, `muxt check` works too. +- [jba/templatecheck](https://github.com/jba/templatecheck) — a more mature alternative for template type-checking. ## Limitations -1. **Type required** - You must provide a `types.Type` that represents the template’s root context (`.`). - -2. **Function sets** - Currently, default functions do not differentiate between `text/template` and `html/template`. - -3. **Third-party template packages** - Compatibility with specialized template libraries (e.g. [safehtml](https://pkg.go.dev/github.com/google/safehtml)) has no implementation. - -4. **Runtime-only errors** - `Execute` checks static type consistency but cannot detect runtime conditions such as out-of-range indexes. - The standard library will try to dereference boxed types that may contain any type. - Errors introduced by changes on a boxed type can not be caught by this package. +1. You must provide a `types.Type` for the template's root context (`.`). +2. Default functions do not yet differentiate between `text/template` and `html/template` built-ins. +3. No support for third-party template packages (e.g. [safehtml](https://pkg.go.dev/github.com/google/safehtml)). +4. Cannot detect runtime conditions such as out-of-range indexes or errors from boxed types.