Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
9c431e8
Ask the standard library through muxt.Checker
crhntr Sep 14, 2026
1018f8b
Hand each command its configuration, and test what command lines mean
crhntr Sep 14, 2026
2a2fcac
Snapshot what generate writes, from packages loaded in memory
crhntr Sep 14, 2026
0b6cece
Snapshot the template checks and listings, from packages loaded in me…
crhntr Sep 14, 2026
9554298
Plan mutations from a source.Package, and snapshot dry runs
crhntr Sep 14, 2026
ed9bcee
Read and write JSON with encoding/json/v2
crhntr Sep 16, 2026
95e9940
Say it with slices and maps
crhntr Sep 16, 2026
7e9d555
organize test colaborator in fake package
crhntr Sep 18, 2026
b067925
factor out index Route
crhntr Sep 18, 2026
196271d
factor out callFuncExpression and reduce types.Signature passing
crhntr Sep 18, 2026
396424f
split executeHTMLTemplateHandler
crhntr Sep 18, 2026
d6f5dcd
standardize initHandlerScope
crhntr Sep 18, 2026
b7ea231
factor out writeHeadersAndStatusCode
crhntr Sep 18, 2026
285b1bb
replace path value maps with Definition.Segments
crhntr Sep 27, 2026
55e1902
classify nested call results in muxt
crhntr Sep 27, 2026
5cfeeb4
record where a result offers its status code
crhntr Sep 27, 2026
591fa45
test segment kinds and path parameter lookup
crhntr Sep 27, 2026
1fbb737
read parameter types from resolved arguments
crhntr Sep 27, 2026
6ab9f0b
add source.Type
crhntr Sep 27, 2026
c52cd50
expose argument and field types as source.Type
crhntr Sep 27, 2026
bcd57be
render path segment types through source.Type
crhntr Sep 27, 2026
fdd70dd
render result and scope types through source.Type
crhntr Sep 27, 2026
1625ed3
render call signatures through source.Type
crhntr Sep 27, 2026
7b74050
remove the go/types renderer from generate
crhntr Sep 27, 2026
05b1154
add muxt.Resolve
crhntr Sep 27, 2026
300a769
generate from resolved definitions
crhntr Sep 27, 2026
c108f62
split logging from receiver method collection
crhntr Sep 27, 2026
d371c7f
format path values from a source.Type
crhntr Sep 27, 2026
07caec7
gofumpt the example fixture
crhntr Sep 27, 2026
a89d9a6
test result shape rules mutation testing left open
crhntr Sep 27, 2026
c948a0d
rename StatusCodeSource to ResultStatusCode
crhntr Sep 28, 2026
d890513
rename muxt.Resolve to ResolveDefinitions
crhntr Sep 28, 2026
1e1adda
rename ResultDataType to ResultType
crhntr Sep 28, 2026
f1433ba
rename logResolution to logResolutionNotes
crhntr Sep 28, 2026
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
99 changes: 80 additions & 19 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,15 +25,32 @@ source.Package: types + templates variables ./internal/source
↓ muxt.Definitions, muxt.ResolveCall
Resolved routes (muxt.Definition) ./internal/muxt
↓
Generated files / check reports ./internal/generate, ./internal/analysis
Generated files / check reports / mutations ./internal/generate, ./internal/analysis, ./internal/mutation
```

**The package load stops at `internal/load`.** It is the only package that
calls `packages.Load` (the go command, seconds per run). It hydrates a
command's configuration into a `source.Package`: plain data holding the
package's types, and each templates variable's template set, functions,
definitions and ExecuteTemplate calls. Route resolution, generation and the
template checks read only that, so they can be handed values built in memory.
definitions and ExecuteTemplate calls. Everything below it reads only that;
the mutation run, which loads again for `--diff` and with test files, loads
through `internal/load` too.

**The standard library is asked, not copied.** Route resolution asks a
`muxt.Checker` what a reserved argument binds to and which types marshal to
and from text. `load.StandardLibrary` is the one implementation, answering from
the official standard library a run loaded. Tests of resolution and generation
use `internal/muxt/muxttest`, which builds the counterfeiter fake in
`internal/muxt/muxtfakes` over stand-in types declared in the test's own
source (`muxttest.StandInChecker(t, pkg)`, or `muxttest.NewChecker()` with
`Binds`, `ParsesFromText` and `FormatsAsText`), so they state muxt's rules
rather than one library version's shape. Regenerate the fake with
`go generate ./internal/muxt`. Tests that need real types use
`internal/load/loadtest`, which type checks package source against the
official standard library's export data without loading the package graph.
It loads one package whose imports are all in the standard library, embeds
only its top-level files, and has no test variants; behavior that depends on
more stays in `cmd/muxt` scripts.

**Key concept:** Muxt reads template names like `"GET /{id} GetUser(ctx, id)"` and generates `http.Handler` implementations that:
- Parse URL parameters to the correct Go types
Expand Down Expand Up @@ -93,41 +110,81 @@ Update the code in order:
3. `internal/load/` — Only if a run needs something new from the loaded packages
4. `internal/cli/` — CLI handling (if needed)

Before adding an integration script, see whether a unit test can state it,
at the layer that owns the behavior:
- **Flags:** `internal/cli/configurations_test.go` states what a command line
parses into, and which command lines are rejected, without loading anything.
(`generate-fake-server` and `explore-module` do not go through it yet.)
- **What a command does with a valid configuration:**
`internal/{generate,analysis,mutation}/testdata/<command>/*.txtar` snapshot
generated files, check reports, the route and template listings, and
mutation dry runs, from packages loaded in memory in milliseconds. Each
archive is self-contained: the directory it is in names the command, its
`config.json` holds the configuration the command line in its header parses
into, and its `want/` files are what that produced. Run one with
`go test ./internal/analysis -run TestSnapshots/list-template-calls/calls`,
and rewrite them with
`go test ./internal/{generate,analysis,mutation} -run TestSnapshots -update`,
then review the diff.
- **Route names and call resolution:** `internal/muxt` tests type check source
in memory and resolve against a checker from `muxttest`.

To add a snapshot case, write the archive in the command's directory: a
`config.json` holding what the command line parses into
(`internal/cli/configurations_test.go` states that parse), the package's files,
and no `want/` files. Then run the package's `TestSnapshots -update` and read
what it wrote.

Integration scripts are for what needs the go command: generated code
compiling and serving requests, and files on disk.

### 5. Verify Your Changes

```bash
# Check for build/type errors
go test ./cmd/muxt

# Run the formatter
# Run the formatters. goimports -local keeps this module's imports in
# their own group, after the third-party one; gofumpt does not group.
go fmt ./...
gofumpt -w .
goimports -local github.com/typelate/muxt -w .
```

## Common Tasks

### Adding a New Feature

1. Create a test file: `cmd/muxt/testdata/reference_my_feature.txt`
2. Define the expected input (template) and output (generated code)
3. Run the test to see it fail
4. Update `internal/muxt/` generator functions
5. Run `go test ./cmd/muxt` until it passes
1. State it at the layer that owns it (see step 4 above): a snapshot archive
in `internal/generate/testdata/` for what is generated,
`internal/analysis/testdata/` for what is reported,
`internal/mutation/testdata/` for what a run mutates, a test in
`internal/muxt/` for how a name resolves, and a flag case in
`internal/cli/configurations_test.go` for a new flag
2. Run the test to see it fail
3. Update the package that owns the behavior: `internal/muxt/`,
`internal/generate/`, `internal/analysis/` or `internal/mutation/`
4. Rewrite the snapshot with `-update` and review the diff
5. Add `cmd/muxt/testdata/reference_my_feature.txt` when the generated code
must compile and serve requests, and run `go test ./cmd/muxt`

### Fixing a Bug

1. Create a test file: `cmd/muxt/testdata/err_bug_description.txt` or update an existing test
2. Reproduce the bug in the test
3. Run `go test ./cmd/muxt` to confirm failure
4. Fix the bug in `internal/muxt/`
5. Run `go test ./cmd/muxt` to confirm the fix
1. Reproduce it in the lowest test that can: a snapshot archive, an
`internal/muxt/` test, or, when it needs the go command,
`cmd/muxt/testdata/err_bug_description.txt`
2. Run the test to confirm failure
3. Fix the bug
4. Run the test, then `go test ./...`, to confirm the fix

### Adding Error Detection

1. Create a test: `cmd/muxt/testdata/err_error_name.txt`
2. Define input that should produce an error
3. Add validation logic to `internal/muxt/`
4. Verify the error message is clear
1. Add an `err_` snapshot archive (in `internal/generate/testdata/`,
`internal/analysis/testdata/` or `internal/mutation/testdata/`) whose
`want/error.txt` is the message
2. Add the validation to the package that reports it: `internal/muxt/` for a
route name, otherwise the package the archive belongs to
3. Verify the error message is clear

### Improving Documentation

Expand Down Expand Up @@ -161,6 +218,9 @@ ls cmd/muxt/testdata/err_*.txt
- `internal/muxt/` — Template name parsing and route resolution against go/types
- `internal/generate/` — Routes file generation
- `internal/analysis/` — `muxt check` and the template listings
- `internal/muxt/muxtfakes/` — The counterfeiter fake of `muxt.Checker`, generated by `go generate ./internal/muxt`
- `internal/muxt/muxttest/` — Builds that fake from what a test says the standard library looks like, plus import-free type checking
- `internal/load/loadtest/` — A loaded package type checked against the official standard library, for tests that go through `internal/load`
- `internal/cli/` — Command-line interface
- `cmd/muxt/` — Command entry point

Expand Down Expand Up @@ -229,7 +289,8 @@ go -C ./cmd/muxt/testdata/debug-test test -v
## Pull Request Checklist

- [ ] Tests pass: `go test ./...`
- [ ] Code formatted: `go fmt ./...` and `gofumpt -w .`
- [ ] Code formatted: `go fmt ./...`, `gofumpt -w .` and
`goimports -local github.com/typelate/muxt -w .`
- [ ] New features have test files with clear naming
- [ ] Error conditions are documented with `err_*` tests
- [ ] No unnecessary changes to generated output
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module github.com/typelate/muxt

go 1.26.0
go 1.27.0

require (
github.com/dustin/go-humanize v1.1.0
Expand Down
58 changes: 58 additions & 0 deletions internal/analysis/configuration_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package analysis_test

import (
"encoding/json/v2"
"regexp"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/typelate/muxt/internal/analysis"
"github.com/typelate/muxt/internal/configjson"
)

// TestListingConfigurationJSON states how a listing's configuration reads
// and writes as JSON, which is how an archive in testdata holds the one it
// runs with: a --match pattern is the text it was written as, and a field
// the command line left alone is null rather than an empty list, so a
// configuration read back is the one the command line produced.
// regexp.Regexp reads and writes itself, as a TextMarshaler; configjson
// says the rest.
func TestListingConfigurationJSON(t *testing.T) {
for _, tt := range []struct {
name string
config analysis.TemplateCallersConfiguration
want string
}{
{
name: "no patterns",
config: analysis.TemplateCallersConfiguration{TemplatesVariables: []string{"templates"}},
want: `{"TemplatesVariables":["templates"],"FilterTemplates":null}`,
},
{
name: "a pattern is the text it was written as",
config: analysis.TemplateCallersConfiguration{TemplatesVariables: []string{"pages"}, FilterTemplates: []*regexp.Regexp{regexp.MustCompile("^head")}},
want: `{"TemplatesVariables":["pages"],"FilterTemplates":["^head"]}`,
},
} {
t.Run(tt.name, func(t *testing.T) {
written, err := json.Marshal(tt.config, configjson.Options())
require.NoError(t, err)
assert.JSONEq(t, tt.want, string(written))

var read analysis.TemplateCallersConfiguration
require.NoError(t, json.Unmarshal(written, &read, configjson.Options()))
assert.Equal(t, tt.config, read)
})
}
}

// TestListingConfigurationJSONRejectsABadPattern states that a pattern
// that does not compile is reported where it was read.
func TestListingConfigurationJSONRejectsABadPattern(t *testing.T) {
var config analysis.TemplateCallsConfiguration
err := json.Unmarshal([]byte(`{"FilterTemplates":["("]}`), &config, configjson.Options())
require.ErrorContains(t, err, "error parsing regexp")
require.ErrorContains(t, err, "FilterTemplates")
}
23 changes: 9 additions & 14 deletions internal/analysis/module.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@ package analysis
import (
"bufio"
"bytes"
"encoding/json"
"encoding/json/v2"
"io"
"maps"
"os"
"os/exec"
"path/filepath"
"regexp"
"sort"
"slices"
"strings"

"github.com/spf13/pflag"
Expand Down Expand Up @@ -38,11 +39,11 @@ type PackageConfig struct {
ReceiverType string `json:"receiverType,omitempty"`
ReceiverPackage string `json:"receiverPackage,omitempty"`
TemplateRoutePathsType string `json:"templateRoutePathsType"`
OutputHTMX bool `json:"outputHTMX,omitempty"`
OutputDatastar bool `json:"outputDatastar,omitempty"`
Logger bool `json:"logger,omitempty"`
PathPrefix bool `json:"pathPrefix,omitempty"`
Middleware bool `json:"middleware,omitempty"`
OutputHTMX bool `json:"outputHTMX,omitzero"`
OutputDatastar bool `json:"outputDatastar,omitzero"`
Logger bool `json:"logger,omitzero"`
PathPrefix bool `json:"pathPrefix,omitzero"`
Middleware bool `json:"middleware,omitzero"`
}

type PackageCommands struct {
Expand Down Expand Up @@ -155,14 +156,8 @@ func NewModule(workingDirectory string, addFlags func(*pflag.FlagSet, *generate.
return nil, err
}

dirs := make([]string, 0, len(dirMap))
for dir := range dirMap {
dirs = append(dirs, dir)
}
sort.Strings(dirs)

var packages []PackageInfo
for _, dir := range dirs {
for _, dir := range slices.Sorted(maps.Keys(dirMap)) {
entry := dirMap[dir]
var config generate.RoutesFileConfiguration
set := pflag.NewFlagSet("parse-header", pflag.ContinueOnError)
Expand Down
Loading
Loading