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 [](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.