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
57 changes: 20 additions & 37 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,50 +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 ./...
```

For a more robust and easier-to-configure alternative, consider [jba/templatecheck](https://github.com/jba/templatecheck).
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`

## Key Types and Functions
## Library usage

* **`Global`**
Holds type and template resolution state. Constructed with `NewGlobal`.
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.

* **`Execute`**
Entry point to validate a template tree against a given `types.Type`.
## Related projects

* **`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 not been fully tested.

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.
86 changes: 82 additions & 4 deletions cmd/check-templates/main.go
Original file line number Diff line number Diff line change
@@ -1,13 +1,17 @@
package main

import (
"encoding/json"
"flag"
"fmt"
"go/ast"
"go/token"
"go/types"
"io"
"log"
"os"
"strconv"
"strings"
"text/template/parse"

"golang.org/x/tools/go/packages"
Expand All @@ -24,9 +28,34 @@ func main() {
}

func run(dir string, args []string, stdout, stderr io.Writer) int {
var (
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 len(args) > 0 {
loadArgs = args
if args := flagSet.Args(); len(args) > 0 {
loadArgs = flagSet.Args()
}

fset := token.NewFileSet()
Expand All @@ -41,20 +70,69 @@ 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 {
_, _ = fmt.Fprintln(stderr, e)
exitCode = 1
}
if err := check.Package(pkg, func(node *ast.CallExpr, t *parse.Tree, tp types.Type) {

writeCall(fset.Position(node.Pos()), t.Name, tp)
}, func(node *parse.TemplateNode, t *parse.Tree, tp types.Type) {

loc, _ := t.ErrorContext(node)
writeCall(parseLocation(loc), t.Name, tp)
}); err != nil {
_, _ = fmt.Fprintln(stderr, err)
exitCode = 1
}
}
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
}
29 changes: 29 additions & 0 deletions cmd/check-templates/testdata/err_change_dir.txt
Original file line number Diff line number Diff line change
@@ -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(`<h1>{{.Missing}}</h1>`))

type Page struct {
Title string
}

func handle(w http.ResponseWriter, r *http.Request) {
_ = templates.ExecuteTemplate(w, "page", Page{Title: "Home"})
}

var _ = fmt.Sprint
29 changes: 29 additions & 0 deletions cmd/check-templates/testdata/err_output_unsupported.txt
Original file line number Diff line number Diff line change
@@ -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(`<h1>{{.Title}}</h1>`))

type Page struct {
Title string
}

func handle(w http.ResponseWriter, r *http.Request) {
_ = templates.ExecuteTemplate(w, "page", Page{Title: "Home"})
}

var _ = fmt.Sprint
29 changes: 29 additions & 0 deletions cmd/check-templates/testdata/pass_change_dir.txt
Original file line number Diff line number Diff line change
@@ -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(`<h1>Hello, {{.Name}}!</h1>`))

type Greeting struct {
Name string
}

func handle(w http.ResponseWriter, r *http.Request) {
_ = templates.ExecuteTemplate(w, "greeting", Greeting{Name: "World"})
}

var _ = fmt.Sprint
43 changes: 43 additions & 0 deletions cmd/check-templates/testdata/pass_output_jsonl.txt
Original file line number Diff line number Diff line change
@@ -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 --
<h1>{{.Title}}</h1>
39 changes: 39 additions & 0 deletions cmd/check-templates/testdata/pass_verbose.txt
Original file line number Diff line number Diff line change
@@ -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 --
<h1>{{.Title}}</h1>
Loading