From 8f20fbf9f64447faf8a8a4b6d49ee9630dfc66a2 Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:03:20 -0700 Subject: [PATCH 1/5] Snapshot what generate writes, from packages loaded in memory generate's own tests covered a few percent of it: what a handler looks like for a form struct, an sse route, a status code in the name or a redirect in the template was only stated by the integration suite, which compiles and runs every scratch module and takes over a minute. internal/load/loadtest builds what a package load returns without loading the package graph: it writes the files, type checks them in memory, and imports the official standard library from the export data go list reports for it, reading it with gcexportdata -- as go/packages does -- and giving each package the imports go list reports, which is how check finds fmt behind html/template. The standard library is whichever the go command in use provides. A test binary pays a few hundred milliseconds once to read it. TestStandardLibrary states load.StandardLibrary's answers against it, and TestHydration the order load's hydration reports a missing package, a missing receiver and a variable that does not evaluate. internal/generate/testdata/generate/*.txtar holds one case per feature: the receiver's Go source, the templates, and the files and log lines generation produces. An archive holds the configuration it generates with, in its own config.json, beside the command line that parses into it; nothing outside the archive says what a case is. The directory an archive is in names the command, so a case runs alone as -run TestSnapshots/generate/sse. Reading it is encoding/json/v2, which the module now asks for Go 1.27 to have. TestSnapshots loads the case through loadtest and load.GenerateSource, as muxt generate does, generates, and compares; the 32 cases cover most of the package. It also fails on a generated file that imports a package it does not use. go test -run TestSnapshots -update rewrites the want/ files, and the diff is the review. A snapshot says what the generator does, not that the result compiles or serves requests; that stays the integration suite's job. Assisted-by: Claude:claude-opus-5 gofumpt --- CLAUDE.md | 13 +- go.mod | 2 +- internal/cli/configurations_test.go | 17 + internal/configjson/configjson.go | 23 + internal/generate/generated_test.go | 38 ++ internal/generate/snapshot_test.go | 250 ++++++++++ .../generate/err_duplicate_pattern.txtar | 40 ++ .../testdata/generate/err_name_errors.txtar | 49 ++ .../testdata/generate/err_resolution.txtar | 61 +++ ...esponse_state_with_response_argument.txtar | 44 ++ .../err_route_paths_method_collision.txtar | 41 ++ .../err_signals_without_datastar.txtar | 37 ++ .../testdata/generate/execute_callback.txtar | 229 +++++++++ .../testdata/generate/flag_custom_names.txtar | 191 ++++++++ .../testdata/generate/flag_htmx.txtar | 246 ++++++++++ .../flag_logger_path_prefix_middleware.txtar | 219 +++++++++ .../generate/flag_multiple_files.txtar | 310 ++++++++++++ .../flag_unexported_identifiers.txtar | 369 +++++++++++++++ .../generate/flag_without_muxt_version.txtar | 166 +++++++ .../testdata/generate/form_struct.txtar | 260 ++++++++++ .../testdata/generate/form_values.txtar | 223 +++++++++ .../testdata/generate/inferred_methods.txtar | 252 ++++++++++ .../testdata/generate/last_event_id.txtar | 186 ++++++++ .../testdata/generate/marshal_json.txtar | 203 ++++++++ .../testdata/generate/multipart.txtar | 241 ++++++++++ .../testdata/generate/nested_calls.txtar | 240 ++++++++++ .../generate/path_parameter_types.txtar | 443 ++++++++++++++++++ .../generate/receiver_method_sets.txtar | 250 ++++++++++ .../generate/testdata/generate/redirect.txtar | 250 ++++++++++ .../testdata/generate/request_body.txtar | 230 +++++++++ .../testdata/generate/response_argument.txtar | 172 +++++++ .../testdata/generate/result_shapes.txtar | 286 +++++++++++ .../generate/route_without_call.txtar | 195 ++++++++ internal/generate/testdata/generate/sse.txtar | 372 +++++++++++++++ .../testdata/generate/sse_datastar.txtar | 408 ++++++++++++++++ .../sse_messages_without_datastar.txtar | 344 ++++++++++++++ .../testdata/generate/status_codes.txtar | 277 +++++++++++ .../generate/synthesized_method_note.txtar | 229 +++++++++ internal/load/hydrate_test.go | 80 ++++ internal/load/loadtest/loadtest.go | 228 +++++++++ internal/load/loadtest/loadtest_test.go | 64 +++ internal/load/stdlib_test.go | 86 ++++ 42 files changed, 7862 insertions(+), 2 deletions(-) create mode 100644 internal/configjson/configjson.go create mode 100644 internal/generate/generated_test.go create mode 100644 internal/generate/snapshot_test.go create mode 100644 internal/generate/testdata/generate/err_duplicate_pattern.txtar create mode 100644 internal/generate/testdata/generate/err_name_errors.txtar create mode 100644 internal/generate/testdata/generate/err_resolution.txtar create mode 100644 internal/generate/testdata/generate/err_response_state_with_response_argument.txtar create mode 100644 internal/generate/testdata/generate/err_route_paths_method_collision.txtar create mode 100644 internal/generate/testdata/generate/err_signals_without_datastar.txtar create mode 100644 internal/generate/testdata/generate/execute_callback.txtar create mode 100644 internal/generate/testdata/generate/flag_custom_names.txtar create mode 100644 internal/generate/testdata/generate/flag_htmx.txtar create mode 100644 internal/generate/testdata/generate/flag_logger_path_prefix_middleware.txtar create mode 100644 internal/generate/testdata/generate/flag_multiple_files.txtar create mode 100644 internal/generate/testdata/generate/flag_unexported_identifiers.txtar create mode 100644 internal/generate/testdata/generate/flag_without_muxt_version.txtar create mode 100644 internal/generate/testdata/generate/form_struct.txtar create mode 100644 internal/generate/testdata/generate/form_values.txtar create mode 100644 internal/generate/testdata/generate/inferred_methods.txtar create mode 100644 internal/generate/testdata/generate/last_event_id.txtar create mode 100644 internal/generate/testdata/generate/marshal_json.txtar create mode 100644 internal/generate/testdata/generate/multipart.txtar create mode 100644 internal/generate/testdata/generate/nested_calls.txtar create mode 100644 internal/generate/testdata/generate/path_parameter_types.txtar create mode 100644 internal/generate/testdata/generate/receiver_method_sets.txtar create mode 100644 internal/generate/testdata/generate/redirect.txtar create mode 100644 internal/generate/testdata/generate/request_body.txtar create mode 100644 internal/generate/testdata/generate/response_argument.txtar create mode 100644 internal/generate/testdata/generate/result_shapes.txtar create mode 100644 internal/generate/testdata/generate/route_without_call.txtar create mode 100644 internal/generate/testdata/generate/sse.txtar create mode 100644 internal/generate/testdata/generate/sse_datastar.txtar create mode 100644 internal/generate/testdata/generate/sse_messages_without_datastar.txtar create mode 100644 internal/generate/testdata/generate/status_codes.txtar create mode 100644 internal/generate/testdata/generate/synthesized_method_note.txtar create mode 100644 internal/load/hydrate_test.go create mode 100644 internal/load/loadtest/loadtest.go create mode 100644 internal/load/loadtest/loadtest_test.go create mode 100644 internal/load/stdlib_test.go diff --git a/CLAUDE.md b/CLAUDE.md index 6a51584d..bdf3060f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,7 +44,9 @@ use `internal/muxt/muxttest`, which builds the counterfeiter fake in 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`. +`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. **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 @@ -108,6 +110,14 @@ 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. +- **What a command does with a valid configuration:** + `internal/generate/testdata/generate/*.txtar` snapshot generated files from + packages loaded in memory in milliseconds. The directory names the command; + each archive's `config.json` is the configuration the command line in its + header parses into. Run one with + `go test ./internal/generate -run TestSnapshots/generate/sse`, and rewrite + them with `go test ./internal/generate -run TestSnapshots -update`, then + review the diff. Integration scripts are for what needs the go command: generated code compiling and serving requests, and files on disk. @@ -182,6 +192,7 @@ ls cmd/muxt/testdata/err_*.txt - `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 diff --git a/go.mod b/go.mod index 2efbcaee..8858bdc1 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/typelate/muxt -go 1.26.0 +go 1.27.0 require ( github.com/dustin/go-humanize v1.0.1 diff --git a/internal/cli/configurations_test.go b/internal/cli/configurations_test.go index 3123d179..87fc3b09 100644 --- a/internal/cli/configurations_test.go +++ b/internal/cli/configurations_test.go @@ -215,6 +215,23 @@ func TestCommandLineConfigurations(t *testing.T) { OutputMuxtVersion: true, }, }, + { + name: "unexported default identifiers with a receiver type", + args: "generate --use-receiver-type=Server --output-exported-default-identifiers=false", + want: generate.RoutesFileConfiguration{ + MuxtVersion: "v1.2.3", + PackageName: "main", + RoutesFunction: "templateRoutes", + ReceiverType: "Server", + ReceiverInterface: "routesReceiver", + TemplateDataType: "templateData", + SSETemplateDataType: "sseTemplateData", + TemplateRoutePathsTypeName: "templateRoutePaths", + TemplatesVariables: []string{"templates"}, + OutputFileName: "template_routes.go", + OutputMuxtVersion: true, + }, + }, { name: "unexported default identifiers keep an explicit name", args: "generate --output-exported-default-identifiers=false --output-routes-func=Routes", diff --git a/internal/configjson/configjson.go b/internal/configjson/configjson.go new file mode 100644 index 00000000..2ed664f7 --- /dev/null +++ b/internal/configjson/configjson.go @@ -0,0 +1,23 @@ +// Package configjson says how muxt reads and writes a command's +// configuration as JSON. +// +// It is how the snapshot archives in internal/{generate,analysis,mutation} +// hold the configuration they run with, so a configuration read back is the +// one a command line produced: a field a command line left alone is null +// rather than an empty list, and a member the configuration does not +// declare is an error rather than a typo nothing reports. +// +// Patterns say nothing here: regexp.Regexp reads and writes itself as the +// text it was compiled from. +package configjson + +import "encoding/json/v2" + +// Options reads and writes a configuration the way muxt holds one. +func Options() json.Options { + return json.JoinOptions( + json.FormatNilSliceAsNull(true), + json.FormatNilMapAsNull(true), + json.RejectUnknownMembers(true), + ) +} diff --git a/internal/generate/generated_test.go b/internal/generate/generated_test.go new file mode 100644 index 00000000..efef5384 --- /dev/null +++ b/internal/generate/generated_test.go @@ -0,0 +1,38 @@ +package generate + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFileArguments(t *testing.T) { + dir := t.TempDir() + for name, content := range map[string]string{ + "template_routes.go": "// Code generated by muxt generate --use-receiver-type=T. DO NOT EDIT.\n\npackage server\n", + "index_gen.go": "// Code generated by muxt generate . DO NOT EDIT.\n", + "other_generated.go": "// Code generated by stringer. DO NOT EDIT.\n", + "handwritten.go": "package server\n", + "notes.txt": "// Code generated by muxt generate --output-file=notes.txt. DO NOT EDIT.\n", + "second_line.go": "package server\n// Code generated by muxt generate --output-htmx. DO NOT EDIT.\n", + "empty.go": "", + "sub/template_rou.go": "// Code generated by muxt generate --output-htmx. DO NOT EDIT.\n", + } { + path := filepath.Join(dir, filepath.FromSlash(name)) + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) + } + + files, err := FileArguments(dir, DefaultRoutesFunctionName) + require.NoError(t, err) + assert.Equal(t, map[string][]string{ + filepath.Join(dir, "template_routes.go"): {"--use-receiver-type=T"}, + filepath.Join(dir, "index_gen.go"): {}, + }, files, "only the .go files in the directory itself whose first line is the muxt header") + + _, err = FileArguments(filepath.Join(dir, "missing"), DefaultRoutesFunctionName) + assert.Error(t, err) +} diff --git a/internal/generate/snapshot_test.go b/internal/generate/snapshot_test.go new file mode 100644 index 00000000..c2f11092 --- /dev/null +++ b/internal/generate/snapshot_test.go @@ -0,0 +1,250 @@ +package generate_test + +import ( + "encoding/json/v2" + "errors" + "flag" + "go/ast" + "go/parser" + "go/token" + "log" + "os" + "path" + "path/filepath" + "slices" + "strconv" + "strings" + "testing" + + "golang.org/x/tools/txtar" + + "github.com/typelate/muxt/internal/configjson" + "github.com/typelate/muxt/internal/generate" + "github.com/typelate/muxt/internal/load" + "github.com/typelate/muxt/internal/load/loadtest" + "github.com/typelate/muxt/internal/muxt" +) + +var update = flag.Bool("update", false, "rewrite the want/ files of the snapshot archives in testdata") + +// templatesGo declares the templates variable for an archive that does not +// declare its own: every template file, parsed as ParseFS would. +const templatesGo = `package server + +import ( + "embed" + "html/template" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) +` + +// TestSnapshots generates the routes files for each archive in +// testdata/generate and compares them with the archive's want/ files. The +// directory is the command, so one case runs with +// -run TestSnapshots/generate/sse. +// +// An archive holds everything a case is: the configuration it generates +// with, its inputs, and what it generates. +// +// - config.json is the configuration, as the command line named in the +// archive's header parses into it. TestCommandLineConfigurations in +// internal/cli states that parse; this states what generate does with +// the result. +// - Go files and .gohtml files are loaded as example.com/server by +// internal/load/loadtest -- type checked against the official standard +// library, without loading the package graph -- and hydrated by +// load.GenerateSource, as muxt generate does. An archive with no +// templates.go gets one declaring the templates variable over every +// .gohtml file. +// - want/ files are the expected output: one per generated file, named +// for it, and want/log.txt and want/error.txt for what generation +// logged and the error loading or generating returned. +// +// Paths in the output are relative to the directory the package was +// written to. +// +// Run with -update to rewrite the want/ files from the generator, then +// read the diff: the snapshot says what the generator does, not what it +// should do. Whether generated code compiles and serves requests is the +// integration suite's job, under cmd/muxt/testdata. +func TestSnapshots(t *testing.T) { + const command = "generate" + if stray, _ := filepath.Glob(filepath.Join("testdata", "*.txtar")); len(stray) > 0 { + t.Fatalf("%s is not in a command's directory; generate's archives are in testdata/%s", stray[0], command) + } + archives, err := filepath.Glob(filepath.Join("testdata", command, "*.txtar")) + if err != nil { + t.Fatal(err) + } + if len(archives) == 0 { + t.Fatalf("no archives in testdata/%s", command) + } + t.Run(command, func(t *testing.T) { + for _, archivePath := range archives { + runSnapshot(t, archivePath) + } + }) +} + +// runSnapshot compares one archive's want/ files with what it generates, +// or with -update rewrites them. +func runSnapshot(t *testing.T, archivePath string) { + t.Helper() + t.Run(strings.TrimSuffix(filepath.Base(archivePath), ".txtar"), func(t *testing.T) { + archive, err := txtar.ParseFile(archivePath) + if err != nil { + t.Fatal(err) + } + got := snapshot(t, configuration(t, archive), archive) + if *update { + writeSnapshot(t, archivePath, archive, got) + return + } + want := make(map[string]string) + for _, file := range archive.Files { + if name, ok := strings.CutPrefix(file.Name, "want/"); ok { + want[name] = string(file.Data) + } + } + for _, name := range sortedKeys(got, want) { + if got[name] != want[name] { + t.Errorf("want/%s differs (run go test -run TestSnapshots -update to rewrite):\n--- got\n%s\n--- want\n%s", name, got[name], want[name]) + } + } + }) +} + +// configuration reads the archive's config.json: the configuration to +// generate with. +func configuration(t *testing.T, archive *txtar.Archive) generate.RoutesFileConfiguration { + t.Helper() + for _, file := range archive.Files { + if file.Name != "config.json" { + continue + } + var config generate.RoutesFileConfiguration + if err := json.Unmarshal(file.Data, &config, configjson.Options()); err != nil { + t.Fatalf("config.json: %v", err) + } + return config + } + t.Fatal("the archive has no config.json") + return generate.RoutesFileConfiguration{} +} + +// snapshot loads an archive's package, generates from it, and returns the +// want/ files it produces, by name. +func snapshot(t *testing.T, config generate.RoutesFileConfiguration, archive *txtar.Archive) map[string]string { + t.Helper() + files := make(map[string]string) + for _, file := range archive.Files { + if strings.HasPrefix(file.Name, "want/") || file.Name == "config.json" { + continue + } + files[file.Name] = string(file.Data) + } + if _, declared := files["templates.go"]; !declared { + files["templates.go"] = templatesGo + } + + dir := t.TempDir() + relative := func(text string) string { return strings.ReplaceAll(text, dir+string(filepath.Separator), "") } + got := make(map[string]string) + fail := func(err error) map[string]string { + text := err.Error() + if multiLine, ok := errors.AsType[muxt.MultiLineError](err); ok { + text = multiLine.MultiLineError() + } + got["error.txt"] = relative(text) + "\n" + return got + } + + pl := loadtest.Package(t, dir, "example.com/server", files) + pkg, receiver, err := load.GenerateSource(dir, pl, config) + if err != nil { + return fail(err) + } + var logs strings.Builder + generated, err := generate.TemplateRoutesFiles(dir, config, pkg, receiver, load.StandardLibrary(pl), log.New(&logs, "", 0)) + for _, file := range generated { + got[relative(file.Path)] = file.Content + for _, name := range unusedImports(t, file.Content) { + t.Errorf("%s imports %s without using it", relative(file.Path), name) + } + } + if logs.Len() > 0 { + got["log.txt"] = relative(logs.String()) + } + if err != nil { + return fail(err) + } + return got +} + +func writeSnapshot(t *testing.T, archivePath string, archive *txtar.Archive, got map[string]string) { + t.Helper() + files := slices.DeleteFunc(slices.Clone(archive.Files), func(file txtar.File) bool { + return strings.HasPrefix(file.Name, "want/") + }) + for _, name := range sortedKeys(got) { + files = append(files, txtar.File{Name: "want/" + name, Data: []byte(got[name])}) + } + archive.Files = files + if err := os.WriteFile(archivePath, txtar.Format(archive), 0o644); err != nil { + t.Fatal(err) + } +} + +func sortedKeys(maps ...map[string]string) []string { + var keys []string + for _, m := range maps { + for key := range m { + if !slices.Contains(keys, key) { + keys = append(keys, key) + } + } + } + slices.Sort(keys) + return keys +} + +// unusedImports names the imports a generated file declares but does not +// refer to. Generated code that compiles has none; a file that did would +// be a generator registering an import for a declaration it did not write. +func unusedImports(t *testing.T, content string) []string { + t.Helper() + file, err := parser.ParseFile(token.NewFileSet(), "generated.go", content, 0) + if err != nil { + t.Fatal(err) + } + referenced := make(map[string]bool) + ast.Inspect(file, func(node ast.Node) bool { + if sel, ok := node.(*ast.SelectorExpr); ok { + // A package name resolves to nothing in the file; a local + // variable of the same name does. + if id, ok := sel.X.(*ast.Ident); ok && id.Obj == nil { + referenced[id.Name] = true + } + } + return true + }) + var unused []string + for _, spec := range file.Imports { + importPath, err := strconv.Unquote(spec.Path.Value) + if err != nil { + t.Fatal(err) + } + name := path.Base(importPath) + if spec.Name != nil { + name = spec.Name.Name + } + if !referenced[name] { + unused = append(unused, importPath) + } + } + return unused +} diff --git a/internal/generate/testdata/generate/err_duplicate_pattern.txtar b/internal/generate/testdata/generate/err_duplicate_pattern.txtar new file mode 100644 index 00000000..6ea98c9a --- /dev/null +++ b/internal/generate/testdata/generate/err_duplicate_pattern.txtar @@ -0,0 +1,40 @@ +Two templates may not register the same pattern. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /a"}}{{end}} +{{define "GET /a"}}{{end}} +-- server.go -- +package server +-- want/error.txt -- +duplicate route pattern "GET /a" +index.gohtml:1:11: first defined here +index.gohtml:2:11: also defined here diff --git a/internal/generate/testdata/generate/err_name_errors.txtar b/internal/generate/testdata/generate/err_name_errors.txtar new file mode 100644 index 00000000..741d0d17 --- /dev/null +++ b/internal/generate/testdata/generate/err_name_errors.txtar @@ -0,0 +1,49 @@ +Every malformed name is reported, each at its position. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "OPTIONS /a"}}{{end}} +{{define "GET /b//c"}}{{end}} +{{define "GET /{id}/{id} F(id)"}}{{end}} +-- server.go -- +package server +-- want/error.txt -- + OPTIONS /a + ^^^^^^^ +index.gohtml:1:11: OPTIONS method not allowed; allowed methods: GET, POST, PUT, PATCH, and DELETE + + GET /b//c + ^ +index.gohtml:2:18: path has an empty segment + + GET /{id}/{id} F(id) + ^^ +index.gohtml:3:22: path parameter name "id" is used more than once; parameter names must be unique within a path diff --git a/internal/generate/testdata/generate/err_resolution.txtar b/internal/generate/testdata/generate/err_resolution.txtar new file mode 100644 index 00000000..89bd24c0 --- /dev/null +++ b/internal/generate/testdata/generate/err_resolution.txtar @@ -0,0 +1,61 @@ +Resolution errors from every route are reported together, pointing at +the argument or method at fault, with where the method is defined. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{id} Float(id)"}}{{end}} +{{define "GET /ctx Context(request)"}}{{end}} +{{define "GET /none NoResults()"}}{{end}} +-- server.go -- +package server + +import "context" + +type T struct{} + +func (T) Float(id float64) string { return "" } +func (T) Context(ctx context.Context) string { return "" } +func (T) NoResults() {} +-- want/error.txt -- + GET /ctx Context(request) + ^^^^^^^ +index.gohtml:2:28: method expects type context.Context but request is *http.Request +server.go:8:10: Context is defined here + + GET /none NoResults() + ^^^^^^^^^ +index.gohtml:3:21: method NoResults() has no results; it should have one or two +server.go:9:10: NoResults is defined here + + GET /{id} Float(id) + ^^ +index.gohtml:1:27: method param type float64 not supported (supported: string, bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, or a type whose pointer implements encoding.TextUnmarshaler; bind as string and parse it yourself for other values) +server.go:7:10: Float is defined here diff --git a/internal/generate/testdata/generate/err_response_state_with_response_argument.txtar b/internal/generate/testdata/generate/err_response_state_with_response_argument.txtar new file mode 100644 index 00000000..a5dd0b36 --- /dev/null +++ b/internal/generate/testdata/generate/err_response_state_with_response_argument.txtar @@ -0,0 +1,44 @@ +A template may not set the status of a route whose method took the +response. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /download Download(response)"}}{{.StatusCode 404}}{{end}} +-- server.go -- +package server + +import "net/http" + +type T struct{} + +func (T) Download(w http.ResponseWriter) string { return "" } +-- want/error.txt -- +index.gohtml:1:11: template "GET /download Download(response)" calls StatusCode but Download takes the http.ResponseWriter, so muxt writes no status code or redirect for this route: either drop the response argument or call response.WriteHeader in the method diff --git a/internal/generate/testdata/generate/err_route_paths_method_collision.txtar b/internal/generate/testdata/generate/err_route_paths_method_collision.txtar new file mode 100644 index 00000000..1f7bcf8e --- /dev/null +++ b/internal/generate/testdata/generate/err_route_paths_method_collision.txtar @@ -0,0 +1,41 @@ +Handlers whose names export to the same TemplateRoutePaths method +collide. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /a list()"}}{{end}} +{{define "GET /b List()"}}{{end}} +-- server.go -- +package server +-- want/error.txt -- +TemplateRoutePaths method name collision: handlers "list" and "List" both produce method "List" +index.gohtml:1:11: "list" is defined here +index.gohtml:2:11: "List" is defined here diff --git a/internal/generate/testdata/generate/err_signals_without_datastar.txtar b/internal/generate/testdata/generate/err_signals_without_datastar.txtar new file mode 100644 index 00000000..1623d484 --- /dev/null +++ b/internal/generate/testdata/generate/err_signals_without_datastar.txtar @@ -0,0 +1,37 @@ +signals is Datastar's request body, so it needs --output-datastar. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /count Count(signals)"}}{{end}} +-- server.go -- +package server +-- want/error.txt -- +the signals argument in "POST /count Count(signals)" requires --output-datastar; it is shorthand for unmarshalJSON(body) diff --git a/internal/generate/testdata/generate/execute_callback.txtar b/internal/generate/testdata/generate/execute_callback.txtar new file mode 100644 index 00000000..0e384bc9 --- /dev/null +++ b/internal/generate/testdata/generate/execute_callback.txtar @@ -0,0 +1,229 @@ +A method taking execute renders the template when it calls back, with +the data it passes or none. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /with-data Render(ctx, execute)"}}{{.Result}}{{end}} +{{define "GET /without-data Plain(execute)"}}{{end}} +-- server.go -- +package server + +import "context" + +type T struct{} + +func (T) Render(ctx context.Context, execute func(string) error) error { return nil } +func (T) Plain(execute func() error) error { return nil } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" + "sync/atomic" +) + +type RoutesReceiver interface { + Render(ctx context.Context, execute func(string) error) error + Plain(execute func() error) error +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /with-data", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + var executed atomic.Bool + if len(td.errList) == 0 { + if err := receiver.Render(ctx, func(data string) error { + if !executed.CompareAndSwap(false, true) { + return errors.New("execute callback called more than once") + } + td.result = data + return templates.ExecuteTemplate(buf, "GET /with-data Render(ctx, execute)", &td) + }); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + td.okay = true + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /without-data", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct{}]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + var executed atomic.Bool + if len(td.errList) == 0 { + if err := receiver.Plain(func() error { + if !executed.CompareAndSwap(false, true) { + return errors.New("execute callback called more than once") + } + return templates.ExecuteTemplate(buf, "GET /without-data Plain(execute)", &td) + }); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + td.okay = true + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Render() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "with-data") +} + +func (routePaths TemplateRoutePaths) Plain() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "without-data") +} diff --git a/internal/generate/testdata/generate/flag_custom_names.txtar b/internal/generate/testdata/generate/flag_custom_names.txtar new file mode 100644 index 00000000..ac49c725 --- /dev/null +++ b/internal/generate/testdata/generate/flag_custom_names.txtar @@ -0,0 +1,191 @@ +The output flags rename the generated file and identifiers. + +Command line: muxt generate --use-receiver-type=Server --output-file=routes.go --output-routes-func=Routes --output-receiver-interface=Handlers --output-template-data-type=Data --output-template-route-paths-type=Paths +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "Routes", + "ReceiverType": "Server", + "ReceiverPackage": "", + "ReceiverInterface": "Handlers", + "TemplateDataType": "Data", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "Paths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{id} Show(id)"}}{{.Result}}{{end}} +-- server.go -- +package server + +type Server struct{} + +func (*Server) Show(id string) string { return id } +-- want/routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "sync" +) + +type Handlers interface { + Show(id string) string +} + +func Routes(mux *http.ServeMux, receiver Handlers) Paths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /{id}", func(response http.ResponseWriter, request *http.Request) { + var td = Data[Handlers, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idPathParam := request.PathValue("id") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Show(idPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /{id} Show(id)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return Paths{pathsPrefix: pathsPrefix} +} + +type Data[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *Data[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *Data[R, T]) Path() Paths { + return Paths{pathsPrefix: data.pathsPrefix} +} + +func (data *Data[R, T]) Result() T { + return data.result +} + +func (data *Data[R, T]) Request() *http.Request { + return data.request +} + +func (data *Data[R, T]) StatusCode(statusCode int) *Data[R, T] { + data.statusCode = statusCode + return data +} + +func (data *Data[R, T]) Header(key, value string) *Data[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *Data[R, T]) Ok() bool { + return data.okay +} + +func (data *Data[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *Data[R, T]) Receiver() R { + return data.receiver +} + +func (data *Data[R, T]) Redirect(url string, code int) (*Data[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *Data[R, T]) RedirectMultipleChoices(url string) (*Data[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *Data[R, T]) RedirectMovedPermanently(url string) (*Data[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *Data[R, T]) RedirectFound(url string) (*Data[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *Data[R, T]) RedirectSeeOther(url string) (*Data[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *Data[R, T]) String() string { + return "" +} + +type Paths struct { + pathsPrefix string +} + +func (routePaths Paths) Show(idPathParam string) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), routePaths.escapePathSegment(idPathParam)) +} + +func (routePaths Paths) escapePathSegment(value string) string { + switch value { + case ".": + return "%2E" + case "..": + return "%2E%2E" + } + return url.PathEscape(value) +} diff --git a/internal/generate/testdata/generate/flag_htmx.txtar b/internal/generate/testdata/generate/flag_htmx.txtar new file mode 100644 index 00000000..eb63cc8f --- /dev/null +++ b/internal/generate/testdata/generate/flag_htmx.txtar @@ -0,0 +1,246 @@ +--output-htmx adds the HX header helpers to TemplateData. + +Command line: muxt generate --output-htmx +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": true, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{$}"}}home{{end}} +-- server.go -- +package server +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "sync" +) + +type RoutesReceiver interface { +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /{$}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if err := templates.ExecuteTemplate(buf, "GET /{$}", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +func (data *TemplateData[R, T]) HXLocation(link string) *TemplateData[R, T] { + return data.Header("HX-Location", link) +} + +func (data *TemplateData[R, T]) HXPushURL(link string) *TemplateData[R, T] { + return data.Header("HX-Push-Url", link) +} + +func (data *TemplateData[R, T]) HXRedirect(link string) *TemplateData[R, T] { + return data.Header("HX-Redirect", link) +} + +func (data *TemplateData[R, T]) HXRefresh() *TemplateData[R, T] { + return data.Header("HX-Refresh", "true") +} + +func (data *TemplateData[R, T]) HXReplaceURL(link string) *TemplateData[R, T] { + return data.Header("HX-Replace-Url", link) +} + +func (data *TemplateData[R, T]) HXReswap(swap string) *TemplateData[R, T] { + return data.Header("HX-Reswap", swap) +} + +func (data *TemplateData[R, T]) HXRetarget(target string) *TemplateData[R, T] { + return data.Header("HX-Retarget", target) +} + +func (data *TemplateData[R, T]) HXReselect(selector string) *TemplateData[R, T] { + return data.Header("HX-Reselect", selector) +} + +func (data *TemplateData[R, T]) HXTrigger(eventName string) *TemplateData[R, T] { + return data.Header("HX-Trigger", eventName) +} + +func (data *TemplateData[R, T]) HXTriggerAfterSettle(eventName string) *TemplateData[R, T] { + return data.Header("HX-Trigger-After-Settle", eventName) +} + +func (data *TemplateData[R, T]) HXTriggerAfterSwap(eventName string) *TemplateData[R, T] { + return data.Header("HX-Trigger-After-Swap", eventName) +} + +func (data *TemplateData[R, T]) HXBoosted() bool { + return data.Request().Header.Get("HX-Boosted") != "" +} + +func (data *TemplateData[R, T]) HXCurrentURL() string { + return data.Request().Header.Get("HX-Current-Url") +} + +func (data *TemplateData[R, T]) HXHistoryRestoreRequest() bool { + return data.Request().Header.Get("HX-History-Restore-Request") == "true" +} + +func (data *TemplateData[R, T]) HXPrompt() string { + return data.Request().Header.Get("HX-Prompt") +} + +func (data *TemplateData[R, T]) HXRequest() bool { + return data.Request().Header.Get("HX-Request") == "true" +} + +func (data *TemplateData[R, T]) HXTargetElementID() string { + return data.Request().Header.Get("HX-Target") +} + +func (data *TemplateData[R, T]) HXTriggerName() string { + return data.Request().Header.Get("HX-Trigger-Name") +} + +func (data *TemplateData[R, T]) HXTriggerElementID() string { + return data.Request().Header.Get("HX-Trigger") +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ReadExact() string { + return "/" +} diff --git a/internal/generate/testdata/generate/flag_logger_path_prefix_middleware.txtar b/internal/generate/testdata/generate/flag_logger_path_prefix_middleware.txtar new file mode 100644 index 00000000..4b2adc3f --- /dev/null +++ b/internal/generate/testdata/generate/flag_logger_path_prefix_middleware.txtar @@ -0,0 +1,219 @@ +The routes function takes a logger, a path prefix, and a middleware. + +Command line: muxt generate --use-receiver-type=T --output-routes-func-with-logger-param --output-routes-func-with-path-prefix-param --output-routes-func-with-middleware-param +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": true, + "Logger": true, + "Middleware": true, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{$}"}}home{{end}} +{{define "GET /article/{id} Article(id)"}}{{.Result}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Article(id int) string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Article(id int) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver, logger *slog.Logger, pathsPrefix string, middleware func(next http.Handler) http.Handler) TemplateRoutePaths { + if middleware == nil { + middleware = func(next http.Handler) http.Handler { + return next + } + } + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.Handle("GET "+path.Join(pathsPrefix, "/article/{id}"), middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Article(idPathParam) + td.okay = true + } + logger.DebugContext(request.Context(), "handling request", slog.String("pattern", "GET /article/{id}"), slog.String("path", request.URL.Path), slog.String("method", request.Method)) + if err := templates.ExecuteTemplate(buf, "GET /article/{id} Article(id)", &td); err != nil { + logger.ErrorContext(request.Context(), "failed to render page", slog.String("pattern", "GET /article/{id}"), slog.String("path", request.URL.Path), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }))) + mux.Handle("GET "+path.Join(pathsPrefix, "/{$}"), middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + logger.DebugContext(request.Context(), "handling request", slog.String("pattern", "GET /{$}"), slog.String("path", request.URL.Path), slog.String("method", request.Method)) + if err := templates.ExecuteTemplate(buf, "GET /{$}", &td); err != nil { + logger.ErrorContext(request.Context(), "failed to render page", slog.String("pattern", "GET /{$}"), slog.String("path", request.URL.Path), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }))) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Article(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "article", strconv.Itoa(idPathParam)) +} + +func (routePaths TemplateRoutePaths) ReadExact() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/")) +} diff --git a/internal/generate/testdata/generate/flag_multiple_files.txtar b/internal/generate/testdata/generate/flag_multiple_files.txtar new file mode 100644 index 00000000..f6228cf9 --- /dev/null +++ b/internal/generate/testdata/generate/flag_multiple_files.txtar @@ -0,0 +1,310 @@ +--output-multiple-files writes the routes for each template file into +its own file, beside the main routes file. + +Command line: muxt generate --use-receiver-type=T --output-multiple-files --output-routes-func-with-middleware-param +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": true, + "Verbose": false, + "OutputMultipleFiles": true, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{$} Home()"}}{{.Result}}{{end}} +-- user-profile.gohtml -- +{{define "GET /user/{id} User(id)"}}{{.Result}}{{end}} +{{define "POST /user/{id} Save(id, form)"}}{{.Result}}{{end}} +-- server.go -- +package server + +import "net/url" + +type T struct{} + +func (T) Home() string { return "" } +func (T) User(id int) string { return "" } +func (T) Save(id int, form url.Values) error { return nil } +-- want/index_template_routes_gen.go -- +package server + +import ( + "bytes" + "cmp" + "log/slog" + "net/http" + "strconv" + "sync" +) + +type indexRoutesReceiver interface { + Home() string +} + +func indexTemplateRoutes(mux *http.ServeMux, receiver indexRoutesReceiver, pathsPrefix string, middleware func(next http.Handler) http.Handler) { + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.Handle("GET /{$}", middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[indexRoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Home() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /{$} Home()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }))) +} +-- want/template_routes.go -- +package server + +import ( + "cmp" + "errors" + "fmt" + "net/http" + "path" + "strconv" +) + +type RoutesReceiver interface { + indexRoutesReceiver + userProfileRoutesReceiver +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver, middleware func(next http.Handler) http.Handler) TemplateRoutePaths { + pathsPrefix := "" + if middleware == nil { + middleware = func(next http.Handler) http.Handler { + return next + } + } + indexTemplateRoutes(mux, receiver, pathsPrefix, middleware) + userProfileTemplateRoutes(mux, receiver, pathsPrefix, middleware) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) User(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "user", strconv.Itoa(idPathParam)) +} + +func (routePaths TemplateRoutePaths) Save(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "user", strconv.Itoa(idPathParam)) +} + +func (routePaths TemplateRoutePaths) Home() string { + return "/" +} +-- want/user-profile_template_routes_gen.go -- +package server + +import ( + "bytes" + "cmp" + "log/slog" + "net/http" + "net/url" + "strconv" + "sync" +) + +type userProfileRoutesReceiver interface { + User(id int) string + Save(id int, form url.Values) error +} + +func userProfileTemplateRoutes(mux *http.ServeMux, receiver userProfileRoutesReceiver, pathsPrefix string, middleware func(next http.Handler) http.Handler) { + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.Handle("GET /user/{id}", middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[userProfileRoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.User(idPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /user/{id} User(id)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }))) + mux.Handle("POST /user/{id}", middleware(http.HandlerFunc(func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[userProfileRoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + if err := request.ParseForm(); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + var form url.Values = request.Form + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Save(idPathParam, form) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /user/{id} Save(id, form)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }))) +} diff --git a/internal/generate/testdata/generate/flag_unexported_identifiers.txtar b/internal/generate/testdata/generate/flag_unexported_identifiers.txtar new file mode 100644 index 00000000..9706973b --- /dev/null +++ b/internal/generate/testdata/generate/flag_unexported_identifiers.txtar @@ -0,0 +1,369 @@ +--output-exported-default-identifiers=false names the generated +identifiers unexported, as the command line spells them. + +Command line: muxt generate --use-receiver-type=Server --output-exported-default-identifiers=false +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "templateRoutes", + "ReceiverType": "Server", + "ReceiverPackage": "", + "ReceiverInterface": "routesReceiver", + "TemplateDataType": "templateData", + "SSETemplateDataType": "sseTemplateData", + "TemplateRoutePathsTypeName": "templateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": false, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /events sse(Stream(ctx, execute))"}}{{.Result}}{{end}} +{{define "GET /{$}"}}home{{end}} +-- server.go -- +package server + +import "context" + +type Server struct{} + +func (Server) Stream(ctx context.Context, execute func(data string) error) {} +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strconv" + "strings" + "sync" +) + +type routesReceiver interface { + Stream(ctx context.Context, execute func(data string) error) +} + +func templateRoutes(mux *http.ServeMux, receiver routesReceiver) templateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /events", func(response http.ResponseWriter, request *http.Request) { + defer func() { + _ = request.Body.Close() + }() + flusher, ok := response.(http.Flusher) + if !ok { + http.Error(response, "streaming unsupported", http.StatusInternalServerError) + return + } + ctx := request.Context() + h := response.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Connection", "keep-alive") + h.Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + flusher.Flush() + var mut sync.Mutex + receiver.Stream(ctx, func(result string) error { + if err := request.Context().Err(); err != nil { + return err + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + td := sseTemplateData[routesReceiver, string]{receiver: receiver, request: request, pathsPrefix: pathsPrefix, result: result} + if err := templates.ExecuteTemplate(buf, "GET /events sse(Stream(ctx, execute))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + return err + } + td.data = buf + mut.Lock() + defer mut.Unlock() + if _, err := td.WriteTo(response); err != nil { + return err + } + flusher.Flush() + return nil + }) + }) + mux.HandleFunc("GET /{$}", func(response http.ResponseWriter, request *http.Request) { + var td = templateData[routesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if err := templates.ExecuteTemplate(buf, "GET /{$}", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return templateRoutePaths{pathsPrefix: pathsPrefix} +} + +type templateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *templateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *templateData[R, T]) Path() templateRoutePaths { + return templateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *templateData[R, T]) Result() T { + return data.result +} + +func (data *templateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *templateData[R, T]) StatusCode(statusCode int) *templateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *templateData[R, T]) Header(key, value string) *templateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *templateData[R, T]) Ok() bool { + return data.okay +} + +func (data *templateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *templateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *templateData[R, T]) Redirect(url string, code int) (*templateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *templateData[R, T]) RedirectMultipleChoices(url string) (*templateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *templateData[R, T]) RedirectMovedPermanently(url string) (*templateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *templateData[R, T]) RedirectFound(url string) (*templateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *templateData[R, T]) RedirectSeeOther(url string) (*templateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *templateData[R, T]) String() string { + return "" +} + +type sseTemplateData[R, T any] struct { + receiver R + request *http.Request + result T + pathsPrefix string + event, id *string + retryMilliseconds *int + errList []error + data *bytes.Buffer +} + +func (m *sseTemplateData[R, T]) String() string { + return "" +} + +func (m *sseTemplateData[R, T]) Receiver() R { + return m.receiver +} + +func (m *sseTemplateData[R, T]) Request() *http.Request { + return m.request +} + +func (m *sseTemplateData[R, T]) Result() T { + return m.result +} + +func (m *sseTemplateData[R, T]) Err() error { + return errors.Join(m.errList...) +} + +func (m *sseTemplateData[R, T]) Event(event string) *sseTemplateData[R, T] { + m.event = &event + return m +} + +func (m *sseTemplateData[R, T]) ID(id string) *sseTemplateData[R, T] { + m.id = &id + return m +} + +func (m *sseTemplateData[R, T]) Retry(retryMilliseconds int) *sseTemplateData[R, T] { + m.retryMilliseconds = &retryMilliseconds + return m +} + +func (m *sseTemplateData[R, T]) Path() templateRoutePaths { + return templateRoutePaths{pathsPrefix: m.pathsPrefix} +} + +func (m *sseTemplateData[R, T]) WriteTo(w io.Writer) (int64, error) { + if m.id != nil && strings.ContainsAny(*m.id, "\r\n\x00") { + return 0, errors.New("sse: id contains a forbidden character") + } + if m.event != nil && strings.ContainsAny(*m.event, "\r\n") { + return 0, errors.New("sse: event contains a forbidden character") + } + var bytesWritten int + if m.id != nil { + if n, err := io.WriteString(w, "id: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.id); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.event != nil { + if n, err := io.WriteString(w, "event: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.event); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.retryMilliseconds != nil { + if n, err := io.WriteString(w, "retry: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + var retryBuf [20]byte + if n, err := w.Write(strconv.AppendInt(retryBuf[:0], int64(*m.retryMilliseconds), 10)); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + data := m.data.Bytes() + if bytes.IndexByte(data, '\r') >= 0 { + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + data = bytes.ReplaceAll(data, []byte("\r"), []byte("\n")) + } + data = bytes.TrimSuffix(data, []byte{'\n'}) + for line := range bytes.SplitSeq(data, []byte{'\n'}) { + if n, err := io.WriteString(w, "data: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if len(line) > 0 { + if n, err := w.Write(line); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + return int64(bytesWritten), nil +} + +type templateRoutePaths struct { + pathsPrefix string +} + +func (routePaths templateRoutePaths) Stream() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "events") +} + +func (routePaths templateRoutePaths) ReadExact() string { + return "/" +} diff --git a/internal/generate/testdata/generate/flag_without_muxt_version.txtar b/internal/generate/testdata/generate/flag_without_muxt_version.txtar new file mode 100644 index 00000000..70ac0bce --- /dev/null +++ b/internal/generate/testdata/generate/flag_without_muxt_version.txtar @@ -0,0 +1,166 @@ +With --output-muxt-version=false, TemplateData has no MuxtVersion method, +so a template calling it does not compile. + +Command line: muxt generate --output-muxt-version=false +-- config.json -- +{ + "MuxtVersion": "", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": false, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{$}"}}{{.MuxtVersion}}{{end}} +-- server.go -- +package server +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "strconv" + "sync" +) + +type RoutesReceiver interface { +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /{$}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if err := templates.ExecuteTemplate(buf, "GET /{$}", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ReadExact() string { + return "/" +} diff --git a/internal/generate/testdata/generate/form_struct.txtar b/internal/generate/testdata/generate/form_struct.txtar new file mode 100644 index 00000000..fae889f9 --- /dev/null +++ b/internal/generate/testdata/generate/form_struct.txtar @@ -0,0 +1,260 @@ +A form struct binds each field from the form, parsing scalars and +slices, renaming inputs with the name tag, and validating from the +input a template tag names. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /signup Signup(form)"}}{{end}} +{{define "signup-form"}} + + +{{end}} +-- server.go -- +package server + +type Signup struct { + Age int `name:"age" template:"signup-form"` + Handle string `name:"handle" template:"signup-form"` + Tags []string `name:"tag"` + Score float64 + Verified bool + Count uint32 +} + +type T struct{} + +func (T) Signup(form Signup) error { return nil } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "regexp" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Signup(form Signup) error +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /signup", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseForm(); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + var form Signup + { + value, err := strconv.Atoi(request.FormValue("age")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } else { + if value < 13 { + td.errList = append(td.errList, errors.New("age must not be less than 13")) + td.errStatusCode = http.StatusBadRequest + } + if value > 130 { + td.errList = append(td.errList, errors.New("age must not be more than 130")) + td.errStatusCode = http.StatusBadRequest + } + } + form.Age = value + } + { + value := request.FormValue("handle") + if !regexp.MustCompile("[a-z]+").MatchString(value) { + td.errList = append(td.errList, errors.New("handle must match \"[a-z]+\"")) + td.errStatusCode = http.StatusBadRequest + } + if len(value) < 2 { + td.errList = append(td.errList, errors.New("handle is too short (the min length is 2)")) + td.errStatusCode = http.StatusBadRequest + } + if len(value) > 20 { + td.errList = append(td.errList, errors.New("handle is too long (the max length is 20)")) + td.errStatusCode = http.StatusBadRequest + } + form.Handle = value + } + for _, val := range request.Form["tag"] { + form.Tags = append(form.Tags, val) + } + { + value, err := strconv.ParseFloat(request.FormValue("Score"), 64) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + form.Score = value + } + { + value, err := strconv.ParseBool(request.FormValue("Verified")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + form.Verified = value + } + { + value, err := strconv.ParseUint(request.FormValue("Count"), 10, 32) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + form.Count = uint32(value) + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Signup(form) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /signup Signup(form)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Signup() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "signup") +} diff --git a/internal/generate/testdata/generate/form_values.txtar b/internal/generate/testdata/generate/form_values.txtar new file mode 100644 index 00000000..4a9f040c --- /dev/null +++ b/internal/generate/testdata/generate/form_values.txtar @@ -0,0 +1,223 @@ +A url.Values parameter receives the parsed form as it is. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /values Values(form)"}}{{end}} +{{define "POST /unmarshal Values(unmarshalForm(body))"}}{{end}} +-- server.go -- +package server + +import "net/url" + +type T struct{} + +func (T) Values(form url.Values) string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Values(form url.Values) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /unmarshal", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseForm(); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + var form url.Values = request.Form + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Values(form) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /unmarshal Values(unmarshalForm(body))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /values", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseForm(); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + var form url.Values = request.Form + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Values(form) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /values Values(form)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) CreateUnmarshalCallingValues() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "unmarshal") +} + +func (routePaths TemplateRoutePaths) CreateValuesCallingValues() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "values") +} diff --git a/internal/generate/testdata/generate/inferred_methods.txtar b/internal/generate/testdata/generate/inferred_methods.txtar new file mode 100644 index 00000000..3a376885 --- /dev/null +++ b/internal/generate/testdata/generate/inferred_methods.txtar @@ -0,0 +1,252 @@ +Without --use-receiver-type every called method is inferred from its +arguments, and returns any. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{id} Get(ctx, id)"}}{{.Result}}{{end}} +{{define "POST /upload Upload(request, response)"}}{{end}} +{{define "PATCH /note Note(form, lastEventID)"}}{{end}} +-- server.go -- +package server +-- want/log.txt -- +warning: POST /upload uses the response argument, so muxt does not manage this route's status codes, headers, or rendering; silence with MUXT_SILENCE_WARNING_HTTP_RESPONSE_ARGUMENT=true +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Note(form url.Values, lastEventID string) any + Upload(request *http.Request, response http.ResponseWriter) any + Get(ctx context.Context, id string) any +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("PATCH /note", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, any]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseForm(); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + var form url.Values = request.Form + lastEventID := request.Header.Get("Last-Event-Id") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Note(form, lastEventID) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "PATCH /note Note(form, lastEventID)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /upload", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, any]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Upload(request, response) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /upload Upload(request, response)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /{id}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, any]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + idPathParam := request.PathValue("id") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Get(ctx, idPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /{id} Get(ctx, id)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Note() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "note") +} + +func (routePaths TemplateRoutePaths) Upload() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "upload") +} + +func (routePaths TemplateRoutePaths) Get(idPathParam string) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), routePaths.escapePathSegment(idPathParam)) +} + +func (routePaths TemplateRoutePaths) escapePathSegment(value string) string { + switch value { + case ".": + return "%2E" + case "..": + return "%2E%2E" + } + return url.PathEscape(value) +} diff --git a/internal/generate/testdata/generate/last_event_id.txtar b/internal/generate/testdata/generate/last_event_id.txtar new file mode 100644 index 00000000..ffe4c5f2 --- /dev/null +++ b/internal/generate/testdata/generate/last_event_id.txtar @@ -0,0 +1,186 @@ +lastEventID reads the Last-Event-Id header, parsed into the parameter +type. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /resume Resume(lastEventID)"}}{{.Result}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Resume(lastEventID uint64) string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Resume(lastEventID uint64) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /resume", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + lastEventIDParsed, err := strconv.ParseUint(request.Header.Get("Last-Event-Id"), 10, 64) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + lastEventID := lastEventIDParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Resume(lastEventID) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /resume Resume(lastEventID)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Resume() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "resume") +} diff --git a/internal/generate/testdata/generate/marshal_json.txtar b/internal/generate/testdata/generate/marshal_json.txtar new file mode 100644 index 00000000..b9a7ea9a --- /dev/null +++ b/internal/generate/testdata/generate/marshal_json.txtar @@ -0,0 +1,203 @@ +marshalJSON writes the result as JSON, rendering the template for its +side effects and on errors. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /api/{id} marshalJSON(Get(id))"}}{{.Err}}{{end}} +-- server.go -- +package server + +type Item struct{ ID int } + +type T struct{} + +func (T) Get(id int) (Item, error) { return Item{}, nil } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Get(id int) (Item, error) +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /api/{id}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, Item]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + var err error + td.result, err = receiver.Get(idPathParam) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusInternalServerError + } + } + if err := templates.ExecuteTemplate(buf, "GET /api/{id} marshalJSON(Get(id))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + if len(td.errList) == 0 { + buf.Reset() + jsonBody, err := json.Marshal(td.result) + if err != nil { + http.Error(response, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError) + return + } + _, _ = buf.Write(jsonBody) + response.Header().Set("content-type", "application/json") + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Get(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "api", strconv.Itoa(idPathParam)) +} diff --git a/internal/generate/testdata/generate/multipart.txtar b/internal/generate/testdata/generate/multipart.txtar new file mode 100644 index 00000000..2dc5b48f --- /dev/null +++ b/internal/generate/testdata/generate/multipart.txtar @@ -0,0 +1,241 @@ +A multipart struct binds text fields and file headers; a +*multipart.Form parameter receives the parsed form. + +Command line: muxt generate --use-receiver-type=T --output-multipart-max-memory=1MiB +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 1048576, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /upload Upload(multipart)"}}{{end}} +{{define "POST /raw Raw(multipart)"}}{{end}} +-- server.go -- +package server + +import "mime/multipart" + +type Upload struct { + Title string + File *multipart.FileHeader + Files []*multipart.FileHeader `name:"attachment"` +} + +type T struct{} + +func (T) Upload(in Upload) error { return nil } +func (T) Raw(form *multipart.Form) error { return nil } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "mime/multipart" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Raw(form *multipart.Form) error + Upload(in Upload) error +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /raw", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseMultipartForm(1048576); err != nil && !errors.Is(err, http.ErrNotMultipart) { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + var multipart *multipart.Form = request.MultipartForm + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Raw(multipart) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /raw Raw(multipart)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /upload", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + if err := request.ParseMultipartForm(1048576); err != nil && !errors.Is(err, http.ErrNotMultipart) { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + var multipart Upload + multipart.Title = request.FormValue("Title") + if request.MultipartForm != nil { + if fhs := request.MultipartForm.File["File"]; len(fhs) > 0 { + multipart.File = fhs[0] + } + } + if request.MultipartForm != nil { + multipart.Files = request.MultipartForm.File["attachment"] + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Upload(multipart) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /upload Upload(multipart)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Raw() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "raw") +} + +func (routePaths TemplateRoutePaths) Upload() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "upload") +} diff --git a/internal/generate/testdata/generate/nested_calls.txtar b/internal/generate/testdata/generate/nested_calls.txtar new file mode 100644 index 00000000..7ba69f84 --- /dev/null +++ b/internal/generate/testdata/generate/nested_calls.txtar @@ -0,0 +1,240 @@ +A call's argument may be the result of another call, a receiver method +or a package function, which runs first. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /article/{id} Show(ctx, Load(ctx, id))"}}{{.Result}}{{end}} +{{define "GET /user Show(ctx, Current(request))"}}{{.Result}}{{end}} +-- server.go -- +package server + +import ( + "context" + "net/http" +) + +type Article struct{ Title string } + +type T struct{} + +func (T) Load(ctx context.Context, id int) (Article, error) { return Article{}, nil } +func (T) Show(ctx context.Context, a Article) string { return "" } + +func Current(r *http.Request) (Article, bool) { return Article{}, false } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Load(ctx context.Context, id int) (Article, error) + Show(ctx context.Context, a Article) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /article/{id}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + result0, err := receiver.Load(ctx, idPathParam) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusInternalServerError + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Show(ctx, result0) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /article/{id} Show(ctx, Load(ctx, id))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /user", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + result0, ok := Current(request) + if !ok { + return + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Show(ctx, result0) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /user Show(ctx, Current(request))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ReadArticleByIDCallingShow(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "article", strconv.Itoa(idPathParam)) +} + +func (routePaths TemplateRoutePaths) ReadUserCallingShow() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "user") +} diff --git a/internal/generate/testdata/generate/path_parameter_types.txtar b/internal/generate/testdata/generate/path_parameter_types.txtar new file mode 100644 index 00000000..755bada0 --- /dev/null +++ b/internal/generate/testdata/generate/path_parameter_types.txtar @@ -0,0 +1,443 @@ +Each path parameter parses into the type of the parameter it is passed +to, and the route path helper takes that type. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /int/{id} Int(id)"}}{{.Result}}{{end}} +{{define "GET /small/{a}/{b}/{c} Small(a, b, c)"}}{{.Result}}{{end}} +{{define "GET /bool/{flag} Bool(flag)"}}{{.Result}}{{end}} +{{define "GET /string/{name} String(name)"}}{{.Result}}{{end}} +{{define "GET /at/{at} At(at)"}}{{.Result}}{{end}} +{{define "GET /files/{path...} Files(path)"}}{{.Result}}{{end}} +{{define "GET /exact/{$} Exact()"}}{{.Result}}{{end}} +-- server.go -- +package server + +import "time" + +type T struct{} + +func (T) Int(id int) int { return id } +func (T) Small(a int8, b uint16, c int64) int64 { return c } +func (T) Bool(flag bool) bool { return flag } +func (T) String(name string) string { return name } +func (T) At(at time.Time) time.Time { return at } +func (T) Files(path string) string { return path } +func (T) Exact() string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "strings" + "sync" + "time" +) + +type RoutesReceiver interface { + At(at time.Time) time.Time + Bool(flag bool) bool + Exact() string + Files(path string) string + Int(id int) int + Small(a int8, b uint16, c int64) int64 + String(name string) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /at/{at}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, time.Time]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + var atParsed time.Time + if err := atParsed.UnmarshalText([]byte(request.PathValue("at"))); err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + atPathParam := atParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.At(atPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /at/{at} At(at)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /bool/{flag}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, bool]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + flagParsed, err := strconv.ParseBool(request.PathValue("flag")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + flagPathParam := flagParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Bool(flagPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /bool/{flag} Bool(flag)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /exact/{$}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Exact() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /exact/{$} Exact()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /files/{path...}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + pathPathParam := request.PathValue("path") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Files(pathPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /files/{path...} Files(path)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /int/{id}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, int]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + idPathParam := idParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Int(idPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /int/{id} Int(id)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /small/{a}/{b}/{c}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, int64]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + aParsed, err := strconv.ParseInt(request.PathValue("a"), 10, 8) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + aPathParam := int8(aParsed) + bParsed, err := strconv.ParseUint(request.PathValue("b"), 10, 16) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + bPathParam := uint16(bParsed) + cParsed, err := strconv.ParseInt(request.PathValue("c"), 10, 64) + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + cPathParam := cParsed + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Small(aPathParam, bPathParam, cPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /small/{a}/{b}/{c} Small(a, b, c)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /string/{name}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + namePathParam := request.PathValue("name") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.String(namePathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /string/{name} String(name)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) At(atPathParam time.Time) (string, error) { + segment2_06e81700, err := atPathParam.MarshalText() + if err != nil { + return "", fmt.Errorf("failed to marshal path value {at} (segment 2) in /at/{at}: %w", err) + } + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "at", routePaths.escapePathSegment(string(segment2_06e81700))), nil +} + +func (routePaths TemplateRoutePaths) Bool(flagPathParam bool) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "bool", strconv.FormatBool(bool(flagPathParam))) +} + +func (routePaths TemplateRoutePaths) Exact() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "exact") + "/" +} + +func (routePaths TemplateRoutePaths) Files(pathPathParam string) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "files", routePaths.escapePathSegments(pathPathParam)) +} + +func (routePaths TemplateRoutePaths) Int(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "int", strconv.Itoa(idPathParam)) +} + +func (routePaths TemplateRoutePaths) Small(aPathParam int8, bPathParam uint16, cPathParam int64) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "small", strconv.FormatInt(int64(aPathParam), 10), strconv.FormatUint(uint64(bPathParam), 10), strconv.FormatInt(int64(cPathParam), 10)) +} + +func (routePaths TemplateRoutePaths) String(namePathParam string) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "string", routePaths.escapePathSegment(namePathParam)) +} + +func (routePaths TemplateRoutePaths) escapePathSegment(value string) string { + switch value { + case ".": + return "%2E" + case "..": + return "%2E%2E" + } + return url.PathEscape(value) +} + +func (routePaths TemplateRoutePaths) escapePathSegments(value string) string { + segments := strings.Split(value, "/") + for i, segment := range segments { + segments[i] = routePaths.escapePathSegment(segment) + } + return strings.Join(segments, "/") +} diff --git a/internal/generate/testdata/generate/receiver_method_sets.txtar b/internal/generate/testdata/generate/receiver_method_sets.txtar new file mode 100644 index 00000000..844e5aa8 --- /dev/null +++ b/internal/generate/testdata/generate/receiver_method_sets.txtar @@ -0,0 +1,250 @@ +Methods are found through embedded fields and pointer receivers; +functions in the package are called directly and never join the +receiver interface. + +Command line: muxt generate --use-receiver-type=Server +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "Server", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /embedded Embedded()"}}{{.Result}}{{end}} +{{define "GET /pointer Pointer()"}}{{.Result}}{{end}} +{{define "GET /function Function()"}}{{.Result}}{{end}} +-- server.go -- +package server + +type Base struct{} + +func (Base) Embedded() string { return "" } + +type Server struct{ Base } + +func (*Server) Pointer() int { return 0 } + +func Function() bool { return false } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Embedded() string + Pointer() int +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /embedded", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Embedded() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /embedded Embedded()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /function", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, bool]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = Function() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /function Function()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /pointer", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, int]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Pointer() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /pointer Pointer()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Embedded() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "embedded") +} + +func (routePaths TemplateRoutePaths) Function() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "function") +} + +func (routePaths TemplateRoutePaths) Pointer() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "pointer") +} diff --git a/internal/generate/testdata/generate/redirect.txtar b/internal/generate/testdata/generate/redirect.txtar new file mode 100644 index 00000000..d1bccde4 --- /dev/null +++ b/internal/generate/testdata/generate/redirect.txtar @@ -0,0 +1,250 @@ +Only a template that may call a redirect method gets the redirect block. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /redirects Save()"}}{{.RedirectSeeOther "/"}}{{end}} +{{define "POST /via-partial Save()"}}{{template "partial" .}}{{end}} +{{define "partial"}}{{.Redirect "/" 302}}{{end}} +{{define "GET /stays Save()"}}{{.Result}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Save() string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Save() string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /redirects", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Save() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /redirects Save()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if td.redirectURL != "" { + http.Redirect(response, request, td.redirectURL, statusCode) + return + } + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /stays", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Save() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /stays Save()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /via-partial", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Save() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /via-partial Save()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if td.redirectURL != "" { + http.Redirect(response, request, td.redirectURL, statusCode) + return + } + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) CreateRedirectsCallingSave() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "redirects") +} + +func (routePaths TemplateRoutePaths) ReadStaysCallingSave() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "stays") +} + +func (routePaths TemplateRoutePaths) CreateViaPartialCallingSave() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "via-partial") +} diff --git a/internal/generate/testdata/generate/request_body.txtar b/internal/generate/testdata/generate/request_body.txtar new file mode 100644 index 00000000..3f1d02e5 --- /dev/null +++ b/internal/generate/testdata/generate/request_body.txtar @@ -0,0 +1,230 @@ +The body is passed as an io.Reader, or decoded as JSON into the +parameter's type. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /raw Raw(body)"}}{{end}} +{{define "POST /json Decode(ctx, unmarshalJSON(body))"}}{{end}} +-- server.go -- +package server + +import ( + "context" + "io" +) + +type Payload struct{ Name string } + +type T struct{} + +func (T) Raw(body io.Reader) error { return nil } +func (T) Decode(ctx context.Context, in Payload) error { return nil } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Decode(ctx context.Context, in Payload) error + Raw(body io.Reader) error +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /json", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + var bodyValue Payload + if err := json.NewDecoder(request.Body).Decode(&bodyValue); err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusBadRequest + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Decode(ctx, bodyValue) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /json Decode(ctx, unmarshalJSON(body))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /raw", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, error]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + body := request.Body + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Raw(body) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /raw Raw(body)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Decode() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "json") +} + +func (routePaths TemplateRoutePaths) Raw() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "raw") +} diff --git a/internal/generate/testdata/generate/response_argument.txtar b/internal/generate/testdata/generate/response_argument.txtar new file mode 100644 index 00000000..12fc8c22 --- /dev/null +++ b/internal/generate/testdata/generate/response_argument.txtar @@ -0,0 +1,172 @@ +A method taking the response writes it itself: muxt writes no status. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /download Download(response, request)"}}{{end}} +-- server.go -- +package server + +import "net/http" + +type T struct{} + +func (T) Download(w http.ResponseWriter, r *http.Request) string { return "" } +-- want/log.txt -- +warning: GET /download uses the response argument, so muxt does not manage this route's status codes, headers, or rendering; silence with MUXT_SILENCE_WARNING_HTTP_RESPONSE_ARGUMENT=true +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "sync" +) + +type RoutesReceiver interface { + Download(w http.ResponseWriter, r *http.Request) string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /download", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Download(response, request) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /download Download(response, request)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Download() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "download") +} diff --git a/internal/generate/testdata/generate/result_shapes.txtar b/internal/generate/testdata/generate/result_shapes.txtar new file mode 100644 index 00000000..665ba3a3 --- /dev/null +++ b/internal/generate/testdata/generate/result_shapes.txtar @@ -0,0 +1,286 @@ +A method returns a value, a value and an error, or a value and a bool. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /value Value()"}}{{.Result}}{{end}} +{{define "GET /error ValueError()"}}{{.Result}}{{end}} +{{define "GET /ok ValueOK()"}}{{.Result}}{{end}} +{{define "GET /function Function()"}}{{.Result}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Value() string { return "" } +func (T) ValueError() (int, error) { return 0, nil } +func (T) ValueOK() (bool, bool) { return false, false } + +func Function() string { return "" } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + ValueError() (int, error) + ValueOK() (bool, bool) + Value() string +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /error", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, int]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + var err error + td.result, err = receiver.ValueError() + if err != nil { + td.errList = append(td.errList, err) + td.errStatusCode = http.StatusInternalServerError + } + } + if err := templates.ExecuteTemplate(buf, "GET /error ValueError()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /function", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = Function() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /function Function()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /ok", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, bool]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + var ok bool + td.result, ok = receiver.ValueOK() + if !ok { + return + } + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /ok ValueOK()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /value", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Value() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /value Value()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ValueError() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "error") +} + +func (routePaths TemplateRoutePaths) Function() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "function") +} + +func (routePaths TemplateRoutePaths) ValueOk() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "ok") +} + +func (routePaths TemplateRoutePaths) Value() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "value") +} diff --git a/internal/generate/testdata/generate/route_without_call.txtar b/internal/generate/testdata/generate/route_without_call.txtar new file mode 100644 index 00000000..b24ba8b7 --- /dev/null +++ b/internal/generate/testdata/generate/route_without_call.txtar @@ -0,0 +1,195 @@ +A route with no call renders its template with no result. + +Command line: muxt generate +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{$}"}}

Home

{{end}} +{{define "GET /about 203"}}

About

{{end}} +-- server.go -- +package server +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /about", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if err := templates.ExecuteTemplate(buf, "GET /about 203", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, http.StatusNonAuthoritativeInfo) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /{$}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, struct { + }]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if err := templates.ExecuteTemplate(buf, "GET /{$}", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ReadAbout() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "about") +} + +func (routePaths TemplateRoutePaths) ReadExact() string { + return "/" +} diff --git a/internal/generate/testdata/generate/sse.txtar b/internal/generate/testdata/generate/sse.txtar new file mode 100644 index 00000000..270101c0 --- /dev/null +++ b/internal/generate/testdata/generate/sse.txtar @@ -0,0 +1,372 @@ +An sse route streams an event per callback, from the route template or +a same-named one, and reads lastEventID. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /events sse(Events(ctx, lastEventID, execute, sseClock))"}}{{.Result}}{{end}} +{{define "sseClock"}}{{.Result}}{{end}} +-- server.go -- +package server + +import "context" + +type T struct{} + +func (T) Events(ctx context.Context, lastEventID int, execute func(string) error, sseClock func(int) error) error { + return nil +} +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strconv" + "strings" + "sync" +) + +type RoutesReceiver interface { + Events(ctx context.Context, lastEventID int, execute func(string) error, sseClock func(int) error) error +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /events", func(response http.ResponseWriter, request *http.Request) { + defer func() { + _ = request.Body.Close() + }() + flusher, ok := response.(http.Flusher) + if !ok { + http.Error(response, "streaming unsupported", http.StatusInternalServerError) + return + } + ctx := request.Context() + lastEventIDParsed, err := strconv.Atoi(request.Header.Get("Last-Event-Id")) + if err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + lastEventID := lastEventIDParsed + h := response.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Connection", "keep-alive") + h.Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + flusher.Flush() + var mut sync.Mutex + if err := receiver.Events(ctx, lastEventID, func(result string) error { + if err := request.Context().Err(); err != nil { + return err + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + td := SSETemplateData[RoutesReceiver, string]{receiver: receiver, request: request, pathsPrefix: pathsPrefix, result: result} + if err := templates.ExecuteTemplate(buf, "GET /events sse(Events(ctx, lastEventID, execute, sseClock))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + return err + } + td.data = buf + mut.Lock() + defer mut.Unlock() + if _, err := td.WriteTo(response); err != nil { + return err + } + flusher.Flush() + return nil + }, func(result int) error { + if err := request.Context().Err(); err != nil { + return err + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + td := SSETemplateData[RoutesReceiver, int]{receiver: receiver, request: request, pathsPrefix: pathsPrefix, result: result} + if err := templates.ExecuteTemplate(buf, "sseClock", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + return err + } + td.data = buf + mut.Lock() + defer mut.Unlock() + if _, err := td.WriteTo(response); err != nil { + return err + } + flusher.Flush() + return nil + }); err != nil { + slog.ErrorContext(request.Context(), "sse handler returned an error", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + } + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type SSETemplateData[R, T any] struct { + receiver R + request *http.Request + result T + pathsPrefix string + event, id *string + retryMilliseconds *int + errList []error + data *bytes.Buffer +} + +func (m *SSETemplateData[R, T]) String() string { + return "" +} + +func (m *SSETemplateData[R, T]) Receiver() R { + return m.receiver +} + +func (m *SSETemplateData[R, T]) Request() *http.Request { + return m.request +} + +func (m *SSETemplateData[R, T]) Result() T { + return m.result +} + +func (m *SSETemplateData[R, T]) Err() error { + return errors.Join(m.errList...) +} + +func (m *SSETemplateData[R, T]) Event(event string) *SSETemplateData[R, T] { + m.event = &event + return m +} + +func (m *SSETemplateData[R, T]) ID(id string) *SSETemplateData[R, T] { + m.id = &id + return m +} + +func (m *SSETemplateData[R, T]) Retry(retryMilliseconds int) *SSETemplateData[R, T] { + m.retryMilliseconds = &retryMilliseconds + return m +} + +func (m *SSETemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: m.pathsPrefix} +} + +func (m *SSETemplateData[R, T]) WriteTo(w io.Writer) (int64, error) { + if m.id != nil && strings.ContainsAny(*m.id, "\r\n\x00") { + return 0, errors.New("sse: id contains a forbidden character") + } + if m.event != nil && strings.ContainsAny(*m.event, "\r\n") { + return 0, errors.New("sse: event contains a forbidden character") + } + var bytesWritten int + if m.id != nil { + if n, err := io.WriteString(w, "id: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.id); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.event != nil { + if n, err := io.WriteString(w, "event: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.event); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.retryMilliseconds != nil { + if n, err := io.WriteString(w, "retry: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + var retryBuf [20]byte + if n, err := w.Write(strconv.AppendInt(retryBuf[:0], int64(*m.retryMilliseconds), 10)); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + data := m.data.Bytes() + if bytes.IndexByte(data, '\r') >= 0 { + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + data = bytes.ReplaceAll(data, []byte("\r"), []byte("\n")) + } + data = bytes.TrimSuffix(data, []byte{'\n'}) + for line := range bytes.SplitSeq(data, []byte{'\n'}) { + if n, err := io.WriteString(w, "data: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if len(line) > 0 { + if n, err := w.Write(line); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + return int64(bytesWritten), nil +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Events() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "events") +} diff --git a/internal/generate/testdata/generate/sse_datastar.txtar b/internal/generate/testdata/generate/sse_datastar.txtar new file mode 100644 index 00000000..991f5a1c --- /dev/null +++ b/internal/generate/testdata/generate/sse_datastar.txtar @@ -0,0 +1,408 @@ +With --output-datastar an sse route frames datastar patch events, and +signals decodes the request body. + +Command line: muxt generate --use-receiver-type=T --output-datastar +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": true, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /count sse(Count(signals, execute, countsSignals))"}}{{.Result}}{{end}} +-- server.go -- +package server + +type Signals struct{ Count int } + +type T struct{} + +func (T) Count(in Signals, execute func(int) error, countsSignals func(Signals) error) {} +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strconv" + "strings" + "sync" +) + +type RoutesReceiver interface { + Count(in Signals, execute func(int) error, countsSignals func(Signals) error) +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("POST /count", func(response http.ResponseWriter, request *http.Request) { + defer func() { + _ = request.Body.Close() + }() + flusher, ok := response.(http.Flusher) + if !ok { + http.Error(response, "streaming unsupported", http.StatusInternalServerError) + return + } + var bodyValue Signals + if err := json.NewDecoder(request.Body).Decode(&bodyValue); err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + h := response.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Connection", "keep-alive") + h.Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + flusher.Flush() + var mut sync.Mutex + receiver.Count(bodyValue, func(result int) error { + if err := request.Context().Err(); err != nil { + return err + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + td := SSETemplateData[RoutesReceiver, int]{receiver: receiver, request: request, pathsPrefix: pathsPrefix, result: result} + if err := templates.ExecuteTemplate(buf, "POST /count sse(Count(signals, execute, countsSignals))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + return err + } + td.data = buf + mut.Lock() + defer mut.Unlock() + if _, err := td.WriteTo(response); err != nil { + return err + } + flusher.Flush() + return nil + }, func(result Signals) error { + if err := request.Context().Err(); err != nil { + return err + } + payload, err := json.Marshal(result) + if err != nil { + return err + } + mut.Lock() + defer mut.Unlock() + if _, err := io.WriteString(response, "event: datastar-patch-signals\ndata: signals "); err != nil { + return err + } + if _, err := response.Write(payload); err != nil { + return err + } + if _, err := io.WriteString(response, "\n\n"); err != nil { + return err + } + flusher.Flush() + return nil + }) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type SSETemplateData[R, T any] struct { + receiver R + request *http.Request + result T + pathsPrefix string + event, id *string + retryMilliseconds *int + errList []error + data *bytes.Buffer + selector, mode *string + useViewTransition bool +} + +func (m *SSETemplateData[R, T]) String() string { + return "" +} + +func (m *SSETemplateData[R, T]) Receiver() R { + return m.receiver +} + +func (m *SSETemplateData[R, T]) Request() *http.Request { + return m.request +} + +func (m *SSETemplateData[R, T]) Result() T { + return m.result +} + +func (m *SSETemplateData[R, T]) Err() error { + return errors.Join(m.errList...) +} + +func (m *SSETemplateData[R, T]) ID(id string) *SSETemplateData[R, T] { + m.id = &id + return m +} + +func (m *SSETemplateData[R, T]) Retry(retryMilliseconds int) *SSETemplateData[R, T] { + m.retryMilliseconds = &retryMilliseconds + return m +} + +func (m *SSETemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: m.pathsPrefix} +} + +func (m *SSETemplateData[R, T]) Selector(selector string) *SSETemplateData[R, T] { + m.selector = &selector + return m +} + +func (m *SSETemplateData[R, T]) Mode(mode string) *SSETemplateData[R, T] { + m.mode = &mode + return m +} + +func (m *SSETemplateData[R, T]) UseViewTransition(value bool) *SSETemplateData[R, T] { + m.useViewTransition = value + return m +} + +func (m *SSETemplateData[R, T]) WriteTo(w io.Writer) (int64, error) { + if m.id != nil && strings.ContainsAny(*m.id, "\r\n\x00") { + return 0, errors.New("sse: id contains a forbidden character") + } + if m.selector != nil && strings.ContainsAny(*m.selector, "\r\n") { + return 0, errors.New("sse: selector contains a forbidden character") + } + if m.mode != nil && strings.ContainsAny(*m.mode, "\r\n") { + return 0, errors.New("sse: mode contains a forbidden character") + } + var bytesWritten int + if n, err := io.WriteString(w, "event: datastar-patch-elements\n"); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if m.id != nil { + if n, err := io.WriteString(w, "id: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.id); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.retryMilliseconds != nil { + if n, err := io.WriteString(w, "retry: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + var retryBuf [20]byte + if n, err := w.Write(strconv.AppendInt(retryBuf[:0], int64(*m.retryMilliseconds), 10)); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.selector != nil { + if n, err := io.WriteString(w, "data: selector "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.selector); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.mode != nil { + if n, err := io.WriteString(w, "data: mode "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.mode); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.useViewTransition { + if n, err := io.WriteString(w, "data: useViewTransition true\n"); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + data := m.data.Bytes() + if bytes.IndexByte(data, '\r') >= 0 { + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + data = bytes.ReplaceAll(data, []byte("\r"), []byte("\n")) + } + data = bytes.TrimSuffix(data, []byte{'\n'}) + for line := range bytes.SplitSeq(data, []byte{'\n'}) { + if n, err := io.WriteString(w, "data: elements "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write(line); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + return int64(bytesWritten), nil +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Count() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "count") +} diff --git a/internal/generate/testdata/generate/sse_messages_without_datastar.txtar b/internal/generate/testdata/generate/sse_messages_without_datastar.txtar new file mode 100644 index 00000000..cc0e6a1b --- /dev/null +++ b/internal/generate/testdata/generate/sse_messages_without_datastar.txtar @@ -0,0 +1,344 @@ +An sse method returning nothing, with a data-less callback. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /ticks/{id} sse(Ticks(request, id, execute))"}}tick{{end}} +-- server.go -- +package server + +import "net/http" + +type T struct{} + +func (T) Ticks(r *http.Request, id int, execute func() error) {} +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "path" + "strconv" + "strings" + "sync" +) + +type RoutesReceiver interface { + Ticks(r *http.Request, id int, execute func() error) +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /ticks/{id}", func(response http.ResponseWriter, request *http.Request) { + defer func() { + _ = request.Body.Close() + }() + flusher, ok := response.(http.Flusher) + if !ok { + http.Error(response, "streaming unsupported", http.StatusInternalServerError) + return + } + idParsed, err := strconv.Atoi(request.PathValue("id")) + if err != nil { + http.Error(response, err.Error(), http.StatusBadRequest) + return + } + idPathParam := idParsed + h := response.Header() + h.Set("Content-Type", "text/event-stream") + h.Set("Connection", "keep-alive") + h.Set("Cache-Control", "no-store") + response.WriteHeader(http.StatusOK) + flusher.Flush() + var mut sync.Mutex + receiver.Ticks(request, idPathParam, func() error { + if err := request.Context().Err(); err != nil { + return err + } + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + td := SSETemplateData[RoutesReceiver, struct{}]{receiver: receiver, request: request, pathsPrefix: pathsPrefix} + if err := templates.ExecuteTemplate(buf, "GET /ticks/{id} sse(Ticks(request, id, execute))", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + return err + } + td.data = buf + mut.Lock() + defer mut.Unlock() + if _, err := td.WriteTo(response); err != nil { + return err + } + flusher.Flush() + return nil + }) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type SSETemplateData[R, T any] struct { + receiver R + request *http.Request + result T + pathsPrefix string + event, id *string + retryMilliseconds *int + errList []error + data *bytes.Buffer +} + +func (m *SSETemplateData[R, T]) String() string { + return "" +} + +func (m *SSETemplateData[R, T]) Receiver() R { + return m.receiver +} + +func (m *SSETemplateData[R, T]) Request() *http.Request { + return m.request +} + +func (m *SSETemplateData[R, T]) Result() T { + return m.result +} + +func (m *SSETemplateData[R, T]) Err() error { + return errors.Join(m.errList...) +} + +func (m *SSETemplateData[R, T]) Event(event string) *SSETemplateData[R, T] { + m.event = &event + return m +} + +func (m *SSETemplateData[R, T]) ID(id string) *SSETemplateData[R, T] { + m.id = &id + return m +} + +func (m *SSETemplateData[R, T]) Retry(retryMilliseconds int) *SSETemplateData[R, T] { + m.retryMilliseconds = &retryMilliseconds + return m +} + +func (m *SSETemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: m.pathsPrefix} +} + +func (m *SSETemplateData[R, T]) WriteTo(w io.Writer) (int64, error) { + if m.id != nil && strings.ContainsAny(*m.id, "\r\n\x00") { + return 0, errors.New("sse: id contains a forbidden character") + } + if m.event != nil && strings.ContainsAny(*m.event, "\r\n") { + return 0, errors.New("sse: event contains a forbidden character") + } + var bytesWritten int + if m.id != nil { + if n, err := io.WriteString(w, "id: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.id); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.event != nil { + if n, err := io.WriteString(w, "event: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := io.WriteString(w, *m.event); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if m.retryMilliseconds != nil { + if n, err := io.WriteString(w, "retry: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + var retryBuf [20]byte + if n, err := w.Write(strconv.AppendInt(retryBuf[:0], int64(*m.retryMilliseconds), 10)); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + data := m.data.Bytes() + if bytes.IndexByte(data, '\r') >= 0 { + data = bytes.ReplaceAll(data, []byte("\r\n"), []byte("\n")) + data = bytes.ReplaceAll(data, []byte("\r"), []byte("\n")) + } + data = bytes.TrimSuffix(data, []byte{'\n'}) + for line := range bytes.SplitSeq(data, []byte{'\n'}) { + if n, err := io.WriteString(w, "data: "); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + if len(line) > 0 { + if n, err := w.Write(line); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + } + if n, err := w.Write([]byte{'\n'}); err != nil { + return int64(bytesWritten + n), err + } else { + bytesWritten += n + } + return int64(bytesWritten), nil +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Ticks(idPathParam int) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "ticks", strconv.Itoa(idPathParam)) +} diff --git a/internal/generate/testdata/generate/status_codes.txtar b/internal/generate/testdata/generate/status_codes.txtar new file mode 100644 index 00000000..1b62088e --- /dev/null +++ b/internal/generate/testdata/generate/status_codes.txtar @@ -0,0 +1,277 @@ +The status a route writes: one named in the template name, one the +result reports with a StatusCode method or field, or 200. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "POST /created 201 Create()"}}{{end}} +{{define "GET /constant http.StatusTeapot Create()"}}{{end}} +{{define "GET /method Method()"}}{{end}} +{{define "GET /field Field()"}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Create() string { return "" } + +type Coded struct{ code int } + +func (c Coded) StatusCode() int { return c.code } + +func (T) Method() Coded { return Coded{} } + +type WithField struct{ StatusCode int } + +func (T) Field() WithField { return WithField{} } +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "errors" + "fmt" + "log/slog" + "net/http" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Create() string + Field() WithField + Method() Coded +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /constant", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Create() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /constant http.StatusTeapot Create()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, http.StatusTeapot) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("POST /created", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Create() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "POST /created 201 Create()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, http.StatusCreated) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /field", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, WithField]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Field() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /field Field()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, td.result.StatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /method", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, Coded]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Method() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /method Method()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, td.result.StatusCode(), defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) ReadConstantCallingCreate() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "constant") +} + +func (routePaths TemplateRoutePaths) CreateCreatedCallingCreate() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "created") +} + +func (routePaths TemplateRoutePaths) Field() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "field") +} + +func (routePaths TemplateRoutePaths) Method() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "method") +} diff --git a/internal/generate/testdata/generate/synthesized_method_note.txtar b/internal/generate/testdata/generate/synthesized_method_note.txtar new file mode 100644 index 00000000..87573fe3 --- /dev/null +++ b/internal/generate/testdata/generate/synthesized_method_note.txtar @@ -0,0 +1,229 @@ +With --use-receiver-type, a method the receiver does not define is +inferred, and generation says so. + +Command line: muxt generate --use-receiver-type=T +-- config.json -- +{ + "MuxtVersion": "v1.2.3", + "PackageName": "main", + "PackagePath": "", + "RoutesFunction": "TemplateRoutes", + "ReceiverType": "T", + "ReceiverPackage": "", + "ReceiverInterface": "RoutesReceiver", + "TemplateDataType": "TemplateData", + "SSETemplateDataType": "SSETemplateData", + "TemplateRoutePathsTypeName": "TemplateRoutePaths", + "TemplatesVariables": [ + "templates" + ], + "OutputFileName": "template_routes.go", + "PathPrefix": false, + "Logger": false, + "Middleware": false, + "Verbose": false, + "OutputMultipleFiles": false, + "OutputHTMX": false, + "OutputDatastar": false, + "OutputExportedDefaultIdentifiers": true, + "OutputMuxtVersion": true, + "MultipartMaxMemory": 0, + "SilenceHTTPResponseWarning": false +} +-- index.gohtml -- +{{define "GET /{id} Missing(ctx, id)"}}{{.Result}}{{end}} +{{define "GET /defined Defined()"}}{{.Result}}{{end}} +-- server.go -- +package server + +type T struct{} + +func (T) Defined() string { return "" } +-- want/log.txt -- +note: T does not define Missing(ctx context.Context, id string) any +note: the inferred signatures return any — implement the methods to type-check the templates against real types +-- want/template_routes.go -- +package server + +import ( + "bytes" + "cmp" + "context" + "errors" + "fmt" + "log/slog" + "net/http" + "net/url" + "path" + "strconv" + "sync" +) + +type RoutesReceiver interface { + Defined() string + Missing(ctx context.Context, id string) any +} + +func TemplateRoutes(mux *http.ServeMux, receiver RoutesReceiver) TemplateRoutePaths { + pathsPrefix := "" + bytesBufferPool := sync.Pool{New: func() any { + return bytes.NewBuffer(nil) + }} + mux.HandleFunc("GET /defined", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, string]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Defined() + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /defined Defined()", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + mux.HandleFunc("GET /{id}", func(response http.ResponseWriter, request *http.Request) { + var td = TemplateData[RoutesReceiver, any]{receiver: receiver, response: response, request: request, pathsPrefix: pathsPrefix} + ctx := request.Context() + idPathParam := request.PathValue("id") + buf := bytesBufferPool.Get().(*bytes.Buffer) + buf.Reset() + defer bytesBufferPool.Put(buf) + if len(td.errList) == 0 { + td.result = receiver.Missing(ctx, idPathParam) + td.okay = true + } + if err := templates.ExecuteTemplate(buf, "GET /{id} Missing(ctx, id)", &td); err != nil { + slog.ErrorContext(request.Context(), "failed to render page", slog.String("path", request.URL.Path), slog.String("pattern", request.Pattern), slog.String("error", err.Error())) + http.Error(response, "failed to render page", http.StatusInternalServerError) + return + } + defaultStatusCode := http.StatusOK + if buf.Len() == 0 { + defaultStatusCode = http.StatusNoContent + } + statusCode := cmp.Or(td.statusCode, td.errStatusCode, defaultStatusCode) + if contentType := response.Header().Get("content-type"); contentType == "" { + response.Header().Set("content-type", "text/html; charset=utf-8") + } + response.Header().Set("content-length", strconv.Itoa(buf.Len())) + response.WriteHeader(statusCode) + _, _ = buf.WriteTo(response) + }) + return TemplateRoutePaths{pathsPrefix: pathsPrefix} +} + +type TemplateData[R any, T any] struct { + receiver R + response http.ResponseWriter + request *http.Request + result T + statusCode int + errStatusCode int + okay bool + errList []error + redirectURL string + pathsPrefix string +} + +func (data *TemplateData[R, T]) MuxtVersion() string { + const muxtVersion = "v1.2.3" + return muxtVersion +} + +func (data *TemplateData[R, T]) Path() TemplateRoutePaths { + return TemplateRoutePaths{pathsPrefix: data.pathsPrefix} +} + +func (data *TemplateData[R, T]) Result() T { + return data.result +} + +func (data *TemplateData[R, T]) Request() *http.Request { + return data.request +} + +func (data *TemplateData[R, T]) StatusCode(statusCode int) *TemplateData[R, T] { + data.statusCode = statusCode + return data +} + +func (data *TemplateData[R, T]) Header(key, value string) *TemplateData[R, T] { + data.response.Header().Set(key, value) + return data +} + +func (data *TemplateData[R, T]) Ok() bool { + return data.okay +} + +func (data *TemplateData[R, T]) Err() error { + return errors.Join(data.errList...) +} + +func (data *TemplateData[R, T]) Receiver() R { + return data.receiver +} + +func (data *TemplateData[R, T]) Redirect(url string, code int) (*TemplateData[R, T], error) { + if code < 300 || code >= 400 { + return data, fmt.Errorf("invalid status code %d for redirect", code) + } + data.redirectURL = url + return data.StatusCode(code), nil +} + +func (data *TemplateData[R, T]) RedirectMultipleChoices(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMultipleChoices) +} + +func (data *TemplateData[R, T]) RedirectMovedPermanently(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusMovedPermanently) +} + +func (data *TemplateData[R, T]) RedirectFound(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusFound) +} + +func (data *TemplateData[R, T]) RedirectSeeOther(url string) (*TemplateData[R, T], error) { + return data.Redirect(url, http.StatusSeeOther) +} + +func (data *TemplateData[R, T]) String() string { + return "" +} + +type TemplateRoutePaths struct { + pathsPrefix string +} + +func (routePaths TemplateRoutePaths) Defined() string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), "defined") +} + +func (routePaths TemplateRoutePaths) Missing(idPathParam string) string { + return path.Join(cmp.Or(routePaths.pathsPrefix, "/"), routePaths.escapePathSegment(idPathParam)) +} + +func (routePaths TemplateRoutePaths) escapePathSegment(value string) string { + switch value { + case ".": + return "%2E" + case "..": + return "%2E%2E" + } + return url.PathEscape(value) +} diff --git a/internal/load/hydrate_test.go b/internal/load/hydrate_test.go new file mode 100644 index 00000000..4040d758 --- /dev/null +++ b/internal/load/hydrate_test.go @@ -0,0 +1,80 @@ +package load_test + +import ( + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/typelate/muxt/internal/analysis" + "github.com/typelate/muxt/internal/generate" + "github.com/typelate/muxt/internal/load" + "github.com/typelate/muxt/internal/load/loadtest" +) + +const hydrateServer = `package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +type Server struct{} + +func Render(w io.Writer) error { + return templates.ExecuteTemplate(w, "page", nil) +} +` + +// TestHydration states what a command's configuration reads from a load, +// and which failure a configuration naming several missing things reports. +func TestHydration(t *testing.T) { + dir := t.TempDir() + pl := loadtest.Package(t, dir, "example.com/server", map[string]string{ + "server.go": hydrateServer, + "page.gohtml": `{{define "page"}}

{{.}}

{{end}}`, + }) + + t.Run("the package, its receiver, and each variable", func(t *testing.T) { + pkg, receiver, err := load.RoutesSource(dir, pl, analysis.DefinitionsConfiguration{ReceiverType: "Server", TemplatesVariables: []string{"templates"}}) + require.NoError(t, err) + assert.Equal(t, "Server", receiver.Obj().Name()) + require.Len(t, pkg.Variables, 1) + variable := pkg.Variables[0] + assert.Equal(t, "templates", variable.Name) + require.Len(t, variable.Calls, 1) + assert.Equal(t, "page", variable.Calls[0].Template) + assert.Equal(t, filepath.Join(dir, "server.go"), variable.Calls[0].Position.Filename) + _, ok := variable.NamePosition("page") + assert.True(t, ok, "the page's definition is located") + }) + + t.Run("no receiver named is none looked up", func(t *testing.T) { + _, receiver, err := load.RoutesSource(dir, pl, analysis.DefinitionsConfiguration{TemplatesVariables: []string{"templates"}}) + require.NoError(t, err) + assert.Nil(t, receiver) + }) + + t.Run("a missing receiver is reported before a missing variable", func(t *testing.T) { + _, _, err := load.RoutesSource(dir, pl, analysis.DefinitionsConfiguration{ReceiverType: "Srever", TemplatesVariables: []string{"nope"}}) + require.EqualError(t, err, "could not find receiver type Srever in example.com/server; did you mean Server?") + }) + + t.Run("the first variable that does not evaluate", func(t *testing.T) { + _, err := load.Package(dir, pl, []string{"templates", "nope", "neither"}) + require.EqualError(t, err, "variable nope not found in package example.com/server") + }) + + t.Run("the routes file belongs to the package in its own directory", func(t *testing.T) { + _, _, err := load.GenerateSource(dir, pl, generate.RoutesFileConfiguration{OutputFileName: filepath.Join("sub", "routes.go"), ReceiverType: "Srever"}) + require.Error(t, err) + assert.Equal(t, "no Go package found at "+filepath.Join(dir, "sub"), err.Error(), "a missing package is reported before a missing receiver") + }) +} diff --git a/internal/load/loadtest/loadtest.go b/internal/load/loadtest/loadtest.go new file mode 100644 index 00000000..64785057 --- /dev/null +++ b/internal/load/loadtest/loadtest.go @@ -0,0 +1,228 @@ +// Package loadtest builds the result of a package load, type checked against +// the official standard library, for tests of what internal/load and its +// callers do with a loaded package. +// +// packages.Load runs go list over the whole package graph, which takes +// seconds. What muxt reads from its result -- syntax, type information, the +// embedded files -- is plain data. Package type checks the files it is given +// in memory and imports the standard library from the go command's export +// data, which the build cache keeps: listing it costs a test binary a couple +// of hundred milliseconds once, and reading a package is shared after that. The +// standard library is whichever the go command in use provides, so the +// tests follow the toolchain rather than a copy of its API. +// +// Package writes the files to disk too, because evaluating ParseFS and +// reading template sources both read them from there. +package loadtest + +import ( + "fmt" + "go/ast" + "go/parser" + "go/token" + "go/types" + "os" + "os/exec" + "path" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + + "golang.org/x/tools/go/gcexportdata" + "golang.org/x/tools/go/packages" +) + +// FileSet positions every package Package loads, and the standard library +// packages imported for them. +var FileSet = token.NewFileSet() + +// The standard library is read from the export data the go command writes +// for it, with gcexportdata -- the reader go/packages uses -- into one map +// shared by every package this test binary loads, so a type a test names is +// the same object wherever it is reached. +// +// Export data lists only the packages a package's API mentions, while a +// load reports every package it imports; check finds fmt, whose functions a +// template calls, through html/template's imports. So each package's +// imports are set to the ones go list reports, as go/packages sets them. +var ( + importLock sync.Mutex + stdlib = make(map[string]*types.Package) + imported = make(map[string]bool) + + stdList = sync.OnceValues(func() (map[string]stdPackage, error) { + // Tab separated: an export path holds whatever the build cache's + // directory is called, spaces included. + out, err := exec.Command("go", "list", "-export", "-f", "{{.ImportPath}}\t{{.Export}}\t{{join .Imports \",\"}}", "std").Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("go list -export std: %w: %s", err, exitErr.Stderr) + } + return nil, fmt.Errorf("go list -export std: %w", err) + } + list := make(map[string]stdPackage) + for line := range strings.Lines(string(out)) { + fields := strings.Split(strings.TrimRight(line, "\n"), "\t") + // A package the go command builds nothing for -- the + // standard library's own test packages -- has no export + // data to read, so it is not one to import. + if len(fields) < 2 || fields[1] == "" { + continue + } + pkg := stdPackage{export: fields[1]} + if len(fields) > 2 && fields[2] != "" { + pkg.imports = strings.Split(fields[2], ",") + } + list[fields[0]] = pkg + } + return list, nil + }) +) + +type stdPackage struct { + export string + imports []string +} + +func importStd(path string) (*types.Package, error) { + importLock.Lock() + defer importLock.Unlock() + return importStdLocked(path) +} + +func importStdLocked(path string) (*types.Package, error) { + if path == "unsafe" { + return types.Unsafe, nil + } + if pkg, ok := stdlib[path]; ok && imported[path] { + return pkg, nil + } + list, err := stdList() + if err != nil { + return nil, err + } + entry, ok := list[path] + if !ok { + return nil, fmt.Errorf("loadtest imports only the standard library, and %q is not in it", path) + } + f, err := os.Open(entry.export) + if err != nil { + return nil, err + } + defer func() { _ = f.Close() }() + r, err := gcexportdata.NewReader(f) + if err != nil { + return nil, fmt.Errorf("reading export data for %s: %w", path, err) + } + pkg, err := gcexportdata.Read(r, FileSet, stdlib, path) + if err != nil { + return nil, fmt.Errorf("reading export data for %s: %w", path, err) + } + imported[path] = true + imports := make([]*types.Package, 0, len(entry.imports)) + for _, importPath := range entry.imports { + if _, listed := list[importPath]; !listed && importPath != "unsafe" { + continue + } + dep, err := importStdLocked(importPath) + if err != nil { + return nil, err + } + imports = append(imports, dep) + } + pkg.SetImports(imports) + return pkg, nil +} + +type importerFunc func(path string) (*types.Package, error) + +func (fn importerFunc) Import(path string) (*types.Package, error) { return fn(path) } + +// Package writes files into dir and returns them loaded as the package with +// import path pkgPath, the way load.Packages would report a load of dir: that +// package, then the standard library packages every load includes. +// +// Go files are type checked against the standard library. Every other file +// is embedded, as a //go:embed pattern covering it would. Keys are +// slash-separated paths relative to dir; files in subdirectories are written +// but not part of the package. +func Package(t testing.TB, dir, pkgPath string, files map[string]string) []*packages.Package { + t.Helper() + var goPaths, embedded []string + for name, content := range files { + file := filepath.Join(dir, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(file), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(file, []byte(content), 0o600); err != nil { + t.Fatal(err) + } + switch { + case path.Dir(name) != ".": + case strings.HasSuffix(name, ".go"): + goPaths = append(goPaths, file) + default: + embedded = append(embedded, file) + } + } + slices.Sort(goPaths) + slices.Sort(embedded) + + // load.Packages always loads these alongside the working directory's + // package, so what a route argument binds to is found even when the + // package does not import it. They are imported before the package is + // checked: a package the checker reaches only through another's imports + // is read shallowly, and it is these complete ones -- fmt, whose + // functions a template calls -- that the load's other packages share. + roots := []string{"encoding", "fmt", "net/http"} + std := make([]*types.Package, 0, len(roots)) + for _, stdPath := range roots { + pkg, err := importStd(stdPath) + if err != nil { + t.Fatal(err) + } + std = append(std, pkg) + } + + syntax := make([]*ast.File, 0, len(goPaths)) + for _, file := range goPaths { + parsed, err := parser.ParseFile(FileSet, file, files[filepath.Base(file)], parser.ParseComments|parser.SkipObjectResolution) + if err != nil { + t.Fatal(err) + } + syntax = append(syntax, parsed) + } + info := &types.Info{ + Types: make(map[ast.Expr]types.TypeAndValue), + Instances: make(map[*ast.Ident]types.Instance), + Defs: make(map[*ast.Ident]types.Object), + Uses: make(map[*ast.Ident]types.Object), + Implicits: make(map[ast.Node]types.Object), + Selections: make(map[*ast.SelectorExpr]*types.Selection), + Scopes: make(map[ast.Node]*types.Scope), + } + config := types.Config{Importer: importerFunc(importStd)} + pkg, err := config.Check(pkgPath, FileSet, syntax, info) + if err != nil { + t.Fatal(err) + } + + pl := []*packages.Package{{ + ID: pkgPath, + Name: pkg.Name(), + PkgPath: pkgPath, + Dir: dir, + GoFiles: goPaths, + EmbedFiles: embedded, + Fset: FileSet, + Syntax: syntax, + Types: pkg, + TypesInfo: info, + }} + for _, pkg := range std { + pl = append(pl, &packages.Package{ID: pkg.Path(), Name: pkg.Name(), PkgPath: pkg.Path(), Fset: FileSet, Types: pkg}) + } + return pl +} diff --git a/internal/load/loadtest/loadtest_test.go b/internal/load/loadtest/loadtest_test.go new file mode 100644 index 00000000..166119b3 --- /dev/null +++ b/internal/load/loadtest/loadtest_test.go @@ -0,0 +1,64 @@ +package loadtest_test + +import ( + "path/filepath" + "slices" + "testing" + + "github.com/typelate/muxt/internal/load" + "github.com/typelate/muxt/internal/load/loadtest" +) + +func TestPackageLoadsTemplates(t *testing.T) { + dir := t.TempDir() + pl := loadtest.Package(t, dir, "example.com/server", map[string]string{ + "server.go": `package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +var inline = template.Must(template.New("inline").Delims("[[", "]]").Parse(` + "`[[define \"note\"]][[.]][[end]]`" + `)) + +func Render(w io.Writer, name string) error { + return templates.ExecuteTemplate(w, "page", name) +} +`, + "page.gohtml": `{{define "page"}}

{{.}}

{{end}}`, + }) + + pkg, err := load.Package(dir, pl, []string{"templates", "inline"}) + if err != nil { + t.Fatal(err) + } + if pkg.Types.Path() != "example.com/server" { + t.Errorf("package %s, want example.com/server", pkg.Types.Path()) + } + sets := pkg.Variables + + if sets[0].Set.Lookup("page") == nil { + t.Error("templates does not hold the page ParseFS read") + } + definition, ok := sets[0].Definitions["page"] + if !ok || definition.Define.Filename != filepath.Join(dir, "page.gohtml") { + t.Errorf("page defined at %+v, want in page.gohtml", definition.Define) + } + if got := len(sets[0].Calls); got != 1 { + t.Errorf("%d ExecuteTemplate calls, want the one Render makes", got) + } + var names []string + for _, tmpl := range sets[1].Set.Templates() { + names = append(names, tmpl.Name()) + } + slices.Sort(names) + if !slices.Equal(names, []string{"inline", "note"}) { + t.Errorf("inline holds %q, want inline and note", names) + } +} diff --git a/internal/load/stdlib_test.go b/internal/load/stdlib_test.go new file mode 100644 index 00000000..3d29eb85 --- /dev/null +++ b/internal/load/stdlib_test.go @@ -0,0 +1,86 @@ +package load_test + +import ( + "go/types" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/typelate/muxt/internal/load" + "github.com/typelate/muxt/internal/load/loadtest" + "github.com/typelate/muxt/internal/muxt" +) + +// TestStandardLibrary states what the one checker backed by the official +// standard library answers, which the mock checkers in other packages' tests +// stand in for. +func TestStandardLibrary(t *testing.T) { + dir := t.TempDir() + pl := loadtest.Package(t, dir, "example.com/server", map[string]string{ + "server.go": `package server + +import ( + "html/template" + "time" +) + +type Plain struct{} + +var ( + _ time.Time + _ template.HTML +) +`, + }) + std := load.StandardLibrary(pl) + + t.Run("the reserved identifiers", func(t *testing.T) { + for identifier, want := range map[string]string{ + muxt.TemplateNameScopeIdentifierHTTPRequest: "*net/http.Request", + muxt.TemplateNameScopeIdentifierHTTPResponse: "net/http.ResponseWriter", + muxt.TemplateNameScopeIdentifierContext: "context.Context", + muxt.TemplateNameScopeIdentifierForm: "net/url.Values", + muxt.TemplateNameScopeIdentifierMultipart: "*mime/multipart.Form", + muxt.TemplateNameScopeIdentifierRequestBody: "io.Reader", + } { + tp, err := std.ScopeType(identifier) + require.NoError(t, err, identifier) + assert.Equal(t, want, types.TypeString(tp, nil), identifier) + } + _, err := std.ScopeType("lastEventID") + assert.EqualError(t, err, "lastEventID is not a reserved argument identifier") + }) + + t.Run("the types a binding needs", func(t *testing.T) { + fileHeader, err := std.FileHeader() + require.NoError(t, err) + assert.Equal(t, "*mime/multipart.FileHeader", types.TypeString(fileHeader, nil)) + rawJSON, err := std.RawJSON() + require.NoError(t, err) + assert.Equal(t, "encoding/json.RawMessage", types.TypeString(rawJSON, nil)) + }) + + t.Run("text marshaling", func(t *testing.T) { + var timeType types.Type + for _, imported := range pl[0].Types.Imports() { + if imported.Path() == "time" { + timeType = imported.Scope().Lookup("Time").Type() + } + } + require.NotNil(t, timeType) + plain := pl[0].Types.Scope().Lookup("Plain").Type() + assert.True(t, std.TextUnmarshaler(timeType), "a *time.Time parses from text") + assert.True(t, std.TextMarshaler(timeType), "a time.Time formats as text") + assert.False(t, std.TextUnmarshaler(plain)) + assert.False(t, std.TextMarshaler(plain)) + }) + + t.Run("a package the load did not reach", func(t *testing.T) { + // Only the package itself, which imports nothing: encoding/json is + // reached through html/template's imports in a real package. + bare := t.TempDir() + _, err := load.StandardLibrary(loadtest.Package(t, bare, "example.com/bare", map[string]string{"bare.go": "package bare\n"})[:1]).RawJSON() + assert.EqualError(t, err, `could not find package "encoding/json" for RawMessage`) + }) +} From cb52fbe4219e5a18fef556fb0b37849bfd0beaab Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:05:05 -0700 Subject: [PATCH 2/5] Snapshot the template checks and listings, from packages loaded in memory muxt check is the command the integration suite runs most, and its reports -- a field the data type lacks, a template nothing renders, a route waiting for muxt generate -- were only stated there, behind a package load per script. internal/analysis/testdata//*.txtar holds cases for check, the route listing, and the template caller and call listings. The directory an archive is in names the command it runs, so a case runs alone as -run TestSnapshots/list-template-calls/calls, and the archive holds the configuration it runs with, in its own config.json, beside the command line that parses into it. A listing's configuration needs nothing to read as JSON: regexp.Regexp writes itself as the text it was compiled from, and internal/configjson says the rest -- a field the command line left alone is null rather than an empty list, and a member a configuration does not declare is an error. TestSnapshots loads the case through loadtest and internal/load's hydration, as the commands do, and compares what the analysis reports. Assisted-by: Claude:claude-opus-5 gofumpt --- CLAUDE.md | 14 +- internal/analysis/configuration_test.go | 58 ++++ internal/analysis/snapshot_test.go | 262 ++++++++++++++++++ .../testdata/check/check_bad_route_name.txtar | 42 +++ .../testdata/check/check_passes.txtar | 35 +++ .../check/check_template_not_found.txtar | 39 +++ .../check/check_unused_templates.txtar | 44 +++ .../testdata/check/check_wrong_field.txtar | 53 ++++ .../testdata/list-routes/routes.txtar | 41 +++ .../list-template-callers/callers.txtar | 57 ++++ .../list-template-callers/callers_match.txtar | 44 +++ .../testdata/list-template-calls/calls.txtar | 47 ++++ 12 files changed, 730 insertions(+), 6 deletions(-) create mode 100644 internal/analysis/configuration_test.go create mode 100644 internal/analysis/snapshot_test.go create mode 100644 internal/analysis/testdata/check/check_bad_route_name.txtar create mode 100644 internal/analysis/testdata/check/check_passes.txtar create mode 100644 internal/analysis/testdata/check/check_template_not_found.txtar create mode 100644 internal/analysis/testdata/check/check_unused_templates.txtar create mode 100644 internal/analysis/testdata/check/check_wrong_field.txtar create mode 100644 internal/analysis/testdata/list-routes/routes.txtar create mode 100644 internal/analysis/testdata/list-template-callers/callers.txtar create mode 100644 internal/analysis/testdata/list-template-callers/callers_match.txtar create mode 100644 internal/analysis/testdata/list-template-calls/calls.txtar diff --git a/CLAUDE.md b/CLAUDE.md index bdf3060f..2d8c5be5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -111,12 +111,14 @@ 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. - **What a command does with a valid configuration:** - `internal/generate/testdata/generate/*.txtar` snapshot generated files from - packages loaded in memory in milliseconds. The directory names the command; - each archive's `config.json` is the configuration the command line in its - header parses into. Run one with - `go test ./internal/generate -run TestSnapshots/generate/sse`, and rewrite - them with `go test ./internal/generate -run TestSnapshots -update`, then + `internal/{generate,analysis}/testdata//*.txtar` snapshot generated + files, check reports and the route and template listings, from packages + loaded in memory in milliseconds. The directory names the command; each + archive's `config.json` is the configuration the command line in its header + parses into. Run one with + `go test ./internal/analysis -run TestSnapshots/list-template-calls/calls`, + and rewrite them with + `go test ./internal/{generate,analysis} -run TestSnapshots -update`, then review the diff. Integration scripts are for what needs the go command: generated code diff --git a/internal/analysis/configuration_test.go b/internal/analysis/configuration_test.go new file mode 100644 index 00000000..e7148ae8 --- /dev/null +++ b/internal/analysis/configuration_test.go @@ -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") +} diff --git a/internal/analysis/snapshot_test.go b/internal/analysis/snapshot_test.go new file mode 100644 index 00000000..5559a701 --- /dev/null +++ b/internal/analysis/snapshot_test.go @@ -0,0 +1,262 @@ +package analysis_test + +import ( + "bytes" + "encoding/json/v2" + "errors" + "flag" + "fmt" + "io" + "log" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "testing" + + "golang.org/x/tools/txtar" + + "github.com/typelate/muxt/internal/analysis" + "github.com/typelate/muxt/internal/configjson" + "github.com/typelate/muxt/internal/load" + "github.com/typelate/muxt/internal/load/loadtest" + "github.com/typelate/muxt/internal/muxt" +) + +var update = flag.Bool("update", false, "rewrite the want/ files of the snapshot archives in testdata") + +// templatesGo declares the templates variable for an archive that does not +// declare its own: every template file, parsed as ParseFS would. +const templatesGo = `package server + +import ( + "embed" + "html/template" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) +` + +// TestSnapshots runs an analysis for each archive in testdata and +// compares what it reports with the archive's want/ files. +// +// An archive's directory names the command it runs -- check, list-routes, +// list-template-callers or list-template-calls -- so one case runs with +// -run TestSnapshots/list-template-calls/calls. The archive holds the rest +// of the case: the configuration it runs with, its inputs, and what it +// reports. +// +// - config.json is the configuration, as the command line named in the +// archive's header parses into it. TestCommandLineConfigurations in +// internal/cli states that parse; this states what the command does +// with the result. +// - Go files and .gohtml files are loaded as example.com/server by +// internal/load/loadtest -- type checked against the official standard +// library, without loading the package graph -- and hydrated by +// internal/load, as the command does. An archive with no templates.go +// gets one declaring the templates variable over every .gohtml file. +// - want/ files are what was reported: want/stdout.txt for what a listing +// writes, want/checked.txt for the number of ExecuteTemplate calls +// check returns, want/log.txt for what check logged, and want/error.txt +// for the error returned. +// +// Paths in the output are relative to the directory the package was +// written to. Run with -update to rewrite the want/ files, then read the +// diff. +func TestSnapshots(t *testing.T) { + if stray, _ := filepath.Glob(filepath.Join("testdata", "*.txtar")); len(stray) > 0 { + t.Fatalf("%s is not in a command's directory", stray[0]) + } + directories, err := os.ReadDir("testdata") + if err != nil { + t.Fatal(err) + } + for _, directory := range directories { + if !directory.IsDir() { + continue + } + command := directory.Name() + newConfiguration, ok := commands[command] + if !ok { + t.Errorf("testdata/%s names no command this package snapshots", command) + continue + } + archives, err := filepath.Glob(filepath.Join("testdata", command, "*.txtar")) + if err != nil { + t.Fatal(err) + } + t.Run(command, func(t *testing.T) { + for _, archivePath := range archives { + runSnapshot(t, archivePath, newConfiguration) + } + }) + } +} + +// commands are the directories in testdata, each named for the command its +// archives run, with the configuration that command's config.json holds. +var commands = map[string]func() any{ + "check": func() any { return new(analysis.CheckConfiguration) }, + "list-routes": func() any { return new(analysis.DefinitionsConfiguration) }, + "list-template-callers": func() any { return new(analysis.TemplateCallersConfiguration) }, + "list-template-calls": func() any { return new(analysis.TemplateCallsConfiguration) }, +} + +// runSnapshot compares one archive's want/ files with what its command +// reports, or with -update rewrites them. +func runSnapshot(t *testing.T, archivePath string, newConfiguration func() any) { + t.Helper() + t.Run(strings.TrimSuffix(filepath.Base(archivePath), ".txtar"), func(t *testing.T) { + archive, err := txtar.ParseFile(archivePath) + if err != nil { + t.Fatal(err) + } + got := snapshot(t, configuration(t, archive, newConfiguration()), archive) + if *update { + files := slices.DeleteFunc(slices.Clone(archive.Files), func(file txtar.File) bool { + return strings.HasPrefix(file.Name, "want/") + }) + for _, name := range sortedKeys(got) { + files = append(files, txtar.File{Name: "want/" + name, Data: []byte(got[name])}) + } + archive.Files = files + if err := os.WriteFile(archivePath, txtar.Format(archive), 0o644); err != nil { + t.Fatal(err) + } + return + } + want := make(map[string]string) + for _, file := range archive.Files { + if name, ok := strings.CutPrefix(file.Name, "want/"); ok { + want[name] = string(file.Data) + } + } + for _, name := range sortedKeys(got, want) { + if got[name] != want[name] { + t.Errorf("want/%s differs (run go test -run TestSnapshots -update to rewrite):\n--- got\n%s\n--- want\n%s", name, got[name], want[name]) + } + } + }) +} + +// configuration reads the archive's config.json into config, a pointer to +// the configuration its directory's command holds, and returns the +// configuration by value, as a command holds it. +func configuration(t *testing.T, archive *txtar.Archive, config any) any { + t.Helper() + for _, file := range archive.Files { + if file.Name != "config.json" { + continue + } + if err := json.Unmarshal(file.Data, config, configjson.Options()); err != nil { + t.Fatalf("config.json: %v", err) + } + return reflect.ValueOf(config).Elem().Interface() + } + t.Fatal("the archive has no config.json") + return nil +} + +func snapshot(t *testing.T, config any, archive *txtar.Archive) map[string]string { + t.Helper() + files := make(map[string]string) + for _, file := range archive.Files { + if strings.HasPrefix(file.Name, "want/") || file.Name == "config.json" { + continue + } + files[file.Name] = string(file.Data) + } + if _, declared := files["templates.go"]; !declared { + files["templates.go"] = templatesGo + } + dir := t.TempDir() + pl := loadtest.Package(t, dir, "example.com/server", files) + relative := func(text string) string { return strings.ReplaceAll(text, dir+string(filepath.Separator), "") } + + got := make(map[string]string) + var stdout bytes.Buffer + var runErr error + switch config := config.(type) { + case analysis.CheckConfiguration: + pkg, err := load.Package(dir, pl, config.TemplatesVariables) + if err != nil { + runErr = err + break + } + var logs strings.Builder + var n int + n, runErr = analysis.Check(config, log.New(&logs, "", 0), pkg) + got["checked.txt"] = fmt.Sprintf("%d\n", n) + if logs.Len() > 0 { + got["log.txt"] = relative(logs.String()) + } + case analysis.DefinitionsConfiguration: + pkg, receiver, err := load.RoutesSource(dir, pl, config) + if err != nil { + runErr = err + break + } + var results []*analysis.Routes + results, runErr = analysis.NewRoutes(pkg, receiver) + for _, result := range results { + writeTo(t, &stdout, result) + } + case analysis.TemplateCallersConfiguration: + pkg, err := load.Package(dir, pl, config.TemplatesVariables) + if err != nil { + runErr = err + break + } + var result *analysis.TemplateCallers + if result, runErr = analysis.NewTemplateCallers(config, pkg); result != nil { + writeTo(t, &stdout, result) + } + case analysis.TemplateCallsConfiguration: + pkg, err := load.Package(dir, pl, config.TemplatesVariables) + if err != nil { + runErr = err + break + } + var result *analysis.TemplateCalls + if result, runErr = analysis.NewTemplateCalls(config, pkg); result != nil { + writeTo(t, &stdout, result) + } + default: + t.Fatalf("no analysis runs with a %T", config) + } + if stdout.Len() > 0 { + got["stdout.txt"] = relative(stdout.String()) + } + if runErr != nil { + text := runErr.Error() + if multiLine, ok := errors.AsType[muxt.MultiLineError](runErr); ok { + text = multiLine.MultiLineError() + } + got["error.txt"] = relative(text) + "\n" + } + return got +} + +func writeTo(t *testing.T, w io.Writer, result io.WriterTo) { + t.Helper() + if _, err := result.WriteTo(w); err != nil { + t.Fatal(err) + } +} + +func sortedKeys(maps ...map[string]string) []string { + var keys []string + for _, m := range maps { + for key := range m { + if !slices.Contains(keys, key) { + keys = append(keys, key) + } + } + } + slices.Sort(keys) + return keys +} diff --git a/internal/analysis/testdata/check/check_bad_route_name.txtar b/internal/analysis/testdata/check/check_bad_route_name.txtar new file mode 100644 index 00000000..23a1c30b --- /dev/null +++ b/internal/analysis/testdata/check/check_bad_route_name.txtar @@ -0,0 +1,42 @@ +A malformed route template name is reported with its position. + +Command line: muxt check -v +-- config.json -- +{ + "Verbose": true, + "TemplatesVariables": [ + "templates" + ] +} +-- index.gohtml -- +{{define "index"}}

{{.Title}}

{{end}} +{{define "TRACE /debug"}}{{end}} +-- server.go -- +package server + +import "io" + +type Page struct { + Title string + Items []Item +} + +type Item struct { + Name string + Price int +} + +func RenderIndex(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "index", page) +} + +-- want/checked.txt -- +1 +-- want/error.txt -- +1 error +-- want/log.txt -- + TRACE /debug + ^^^^^ +index.gohtml:2:11: TRACE method not allowed; allowed methods: GET, POST, PUT, PATCH, and DELETE + +checking endpoint index diff --git a/internal/analysis/testdata/check/check_passes.txtar b/internal/analysis/testdata/check/check_passes.txtar new file mode 100644 index 00000000..d79a4d88 --- /dev/null +++ b/internal/analysis/testdata/check/check_passes.txtar @@ -0,0 +1,35 @@ +Each ExecuteTemplate call type checks the template it names, and the +templates it reaches through {{template}}. + +Command line: muxt check +-- config.json -- +{ + "Verbose": false, + "TemplatesVariables": [ + "templates" + ] +} +-- index.gohtml -- +{{define "index"}}

{{.Title}}

{{range .Items}}{{template "item" .}}{{end}}{{end}} +{{define "item"}}
  • {{.Name}}: {{printf "%d" .Price}}
  • {{end}} +-- server.go -- +package server + +import "io" + +type Page struct { + Title string + Items []Item +} + +type Item struct { + Name string + Price int +} + +func RenderIndex(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "index", page) +} + +-- want/checked.txt -- +1 diff --git a/internal/analysis/testdata/check/check_template_not_found.txtar b/internal/analysis/testdata/check/check_template_not_found.txtar new file mode 100644 index 00000000..fff00550 --- /dev/null +++ b/internal/analysis/testdata/check/check_template_not_found.txtar @@ -0,0 +1,39 @@ +An ExecuteTemplate call naming a template the set does not define. + +Command line: muxt check +-- config.json -- +{ + "Verbose": false, + "TemplatesVariables": [ + "templates" + ] +} +-- index.gohtml -- +{{define "main"}}{{end}} +-- server.go -- +package server + +import "io" + +type Page struct { + Title string + Items []Item +} + +type Item struct { + Name string + Price int +} + +func RenderIndex(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "index", page) +} + +-- want/checked.txt -- +1 +-- want/error.txt -- +1 error +-- want/log.txt -- +server.go:16:9 ExecuteTemplate "index" Page + - template "index" not found + diff --git a/internal/analysis/testdata/check/check_unused_templates.txtar b/internal/analysis/testdata/check/check_unused_templates.txtar new file mode 100644 index 00000000..db448aff --- /dev/null +++ b/internal/analysis/testdata/check/check_unused_templates.txtar @@ -0,0 +1,44 @@ +A template nothing executes is reported; a route template is reported +as waiting for muxt generate, and a partial only it uses is not reported. + +Command line: muxt check +-- config.json -- +{ + "Verbose": false, + "TemplatesVariables": [ + "templates" + ] +} +-- index.gohtml -- +{{define "index"}}

    {{.Title}}

    {{end}} +{{define "orphan"}}

    never rendered

    {{end}} +{{define "GET /items Items()"}}{{template "row" .}}{{end}} +{{define "row"}}{{end}} +-- server.go -- +package server + +import "io" + +type Page struct { + Title string + Items []Item +} + +type Item struct { + Name string + Price int +} + +func RenderIndex(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "index", page) +} + +-- want/checked.txt -- +1 +-- want/error.txt -- +2 errors +-- want/log.txt -- +Route templates with no generated handler; run muxt generate to wire them up: + - index.gohtml:3:32: "GET /items Items()" +Unused templates: + - index.gohtml:2:20: "orphan" diff --git a/internal/analysis/testdata/check/check_wrong_field.txtar b/internal/analysis/testdata/check/check_wrong_field.txtar new file mode 100644 index 00000000..5a3e3205 --- /dev/null +++ b/internal/analysis/testdata/check/check_wrong_field.txtar @@ -0,0 +1,53 @@ +A field the data type does not have is reported at the call and in the +template. + +Command line: muxt check +-- config.json -- +{ + "Verbose": false, + "TemplatesVariables": [ + "templates" + ] +} +-- index.gohtml -- +{{define "index"}}

    {{.Heading}}

    {{range .Items}}{{template "item" .}}{{end}}{{end}} +{{define "item"}}
  • {{.Cost}}
  • {{end}} +-- server.go -- +package server + +import "io" + +type Page struct { + Title string + Items []Item +} + +type Item struct { + Name string + Price int +} + +func RenderIndex(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "index", page) +} + +-- want/checked.txt -- +1 +-- want/error.txt -- +1 error +-- want/log.txt -- +server.go:16:9 ExecuteTemplate "index" Page +index.gohtml:1:25: executing "index" at <.Heading>: field or method Heading not found on Page + + type Page struct { + Title string + Items []Item + } + +index.gohtml:2:24: executing "item" at <.Cost>: field or method Cost not found on Item + + type Item struct { + Name string + Price int + } + diff --git a/internal/analysis/testdata/list-routes/routes.txtar b/internal/analysis/testdata/list-routes/routes.txtar new file mode 100644 index 00000000..b9886ce5 --- /dev/null +++ b/internal/analysis/testdata/list-routes/routes.txtar @@ -0,0 +1,41 @@ +The route listing shows each route template's name, and the receiver's +methods. + +Command line: muxt --use-receiver-type=T +-- config.json -- +{ + "Verbose": false, + "ReceiverPackage": "", + "ReceiverType": "T", + "TemplatesVariables": [ + "templates" + ] +} +-- index.gohtml -- +{{define "GET /{$} Home()"}}

    {{.Result}}

    {{end}} +{{define "POST /save Save(form)"}}{{.Err}}{{end}} +{{define "partial"}}not a route{{end}} +-- server.go -- +package server + +import "net/url" + +type T struct{} + +func (T) Home() string { return "" } +func (*T) Save(form url.Values) error { return nil } +-- want/stdout.txt -- + +Receiver Type: example.com/server.T + +Receiver Methods: + - func (example.com/server.T) Home() string + - func (example.com/server.T) Save(form net/url.Values) error + + +Template Routes: + - POST /save Save(form) + - GET /{$} Home() + +Template Functions: + diff --git a/internal/analysis/testdata/list-template-callers/callers.txtar b/internal/analysis/testdata/list-template-callers/callers.txtar new file mode 100644 index 00000000..4119f6b3 --- /dev/null +++ b/internal/analysis/testdata/list-template-callers/callers.txtar @@ -0,0 +1,57 @@ +Where each template is executed or called from, with the data type. + +Command line: muxt list-template-callers +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "FilterTemplates": null +} +-- index.gohtml -- +{{define "page"}}{{template "header" .Title}}{{range .Items}}{{template "item" .}}{{end}}{{end}} +{{define "header"}}

    {{.}}

    {{end}} +{{define "item"}}
  • {{.Name}}
  • {{end}} +{{define "alone"}}{{template "header" "Alone"}}{{end}} +-- server.go -- +package server + +import "io" + +type Page struct { + Title string + Items []Item +} + +type Item struct{ Name string } + +func Render(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} + +func RenderAlone(w io.Writer) error { + return templates.ExecuteTemplate(w, "alone", nil) +} +-- want/stdout.txt -- + +template "alone" called by: + + - server.go:17:9 execute_template "alone" untyped nil + + +template "header" called by: + + - index.gohtml:4:30 template "alone" string + - index.gohtml:1:29 template "page" string + + +template "item" called by: + + - index.gohtml:1:73 template "page" Item + + +template "page" called by: + + - server.go:13:9 execute_template "page" Page + + diff --git a/internal/analysis/testdata/list-template-callers/callers_match.txtar b/internal/analysis/testdata/list-template-callers/callers_match.txtar new file mode 100644 index 00000000..e5a33ae8 --- /dev/null +++ b/internal/analysis/testdata/list-template-callers/callers_match.txtar @@ -0,0 +1,44 @@ +Where the matching templates are executed or called from. + +Command line: muxt list-template-callers --match=^head +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "FilterTemplates": [ + "^head" + ] +} +-- index.gohtml -- +{{define "page"}}{{template "header" .Title}}{{range .Items}}{{template "item" .}}{{end}}{{end}} +{{define "header"}}

    {{.}}

    {{end}} +{{define "item"}}
  • {{.Name}}
  • {{end}} +{{define "alone"}}{{template "header" "Alone"}}{{end}} +-- server.go -- +package server + +import "io" + +type Page struct { + Title string + Items []Item +} + +type Item struct{ Name string } + +func Render(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} + +func RenderAlone(w io.Writer) error { + return templates.ExecuteTemplate(w, "alone", nil) +} +-- want/stdout.txt -- + +template "header" called by: + + - index.gohtml:4:30 template "alone" string + - index.gohtml:1:29 template "page" string + + diff --git a/internal/analysis/testdata/list-template-calls/calls.txtar b/internal/analysis/testdata/list-template-calls/calls.txtar new file mode 100644 index 00000000..8ae72ea0 --- /dev/null +++ b/internal/analysis/testdata/list-template-calls/calls.txtar @@ -0,0 +1,47 @@ +The templates each template calls, with the data type. + +Command line: muxt list-template-calls +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "FilterTemplates": null +} +-- index.gohtml -- +{{define "page"}}{{template "header" .Title}}{{range .Items}}{{template "item" .}}{{end}}{{end}} +{{define "header"}}

    {{.}}

    {{end}} +{{define "item"}}
  • {{.Name}}
  • {{end}} +{{define "alone"}}{{template "header" "Alone"}}{{end}} +-- server.go -- +package server + +import "io" + +type Page struct { + Title string + Items []Item +} + +type Item struct{ Name string } + +func Render(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} + +func RenderAlone(w io.Writer) error { + return templates.ExecuteTemplate(w, "alone", nil) +} +-- want/stdout.txt -- + +template "alone" calls: + + - index.gohtml:4:30 template "header" string + + +template "page" calls: + + - index.gohtml:1:29 template "header" string + - index.gohtml:1:73 template "item" Item + + From bffa23a66e64caa85b610aea1f228d54153ed029 Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:06:59 -0700 Subject: [PATCH 3/5] Plan mutations from a source.Package, and snapshot dry runs The mutation run's planning -- which templates each ExecuteTemplate call reaches, with what dot, and which variations apply -- read the loaded templates, the checker built on them, and the syntax trees it searched for the string literal a template was written in. So stating any of it took a module on disk and the go command. Planning now runs on an input: the source.Package internal/load reads. loadInput builds one with the go command; planFrom and revisionOf read nothing else. A template's string literal is found by parsing its file's text, which planning already holds, instead of searching the loader's syntax trees. With that, nothing reads load.Templates, and it goes. internal/mutation/testdata/test-template-mutations/*.txtar snapshots a dry run's report for ten cases -- a literal template, partials and trims, a template pattern, skipped mutants, the operand budget, other delimiters, a --diff revision, and the errors a plan returns -- each loaded through loadtest and planned in milliseconds. An archive holds the configuration it plans with, in its own config.json: regexp.Regexp writes itself as the text it was compiled from, so a pattern reads as the command line wrote it, and a pattern the command line left alone is null -- "" would be a pattern matching everything. As in the other two suites, the directory names the command, so a case runs alone as -run TestSnapshots/test-template-mutations/diff. CLAUDE.md's recipes for a feature, a bug and an error now start from the unit test at the layer that owns the behavior, and say what the in-memory loader cannot stand in for. Assisted-by: Claude:claude-opus-5 gofumpt --- CLAUDE.md | 79 +++++--- internal/load/package.go | 25 --- internal/load/templates_test.go | 9 +- internal/mutation/collect.go | 41 ++-- internal/mutation/configuration_test.go | 53 +++++ internal/mutation/delimiters_test.go | 28 +-- internal/mutation/diff.go | 19 +- internal/mutation/input.go | 73 +++++++ internal/mutation/plan.go | 96 +++++---- internal/mutation/snapshot_test.go | 191 ++++++++++++++++++ internal/mutation/source.go | 76 +++---- .../test-template-mutations/delimiters.txtar | 43 ++++ .../test-template-mutations/diff.txtar | 85 ++++++++ .../err_missing_variable.txtar | 42 ++++ .../err_no_call_sites.txtar | 42 ++++ .../err_no_mutations.txtar | 43 ++++ .../literal_template.txtar | 51 +++++ .../operand_budget.txtar | 53 +++++ .../partials_and_trims.txtar | 77 +++++++ .../skipped_mutants.txtar | 65 ++++++ .../template_pattern.txtar | 53 +++++ internal/mutation/traverse.go | 29 ++- internal/source/source.go | 8 +- 23 files changed, 1086 insertions(+), 195 deletions(-) create mode 100644 internal/mutation/configuration_test.go create mode 100644 internal/mutation/input.go create mode 100644 internal/mutation/snapshot_test.go create mode 100644 internal/mutation/testdata/test-template-mutations/delimiters.txtar create mode 100644 internal/mutation/testdata/test-template-mutations/diff.txtar create mode 100644 internal/mutation/testdata/test-template-mutations/err_missing_variable.txtar create mode 100644 internal/mutation/testdata/test-template-mutations/err_no_call_sites.txtar create mode 100644 internal/mutation/testdata/test-template-mutations/err_no_mutations.txtar create mode 100644 internal/mutation/testdata/test-template-mutations/literal_template.txtar create mode 100644 internal/mutation/testdata/test-template-mutations/operand_budget.txtar create mode 100644 internal/mutation/testdata/test-template-mutations/partials_and_trims.txtar create mode 100644 internal/mutation/testdata/test-template-mutations/skipped_mutants.txtar create mode 100644 internal/mutation/testdata/test-template-mutations/template_pattern.txtar diff --git a/CLAUDE.md b/CLAUDE.md index 2d8c5be5..7d687533 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,15 +25,16 @@ 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 @@ -47,6 +48,9 @@ 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 @@ -110,16 +114,26 @@ 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}/testdata//*.txtar` snapshot generated - files, check reports and the route and template listings, from packages - loaded in memory in milliseconds. The directory names the command; each - archive's `config.json` is the configuration the command line in its header - parses into. Run one with + `internal/{generate,analysis,mutation}/testdata//*.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} -run TestSnapshots -update`, then - review the diff. + `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. @@ -130,35 +144,47 @@ compiling and serving requests, and files on disk. # 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 @@ -263,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 diff --git a/internal/load/package.go b/internal/load/package.go index c3ad1684..8d9ecd05 100644 --- a/internal/load/package.go +++ b/internal/load/package.go @@ -122,31 +122,6 @@ func PackageInDirectory(list []*packages.Package, dir string) (*packages.Package return nil, false } -// LoadedTemplates bundles a package's loaded template variable with the -// analysis wiring built from it. -type LoadedTemplates struct { - Package *packages.Package - Templates *check.Templates - Global *check.Global - HTML *template.Template -} - -func Templates(wd, templatesVariable string, pl []*packages.Package) (*LoadedTemplates, error) { - pkg, ok := PackageInDirectory(pl, wd) - if !ok { - return nil, NoPackageError(wd, pl) - } - - lt, ts, err := HTMLTemplates(templatesVariable, pkg) - if err != nil { - return nil, err - } - - global := check.NewGlobal(pkg.Types, pkg.Fset, lt, lt.Functions()) - global.Definitions = lt - return &LoadedTemplates{Package: pkg, Templates: lt, Global: global, HTML: ts}, nil -} - // HTMLTemplates evaluates the package-level template variable through // check.LoadTemplates and returns the loaded handle alongside the // html/template value; muxt introspects template names and trees without diff --git a/internal/load/templates_test.go b/internal/load/templates_test.go index 1c7d345e..b5bae801 100644 --- a/internal/load/templates_test.go +++ b/internal/load/templates_test.go @@ -140,9 +140,8 @@ func TestPackageInADirectoryNamedLikeAGoFile(t *testing.T) { require.NoError(t, err) assert.Equal(t, "scratch", pkg.Types.Path()) - // The mutation run still reads Templates, and it looks the package up - // the same way. - lt, err := load.Templates(dir, "templates", pl) - require.NoError(t, err) - require.NotNil(t, lt.HTML.Lookup("home")) + // Every command reads the package through Package now, the mutation + // run included. + require.Len(t, pkg.Variables, 1) + require.NotNil(t, pkg.Variables[0].Set.Lookup("home")) } diff --git a/internal/mutation/collect.go b/internal/mutation/collect.go index 4a9ad14a..c4a67084 100644 --- a/internal/mutation/collect.go +++ b/internal/mutation/collect.go @@ -6,8 +6,7 @@ import ( "path/filepath" "slices" - "github.com/typelate/check" - "golang.org/x/tools/go/packages" + "github.com/typelate/muxt/internal/source" ) // sourceKey identifies the text a template was written in: a template @@ -21,11 +20,14 @@ type sourceKey struct { // distinct texts holding them, reading each file once. type sourceCollector struct { workingDirectory string - packages []*packages.Package files map[string]string byKey map[sourceKey]*templateSource keys []sourceKey + // literals are where the string literals of each Go file sit, read + // once per file: a run asks for every definition written in it. + literals map[string][]literalSpan + // delims are the delimiters each source was parsed with, read off the // definitions before any source is built. A source scans its actions // as it is constructed, so the delimiters have to be known by then, @@ -47,7 +49,7 @@ type sourceCollector struct { // Within one source the pair is fixed, so the first definition that // reveals it answers for the whole source. A source whose only template // has no define clause reveals nothing and keeps the defaults. -func (c *sourceCollector) resolveDelimiters(defs []check.Definition) { +func (c *sourceCollector) resolveDelimiters(defs []source.Definition) { for _, definition := range defs { file := definition.Define.Position.Filename if file == "" { @@ -74,23 +76,38 @@ func (c *sourceCollector) resolveDelimiters(defs []check.Definition) { // // It is the same key add files the definition under, so the delimiters // resolved here reach the source they were read from. -func (c *sourceCollector) keyFor(definition check.Definition) (sourceKey, bool) { +func (c *sourceCollector) keyFor(definition source.Definition) (sourceKey, bool) { file := definition.Define.Position.Filename if filepath.Ext(file) != ".go" { return sourceKey{file: file}, true } - litStart, _, ok := findStringLiteral(c.packages, file, definition.Define.Offset) + text, err := c.read(file) + if err != nil { + return sourceKey{}, false + } + litStart, _, ok := c.literalAt(file, text, definition.Define.Offset) if !ok { return sourceKey{}, false } return sourceKey{file: file, litStart: litStart}, true } -func newSourceCollector(workingDirectory string, pl []*packages.Package, defs []check.Definition) *sourceCollector { +// literalAt returns the bounds of the Go string literal covering offset in +// file, reading the file's literals the first time it is asked about it. +func (c *sourceCollector) literalAt(file, text string, offset int) (start, end int, ok bool) { + spans, read := c.literals[file] + if !read { + spans = stringLiterals(file, text) + c.literals[file] = spans + } + return literalAt(spans, offset) +} + +func newSourceCollector(workingDirectory string, defs []source.Definition) *sourceCollector { c := &sourceCollector{ workingDirectory: workingDirectory, - packages: pl, files: make(map[string]string), + literals: make(map[string][]literalSpan), byKey: make(map[sourceKey]*templateSource), delims: make(map[sourceKey][2]string), } @@ -108,10 +125,10 @@ func (c *sourceCollector) delimitersFor(key sourceKey) (string, string) { // add files one definition under the text it was written in, reading // that text at most once, and reports the source it was filed under. // -// A definition the collector cannot place -- a Go string literal it has -// no package for -- is reported as no source rather than as an error, +// A definition the collector cannot place -- an offset in a Go file that +// is not inside a string literal -- is reported as no source rather than as an error, // since the caller may hold others it can still use. -func (c *sourceCollector) add(definition check.Definition) (*templateSource, error) { +func (c *sourceCollector) add(definition source.Definition) (*templateSource, error) { file := definition.Define.Position.Filename if file == "" { return nil, nil @@ -131,7 +148,7 @@ func (c *sourceCollector) add(definition check.Definition) (*templateSource, err return nil, err } } else { - litStart, litEnd, ok := findStringLiteral(c.packages, file, definition.Define.Offset) + litStart, litEnd, ok := c.literalAt(file, fileText, definition.Define.Offset) if !ok { return nil, nil } diff --git a/internal/mutation/configuration_test.go b/internal/mutation/configuration_test.go new file mode 100644 index 00000000..eb42fe5f --- /dev/null +++ b/internal/mutation/configuration_test.go @@ -0,0 +1,53 @@ +package mutation_test + +import ( + "encoding/json/v2" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/typelate/muxt/internal/configjson" + "github.com/typelate/muxt/internal/mutation" +) + +// TestConfigurationJSON states how a run's configuration reads and writes +// as JSON, which is how an archive in testdata holds the one it plans with: +// a pattern is the text the command line wrote, and a flag the command line +// left alone is null. A pattern read as "" would be a pattern matching +// everything, which is not what leaving --run alone means, so the archives +// hold null. +func TestConfigurationJSON(t *testing.T) { + for _, tt := range []struct { + name string + config mutation.Configuration + }{ + { + name: "no patterns", + config: mutation.Configuration{TemplatesVariables: []string{"templates"}, Packages: []string{}, DryRun: true, Seed: 1, SeedSet: true, MaxCases: mutation.DefaultMaxCases, Workers: 1}, + }, + { + name: "a pattern is the text it was written as", + config: mutation.Configuration{TemplatesVariables: []string{"templates"}, TemplatePattern: regexp.MustCompile("^footer$"), Run: regexp.MustCompile("TestPage"), Packages: []string{"./..."}, MaxCases: 2, Workers: 4, Diff: "main", GoTestArgs: []string{"-count=1"}}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + written, err := json.Marshal(tt.config, configjson.Options()) + require.NoError(t, err) + + var read mutation.Configuration + require.NoError(t, json.Unmarshal(written, &read, configjson.Options())) + assert.Equal(t, tt.config, read) + }) + } +} + +// TestConfigurationJSONRejectsABadPattern states that a pattern that does +// not compile is reported where it was read. +func TestConfigurationJSONRejectsABadPattern(t *testing.T) { + var config mutation.Configuration + err := json.Unmarshal([]byte(`{"TemplatePattern":"("}`), &config, configjson.Options()) + require.ErrorContains(t, err, "error parsing regexp") + require.ErrorContains(t, err, "TemplatePattern") +} diff --git a/internal/mutation/delimiters_test.go b/internal/mutation/delimiters_test.go index 26fdabe1..c42a7d2a 100644 --- a/internal/mutation/delimiters_test.go +++ b/internal/mutation/delimiters_test.go @@ -5,7 +5,7 @@ import ( "strings" "testing" - "github.com/typelate/check" + "github.com/typelate/muxt/internal/source" ) // TestDelimitersReadsThemOffTheEndClause states how the delimiters a @@ -51,11 +51,11 @@ func TestDelimitersReadsThemOffTheEndClause(t *testing.T) { // trivially at offset zero. const prefix = "hello " text := prefix + tt.end - definition := check.Definition{ + definition := source.Definition{ Name: "x", - Define: check.Span{Position: token.Position{Filename: "t.gohtml"}, Length: 1}, - TemplateName: check.Span{Position: token.Position{Filename: "t.gohtml", Offset: 1, Line: 1}, Length: 3}, - End: check.Span{ + Define: source.Span{Position: token.Position{Filename: "t.gohtml"}, Length: 1}, + TemplateName: source.Span{Position: token.Position{Filename: "t.gohtml", Offset: 1, Line: 1}, Length: 3}, + End: source.Span{ Position: token.Position{Filename: "t.gohtml", Offset: len(prefix)}, Length: len(tt.end), }, @@ -82,9 +82,9 @@ func TestDelimitersDeclinesWhatItCannotRead(t *testing.T) { const text = `{{define "x"}}{{end}}` t.Run("a template with no define clause", func(t *testing.T) { - definition := check.Definition{ + definition := source.Definition{ Name: "t.gohtml", - End: check.Span{Position: token.Position{Filename: "t.gohtml", Offset: len(text)}}, + End: source.Span{Position: token.Position{Filename: "t.gohtml", Offset: len(text)}}, } if _, _, ok := delimiters(text, definition); ok { t.Error("delimiters accepted a definition with no end clause to read") @@ -92,10 +92,10 @@ func TestDelimitersDeclinesWhatItCannotRead(t *testing.T) { }) t.Run("a span outside the text", func(t *testing.T) { - definition := check.Definition{ + definition := source.Definition{ Name: "x", - TemplateName: check.Span{Position: token.Position{Filename: "t.gohtml", Offset: 9, Line: 1}, Length: 3}, - End: check.Span{ + TemplateName: source.Span{Position: token.Position{Filename: "t.gohtml", Offset: 9, Line: 1}, Length: 3}, + End: source.Span{ Position: token.Position{Filename: "t.gohtml", Offset: len(text)}, Length: 99, }, @@ -116,11 +116,11 @@ func TestDelimitersAgreeWithTheScanner(t *testing.T) { const text = `[[define "greeting"]]Hello, [[.Name]]![[end]]` endAt := strings.LastIndex(text, "[[end]]") - definition := check.Definition{ + definition := source.Definition{ Name: "greeting", - Define: check.Span{Position: token.Position{Filename: "t.gohtml"}, Length: 21}, - TemplateName: check.Span{Position: token.Position{Filename: "t.gohtml", Offset: 9, Line: 1}, Length: 10}, - End: check.Span{Position: token.Position{Filename: "t.gohtml", Offset: endAt}, Length: len("[[end]]")}, + Define: source.Span{Position: token.Position{Filename: "t.gohtml"}, Length: 21}, + TemplateName: source.Span{Position: token.Position{Filename: "t.gohtml", Offset: 9, Line: 1}, Length: 10}, + End: source.Span{Position: token.Position{Filename: "t.gohtml", Offset: endAt}, Length: len("[[end]]")}, } left, right, ok := delimiters(text, definition) diff --git a/internal/mutation/diff.go b/internal/mutation/diff.go index a668f728..79d13f40 100644 --- a/internal/mutation/diff.go +++ b/internal/mutation/diff.go @@ -11,8 +11,6 @@ import ( "os/exec" "path/filepath" "strings" - - "github.com/typelate/muxt/internal/load" ) // revision is what the templates looked like at an earlier commit: the @@ -56,17 +54,20 @@ func scopesOf(scopes []scope) revision { func templatesAt(config Configuration, dir string) (revision, error) { // The copy is outside any workspace GOWORK may name, and would fail to // load within one, so it loads as the module it is. - pl, err := loadPackages(dir, config.IncludeTests, append(config.environment(), "GOWORK=off")) + in, err := loadInput(dir, config, append(config.environment(), "GOWORK=off")) if err != nil { return nil, err } + return revisionOf(in) +} + +// revisionOf records what each template reached in the input reads like, +// which is what a --diff run compares the working tree with. +func revisionOf(in input) (revision, error) { before := make(revision) - for _, templatesVariable := range config.TemplatesVariables { - lt, err := load.Templates(dir, templatesVariable, pl) - if err != nil { - return nil, err - } - index, err := buildTreeIndex(lt, dir, pl, lt.Templates.Functions()) + for _, variable := range in.pkg.Variables { + lt := newChecked(in.pkg, variable) + index, err := buildTreeIndex(lt, in.dir) if err != nil { return nil, err } diff --git a/internal/mutation/input.go b/internal/mutation/input.go new file mode 100644 index 00000000..e50f5ec6 --- /dev/null +++ b/internal/mutation/input.go @@ -0,0 +1,73 @@ +package mutation + +import ( + "text/template/parse" + + "github.com/typelate/check" + "golang.org/x/tools/go/packages" + + "github.com/typelate/muxt/internal/load" + "github.com/typelate/muxt/internal/source" +) + +// input is what a run plans from: the package its templates variables are +// declared in, and the directory reported paths are relative to. +// +// loadInput builds one with the go command. Everything after that -- +// traversal, enumeration, the --diff comparison -- reads this and the +// template files its definitions name, and needs no go command, so a test +// can plan from a package built in memory. +type input struct { + dir string + pkg source.Package +} + +// loadInput loads the package in dir, with its test files when the +// configuration includes test callers. env is the environment the go +// command runs in, nil for the process's own. +func loadInput(dir string, config Configuration, env []string) (input, error) { + var ( + pl []*packages.Package + err error + ) + if config.IncludeTests { + pl, err = load.PackagesWithTests(dir, env) + } else { + _, pl, err = load.PackagesWithEnv(dir, env) + } + if err != nil { + return input{}, err + } + return inputFrom(dir, pl, config.TemplatesVariables) +} + +// inputFrom reads the templates variables from the package in dir among +// pl. +func inputFrom(dir string, pl []*packages.Package, variables []string) (input, error) { + pkg, err := load.Package(dir, pl, variables) + if err != nil { + return input{}, err + } + return input{dir: dir, pkg: pkg}, nil +} + +// checked is one templates variable with the checker built for it, which +// traversal and the validity check share. +type checked struct { + source.Variable + global *check.Global +} + +func newChecked(pkg source.Package, variable source.Variable) *checked { + trees := check.FindTreeFunc(func(name string) (*parse.Tree, bool) { + t := variable.Set.Lookup(name) + if t == nil || t.Tree == nil { + return nil, false + } + return t.Tree, true + }) + return &checked{ + Variable: variable, + global: check.NewGlobal(pkg.Types, pkg.Fset, trees, check.Functions(variable.Functions)), + } +} diff --git a/internal/mutation/plan.go b/internal/mutation/plan.go index ec7559fc..d249ed1b 100644 --- a/internal/mutation/plan.go +++ b/internal/mutation/plan.go @@ -10,10 +10,9 @@ import ( "text/template/parse" "github.com/typelate/check" - "golang.org/x/tools/go/packages" "github.com/typelate/muxt/internal/asteval" - "github.com/typelate/muxt/internal/load" + "github.com/typelate/muxt/internal/source" ) // plan is everything decided before a single test is run: which templates @@ -145,65 +144,73 @@ func (s selector) choose(scopes []scope, trims []trim) selection { return chosen } -// newPlan loads the project, walks the templates each ExecuteTemplate -// call reaches, and enumerates the variations available in each. +// newPlan loads the project, then plans from it. func newPlan(config Configuration, workingDirectory string) (*plan, error) { - pl, err := loadPackages(workingDirectory, config.IncludeTests, config.env) + in, err := loadInput(workingDirectory, config, config.env) if err != nil { return nil, err } - - include := func(string) bool { return true } - if config.TemplatePattern != nil { - include = config.TemplatePattern.MatchString - } - - p := &plan{ - seed: config.Seed, - draw: newValues(config.Seed), - maxCases: config.MaxCases, - } - if p.maxCases <= 0 { - p.maxCases = DefaultMaxCases - } // before is what the templates looked like at the --diff revision. // Nil means there is nothing to compare with, so every template // counts as changed. - var before revision + var ( + before revision + diffError string + ) if config.Diff != "" { - p.diff = config.Diff dir, cleanup, err := checkout(workingDirectory, config.Diff) if err != nil { return nil, err } defer cleanup() if before, err = templatesAt(config, dir); err != nil { - p.diffError = err.Error() + diffError = err.Error() } } + return planFrom(config, in, before, diffError) +} + +// planFrom walks the templates each ExecuteTemplate call in the input +// reaches and enumerates the variations available in each. +// +// It is everything a plan decides, from inputs that hold no loader: before +// is what the templates read like at the --diff revision, nil when there +// is none, and diffError why that revision could not be read. +func planFrom(config Configuration, in input, before revision, diffError string) (*plan, error) { + include := func(string) bool { return true } + if config.TemplatePattern != nil { + include = config.TemplatePattern.MatchString + } + + p := &plan{ + seed: config.Seed, + draw: newValues(config.Seed), + maxCases: config.MaxCases, + diff: config.Diff, + diffError: diffError, + } + if p.maxCases <= 0 { + p.maxCases = DefaultMaxCases + } sel := selector{ before: before, seen: make(map[string]struct{}), unchanged: make(map[string]struct{}), include: include, - wd: workingDirectory, + wd: in.dir, } - for _, templatesVariable := range config.TemplatesVariables { - lt, err := load.Templates(workingDirectory, templatesVariable, pl) - if err != nil { - return nil, err - } - functions := lt.Templates.Functions() + for _, variable := range in.pkg.Variables { + lt := newChecked(in.pkg, variable) - index, err := buildTreeIndex(lt, workingDirectory, pl, functions) + index, err := buildTreeIndex(lt, in.dir) if err != nil { return nil, err } chosen := sel.choose(traverse(lt, index)) for _, sc := range chosen.mutate { - p.add(lt, sc, functions, workingDirectory) + p.add(lt, sc, lt.Functions, in.dir) } p.unchanged = append(p.unchanged, chosen.unchanged...) p.trimmed = append(p.trimmed, chosen.trimmed...) @@ -224,7 +231,7 @@ func newPlan(config Configuration, workingDirectory string) (*plan, error) { // add enumerates one template's mutants and files them under the call // that reaches it. -func (p *plan) add(lt *load.LoadedTemplates, sc scope, functions check.Functions, workingDirectory string) { +func (p *plan) add(lt *checked, sc scope, functions check.Functions, workingDirectory string) { found, notes := mutantsInScope(sc, functions, p.draw, p.maxCases) report := TemplateReport{ @@ -309,7 +316,7 @@ func (p *plan) add(lt *load.LoadedTemplates, sc scope, functions check.Functions // the tests fail with a render error. That failure would be recorded as // the mutation being caught, which is a lie: nothing asserted on the // behaviour, the template just stopped working. -func invalid(lt *load.LoadedTemplates, sc scope, mutant Mutant, functions check.Functions) (string, bool) { +func invalid(lt *checked, sc scope, mutant Mutant, functions check.Functions) (string, bool) { mutated := sc.src.mutatedText(mutant.edits) trees, err := asteval.ParseTrees(sc.src.rootName, mutated, sc.src.leftDelim, sc.src.rightDelim, functions) if err != nil { @@ -331,21 +338,22 @@ func invalid(lt *load.LoadedTemplates, sc scope, mutant Mutant, functions check. // The trees are parsed here rather than taken from the template set so // that every node position is an offset into text this package holds, // which is what a mutation is spliced into. -func buildTreeIndex(lt *load.LoadedTemplates, workingDirectory string, pl []*packages.Package, functions check.Functions) (map[string]treeLocation, error) { +func buildTreeIndex(lt *checked, workingDirectory string) (map[string]treeLocation, error) { + functions := lt.Functions // The definitions are gathered before the collector is built: a // source scans its actions as it is constructed, and it can only do // that once the delimiters its file was written with are known, // which is something the definitions say. - var defs []check.Definition - for _, t := range lt.HTML.Templates() { - definition, ok := lt.Templates.FindDefinition(t.Name()) + var defs []source.Definition + for _, t := range lt.Set.Templates() { + definition, ok := lt.Definitions[t.Name()] if !ok { continue } defs = append(defs, definition) } - collector := newSourceCollector(workingDirectory, pl, defs) + collector := newSourceCollector(workingDirectory, defs) for _, definition := range defs { if _, err := collector.add(definition); err != nil { return nil, err @@ -421,18 +429,6 @@ func countActions(node parse.Node) int { } } -// loadPackages loads the working directory's package, optionally -// including its test files so that ExecuteTemplate calls written in tests -// are visible. env is the environment the go command runs in, nil for the -// process's own. -func loadPackages(workingDirectory string, includeTests bool, env []string) ([]*packages.Package, error) { - if !includeTests { - _, pl, err := load.PackagesWithEnv(workingDirectory, env) - return pl, err - } - return load.PackagesWithTests(workingDirectory, env) -} - func relativePosition(workingDirectory string, position token.Position) string { if !position.IsValid() { return "?" diff --git a/internal/mutation/snapshot_test.go b/internal/mutation/snapshot_test.go new file mode 100644 index 00000000..7a4f625e --- /dev/null +++ b/internal/mutation/snapshot_test.go @@ -0,0 +1,191 @@ +package mutation + +import ( + "encoding/json/v2" + "errors" + "flag" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "golang.org/x/tools/txtar" + + "github.com/typelate/muxt/internal/configjson" + "github.com/typelate/muxt/internal/load/loadtest" + "github.com/typelate/muxt/internal/muxt" +) + +var update = flag.Bool("update", false, "rewrite the want/ files of the snapshot archives in testdata") + +// TestSnapshots plans a dry run for each archive in +// testdata/test-template-mutations and compares the report with the +// archive's want/ files. The directory is the command, so one case runs +// with -run TestSnapshots/test-template-mutations/diff. +// +// An archive holds everything a case is: the configuration it plans with, +// its inputs, and what the plan reports. +// +// - config.json is the configuration, as the command line named in the +// archive's header parses into it. TestCommandLineConfigurations in +// internal/cli states that parse; this states what planning does with +// the result. +// - Go files and template files are written to a directory and loaded as +// example.com/server by internal/load/loadtest: type checked against +// the official standard library, without loading the package graph. +// - Files under before/ are the package at the --diff revision, loaded +// the same way, when the configuration names one. +// - want/report.txt is the dry run's report and want/error.txt the error +// planning returned. +// +// Run with -update to rewrite the want/ files, then read the diff. Whether +// the tests catch a mutant is decided by running them, which is what +// run_test.go and the integration suite do. +func TestSnapshots(t *testing.T) { + const command = "test-template-mutations" + if stray, _ := filepath.Glob(filepath.Join("testdata", "*.txtar")); len(stray) > 0 { + t.Fatalf("%s is not in a command's directory; the mutation run's archives are in testdata/%s", stray[0], command) + } + archives, err := filepath.Glob(filepath.Join("testdata", command, "*.txtar")) + if err != nil { + t.Fatal(err) + } + if len(archives) == 0 { + t.Fatalf("no archives in testdata/%s", command) + } + t.Run(command, func(t *testing.T) { + for _, archivePath := range archives { + runSnapshot(t, archivePath) + } + }) +} + +// runSnapshot compares one archive's want/ files with the report planning +// it produces, or with -update rewrites them. +func runSnapshot(t *testing.T, archivePath string) { + t.Helper() + t.Run(strings.TrimSuffix(filepath.Base(archivePath), ".txtar"), func(t *testing.T) { + archive, err := txtar.ParseFile(archivePath) + if err != nil { + t.Fatal(err) + } + config := configuration(t, archive) + if !config.DryRun { + t.Fatal("a snapshot plans a dry run; config.json must set DryRun") + } + got := dryRunSnapshot(t, config, archive) + if *update { + files := slices.DeleteFunc(slices.Clone(archive.Files), func(file txtar.File) bool { + return strings.HasPrefix(file.Name, "want/") + }) + for _, name := range []string{"error.txt", "report.txt"} { + if text, ok := got[name]; ok { + files = append(files, txtar.File{Name: "want/" + name, Data: []byte(text)}) + } + } + archive.Files = files + if err := os.WriteFile(archivePath, txtar.Format(archive), 0o644); err != nil { + t.Fatal(err) + } + return + } + want := make(map[string]string) + for _, file := range archive.Files { + if name, ok := strings.CutPrefix(file.Name, "want/"); ok { + want[name] = string(file.Data) + } + } + for _, name := range []string{"error.txt", "report.txt"} { + if got[name] != want[name] { + t.Errorf("want/%s differs (run go test -run TestSnapshots -update to rewrite):\n--- got\n%s\n--- want\n%s", name, got[name], want[name]) + } + } + }) +} + +// configuration reads the archive's config.json: the configuration to plan +// with. +func configuration(t *testing.T, archive *txtar.Archive) Configuration { + t.Helper() + for _, file := range archive.Files { + if file.Name != "config.json" { + continue + } + var config Configuration + if err := json.Unmarshal(file.Data, &config, configjson.Options()); err != nil { + t.Fatalf("config.json: %v", err) + } + return config + } + t.Fatal("the archive has no config.json") + return Configuration{} +} + +// dryRunSnapshot plans and dry runs an archive's package under config. +func dryRunSnapshot(t *testing.T, config Configuration, archive *txtar.Archive) map[string]string { + t.Helper() + current, before := make(map[string]string), make(map[string]string) + for _, file := range archive.Files { + switch { + case file.Name == "config.json": + case strings.HasPrefix(file.Name, "want/"): + case strings.HasPrefix(file.Name, "before/"): + before[strings.TrimPrefix(file.Name, "before/")] = string(file.Data) + default: + current[file.Name] = string(file.Data) + } + } + + got := make(map[string]string) + fail := func(err error) map[string]string { + text := err.Error() + if multiLine, ok := errors.AsType[muxt.MultiLineError](err); ok { + text = multiLine.MultiLineError() + } + got["error.txt"] = text + "\n" + return got + } + + dir := t.TempDir() + in, err := inputFrom(dir, loadtest.Package(t, dir, "example.com/server", current), config.TemplatesVariables) + if err != nil { + return fail(err) + } + + var ( + previous revision + diffError string + ) + if config.Diff != "" { + beforeDir := t.TempDir() + beforeInput, err := inputFrom(beforeDir, loadtest.Package(t, beforeDir, "example.com/server", before), config.TemplatesVariables) + if err == nil { + previous, err = revisionOf(beforeInput) + } + if err != nil { + diffError = err.Error() + } + } + + p, err := planFrom(config, in, previous, diffError) + if err != nil { + return fail(err) + } + report, err := runPlan(p, config, nil, func([]string) (string, error) { + t.Fatal("a dry run ran the baseline") + return "", nil + }, func(string) (Status, error) { + t.Fatal("a dry run ran a mutant") + return "", nil + }) + if err != nil { + return fail(err) + } + var out strings.Builder + if _, err := report.WriteTo(&out); err != nil { + t.Fatal(err) + } + got["report.txt"] = out.String() + return got +} diff --git a/internal/mutation/source.go b/internal/mutation/source.go index a6513210..1c40916e 100644 --- a/internal/mutation/source.go +++ b/internal/mutation/source.go @@ -3,14 +3,14 @@ package mutation import ( "fmt" "go/ast" + "go/parser" "go/token" "path/filepath" "strconv" "strings" "unicode/utf8" - "github.com/typelate/check" - "golang.org/x/tools/go/packages" + "github.com/typelate/muxt/internal/source" ) // templateSource is a file whose bytes hold template text, together with @@ -81,7 +81,7 @@ const spaceChars = " \t\r\n" // an optional trim marker, the word end, another optional marker, and // the right delimiter. Nothing else in it varies, so whatever surrounds // the word is the pair. -func delimiters(text string, definition check.Definition) (left, right string, ok bool) { +func delimiters(text string, definition source.Definition) (left, right string, ok bool) { if !definition.TemplateName.IsValid() { // A template with no define clause has no end clause either. return "", "", false @@ -254,38 +254,44 @@ func literalOffsets(literal, value string) ([]int, error) { return offsets, nil } -// findStringLiteral returns the Go string literal covering offset in the -// named file, which is the literal a template written in Go source was -// written as. -func findStringLiteral(pl []*packages.Package, filename string, offset int) (start, end int, ok bool) { - seen := make(map[*ast.File]struct{}) - for _, pkg := range pl { - for _, file := range pkg.Syntax { - if _, done := seen[file]; done { - continue - } - seen[file] = struct{}{} - tokenFile := pkg.Fset.File(file.Pos()) - if tokenFile == nil || tokenFile.Name() != filename { - continue - } - ast.Inspect(file, func(node ast.Node) bool { - lit, isLit := node.(*ast.BasicLit) - if !isLit || lit.Kind != token.STRING { - return true - } - litStart, litEnd := tokenFile.Offset(lit.Pos()), tokenFile.Offset(lit.End()) - if offset < litStart || offset >= litEnd { - return true - } - // Nested literals do not occur, so the first match is - // the one wanted. - start, end, ok = litStart, litEnd, true - return false - }) - if ok { - return start, end, true - } +// literalSpan is where one string literal sits in its file's text. +type literalSpan struct{ start, end int } + +// stringLiterals returns where every string literal in the file sits, in +// the order they were written, which is how a template written in Go +// source is found in its file's text. +// +// The file is parsed here rather than taken from the loaded package: the +// text is already in hand, and it leaves the plan needing nothing from the +// loader. AllErrors keeps a file that does not fully parse yielding the +// literals the parser did read -- without it the parser gives up after ten +// syntax errors and returns nothing, and a run over a broken file would +// report its templates as unreadable rather than the file as unparsed. +func stringLiterals(filename, text string) []literalSpan { + fset := token.NewFileSet() + file, _ := parser.ParseFile(fset, filename, text, parser.SkipObjectResolution|parser.AllErrors) + if file == nil { + return nil + } + tokenFile := fset.File(file.FileStart) + var spans []literalSpan + ast.Inspect(file, func(node ast.Node) bool { + lit, isLit := node.(*ast.BasicLit) + if !isLit || lit.Kind != token.STRING { + return true + } + spans = append(spans, literalSpan{start: tokenFile.Offset(lit.Pos()), end: tokenFile.Offset(lit.End())}) + return false + }) + return spans +} + +// literalAt returns the bounds of the literal covering offset. Literals do +// not nest, so the first match is the one wanted. +func literalAt(spans []literalSpan, offset int) (start, end int, ok bool) { + for _, span := range spans { + if offset >= span.start && offset < span.end { + return span.start, span.end, true } } return 0, 0, false diff --git a/internal/mutation/testdata/test-template-mutations/delimiters.txtar b/internal/mutation/testdata/test-template-mutations/delimiters.txtar new file mode 100644 index 00000000..d3540e91 --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/delimiters.txtar @@ -0,0 +1,43 @@ +A literal parsed with other delimiters is read with them. + +Command line: muxt test-template-mutations --dry-run --seed=1 -v +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": true, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "html/template" + "io" +) + +var templates = template.Must(template.New("page").Delims("[[", "]]").Parse(`[[define "note"]]

    [[.]]

    [[end]]`)) + +func RenderNote(w io.Writer, note string) error { + return templates.ExecuteTemplate(w, "note", note) +} +-- want/report.txt -- +1 mutant across 1 template (complexity 1, seed 1) +dry run: no tests were run + +server.go:11:9 ExecuteTemplate "note" (dot: string) + "note" server.go (complexity 1, dot: string) + PEND 8:98 action-zero + +1 mutant, 1 runnable, 0 skipped diff --git a/internal/mutation/testdata/test-template-mutations/diff.txtar b/internal/mutation/testdata/test-template-mutations/diff.txtar new file mode 100644 index 00000000..93bca4f4 --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/diff.txtar @@ -0,0 +1,85 @@ +With --diff, a template whose text and dot are unchanged since the +revision is left alone and reported; one that reads differently is +mutated. + +Command line: muxt test-template-mutations --dry-run --seed=1 -v --diff=main +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": true, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "main", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +type Page struct{ Title, Body string } + +func RenderPage(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} +-- page.gohtml -- +{{define "page"}}{{template "header" .}}{{template "body" .}}{{end}} +{{define "header"}}

    {{.Title}}

    {{end}} +{{define "body"}}
    {{.Body}}{{if .Title}}!{{end}}
    {{end}} +-- before/server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +type Page struct{ Title, Body string } + +func RenderPage(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} +-- before/page.gohtml -- +{{define "page"}}{{template "header" .}}{{template "body" .}}{{end}} +{{define "header"}}

    {{.Title}}

    {{end}} +{{define "body"}}
    {{.Body}}
    {{end}} +-- want/report.txt -- +3 mutants across 1 template (complexity 2, seed 1) +2 templates unchanged since main +dry run: no tests were run + +server.go:17:9 ExecuteTemplate "page" (dot: server.Page) + "body" page.gohtml via {{template}} (complexity 2, dot: server.Page) + PEND 3:24 action-zero + PEND 3:33 if-false + PEND 3:33 if-true + +unchanged since main, not mutated: + "page" page.gohtml (dot: server.Page) + "header" page.gohtml (dot: server.Page) + +3 mutants, 3 runnable, 0 skipped diff --git a/internal/mutation/testdata/test-template-mutations/err_missing_variable.txtar b/internal/mutation/testdata/test-template-mutations/err_missing_variable.txtar new file mode 100644 index 00000000..64cf14f2 --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/err_missing_variable.txtar @@ -0,0 +1,42 @@ +A templates variable the package does not declare. + +Command line: muxt test-template-mutations --dry-run --seed=1 --use-templates-variable=pages +-- config.json -- +{ + "TemplatesVariables": [ + "pages" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": false, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +func RenderPage(w io.Writer) error { + return templates.ExecuteTemplate(w, "page", nil) +} +-- page.gohtml -- +{{define "page"}}{{.}}{{end}} +-- want/error.txt -- +variable pages not found in package example.com/server diff --git a/internal/mutation/testdata/test-template-mutations/err_no_call_sites.txtar b/internal/mutation/testdata/test-template-mutations/err_no_call_sites.txtar new file mode 100644 index 00000000..b404b3ce --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/err_no_call_sites.txtar @@ -0,0 +1,42 @@ +A templates variable whose templates no ExecuteTemplate call renders has +nothing to mutate. + +Command line: muxt test-template-mutations --dry-run --seed=1 +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": false, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +var _ io.Writer + +-- page.gohtml -- +{{define "page"}}{{.}}{{end}} +-- want/error.txt -- +no templates.ExecuteTemplate calls found: mutation testing needs a call site to know the type of dot diff --git a/internal/mutation/testdata/test-template-mutations/err_no_mutations.txtar b/internal/mutation/testdata/test-template-mutations/err_no_mutations.txtar new file mode 100644 index 00000000..58832064 --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/err_no_mutations.txtar @@ -0,0 +1,43 @@ +Templates that were reached but hold no action to vary are an error, not +a pass. + +Command line: muxt test-template-mutations --dry-run --seed=1 +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": false, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +func RenderPage(w io.Writer) error { + return templates.ExecuteTemplate(w, "page", nil) +} +-- page.gohtml -- +{{define "page"}}

    static

    {{end}} +-- want/error.txt -- +no mutations available: the 1 template(s) reached hold no dynamic or control flow actions, so a run would report every mutant killed without testing anything diff --git a/internal/mutation/testdata/test-template-mutations/literal_template.txtar b/internal/mutation/testdata/test-template-mutations/literal_template.txtar new file mode 100644 index 00000000..b0a12a8f --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/literal_template.txtar @@ -0,0 +1,51 @@ +A template written as a Go string literal: its actions and branches are +mutated where they are written in the literal. + +Command line: muxt test-template-mutations --dry-run --seed=1 -v +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": true, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "html/template" + "io" +) + +var templates = template.Must(template.New("greeting").Parse(`Hello, {{.Name}}!{{if .Loud}} !!!{{end}}`)) + +type Greeting struct { + Name string + Loud bool +} + +func Render(w io.Writer, greeting Greeting) error { + return templates.ExecuteTemplate(w, "greeting", greeting) +} +-- want/report.txt -- +3 mutants across 1 template (complexity 2, seed 1) +dry run: no tests were run + +server.go:16:9 ExecuteTemplate "greeting" (dot: server.Greeting) + "greeting" server.go (complexity 2, dot: server.Greeting) + PEND 8:70 action-zero + PEND 8:80 if-false + PEND 8:80 if-true + +3 mutants, 3 runnable, 0 skipped diff --git a/internal/mutation/testdata/test-template-mutations/operand_budget.txtar b/internal/mutation/testdata/test-template-mutations/operand_budget.txtar new file mode 100644 index 00000000..8db628e5 --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/operand_budget.txtar @@ -0,0 +1,53 @@ +An action with more operand combinations than --max-cases allows +contributes none, and says so where its mutants would have been. + +Command line: muxt test-template-mutations --dry-run --seed=1 -v --max-cases=2 +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": true, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 2, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +type Page struct{ A, B, C int } + +func RenderPage(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} +-- page.gohtml -- +{{define "page"}}{{printf "%d %d %d" .A .B .C}}{{end}} +-- want/report.txt -- +2 mutants across 1 template (complexity 1, seed 1) +dry run: no tests were run + +server.go:17:9 ExecuteTemplate "page" (dot: server.Page) + "page" page.gohtml (complexity 1, dot: server.Page) + PEND 1:18 action-zero + SKIP 1:18 operands (3 operands need 7 cases, over --max-cases=2) + +2 mutants, 1 runnable, 1 skipped diff --git a/internal/mutation/testdata/test-template-mutations/partials_and_trims.txtar b/internal/mutation/testdata/test-template-mutations/partials_and_trims.txtar new file mode 100644 index 00000000..b2a4bc9f --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/partials_and_trims.txtar @@ -0,0 +1,77 @@ +A page renders partials through {{template}}, narrowing dot through a +range. A partial reached twice with the same dot is mutated once, and the +repeat is reported as trimmed. + +Command line: muxt test-template-mutations --dry-run --seed=1 -v +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": true, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +type Page struct { + Title string + Items []Item +} + +type Item struct { + Name string + Price int +} + +func RenderPage(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} + +func RenderAgain(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} +-- page.gohtml -- +{{define "page"}}

    {{.Title}}

    {{range .Items}}{{template "item" .}}{{end}}{{template "item" (index .Items 0)}}{{end}} +{{define "item"}}
  • {{.Name}}{{if gt .Price 10}} (sale){{end}}
  • {{end}} +-- want/report.txt -- +7 mutants across 2 templates (complexity 4, seed 1) +dry run: no tests were run + +server.go:25:9 ExecuteTemplate "page" (dot: server.Page) + "page" page.gohtml (complexity 2, dot: server.Page) + PEND 1:22 action-zero + PEND 1:37 range-never + PEND 1:53 template-drop + PEND 1:81 template-drop + "item" page.gohtml via {{template}} (complexity 2, dot: server.Item) + PEND 2:22 action-zero + PEND 2:31 if-false + PEND 2:31 if-true + +trimmed 2 subtrees already mutated with the same dot: + "item" at server.go:25:9, first reached from server.go:25:9 + "page" at server.go:29:9, first reached from server.go:25:9 + +7 mutants, 7 runnable, 0 skipped diff --git a/internal/mutation/testdata/test-template-mutations/skipped_mutants.txtar b/internal/mutation/testdata/test-template-mutations/skipped_mutants.txtar new file mode 100644 index 00000000..c1543e34 --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/skipped_mutants.txtar @@ -0,0 +1,65 @@ +A mutant that would not type check is skipped rather than run: emptying +the action that declares $inner leaves its use undefined. So is a +condition simplification proves cannot change the decision. + +Command line: muxt test-template-mutations --dry-run --seed=1 -v +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": null, + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": true, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +type Inner struct{ Name string } + +type Page struct { + Inner Inner + Banned bool + Loud bool +} + +func RenderPage(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} +-- page.gohtml -- +{{define "page"}}{{$inner := .Inner}}{{$inner.Name}}{{if or .Banned (and .Banned .Loud)}}B{{end}}{{end}} +-- want/report.txt -- +7 mutants across 1 template (complexity 3, seed 1) +dry run: no tests were run + +server.go:23:9 ExecuteTemplate "page" (dot: server.Page) + "page" page.gohtml (complexity 3, dot: server.Page) + SKIP 1:18 action-empty (does not type check against server.Page) + PEND 1:38 action-empty + PEND 1:53 condition .Banned=false + PEND 1:53 condition .Banned=true + SKIP 1:53 condition-dead (.Loud cannot change the decision) + PEND 1:53 if-false + PEND 1:53 if-true + +7 mutants, 5 runnable, 2 skipped diff --git a/internal/mutation/testdata/test-template-mutations/template_pattern.txtar b/internal/mutation/testdata/test-template-mutations/template_pattern.txtar new file mode 100644 index 00000000..964ba793 --- /dev/null +++ b/internal/mutation/testdata/test-template-mutations/template_pattern.txtar @@ -0,0 +1,53 @@ +--template-pattern limits mutation to the templates it matches; the +traversal still passes through the others to reach them. + +Command line: muxt test-template-mutations --dry-run --seed=1 -v --template-pattern=^footer$ +-- config.json -- +{ + "TemplatesVariables": [ + "templates" + ], + "TemplatePattern": "^footer$", + "Run": null, + "Packages": [], + "DryRun": true, + "Verbose": true, + "IncludeTests": false, + "Seed": 1, + "SeedSet": true, + "MaxCases": 8, + "Workers": 1, + "Diff": "", + "GoTestArgs": null +} +-- server.go -- +package server + +import ( + "embed" + "html/template" + "io" +) + +//go:embed *.gohtml +var templateFiles embed.FS + +var templates = template.Must(template.ParseFS(templateFiles, "*.gohtml")) + +type Page struct{ Title string } + +func RenderPage(w io.Writer, page Page) error { + return templates.ExecuteTemplate(w, "page", page) +} +-- page.gohtml -- +{{define "page"}}

    {{.Title}}

    {{template "footer" .}}{{end}} +{{define "footer"}}
    {{.Title}}
    {{end}} +-- want/report.txt -- +1 mutant across 1 template (complexity 1, seed 1) +dry run: no tests were run + +server.go:17:9 ExecuteTemplate "page" (dot: server.Page) + "footer" page.gohtml via {{template}} (complexity 1, dot: server.Page) + PEND 2:28 action-zero + +1 mutant, 1 runnable, 0 skipped diff --git a/internal/mutation/traverse.go b/internal/mutation/traverse.go index 6c1e6aae..2a1d2e8b 100644 --- a/internal/mutation/traverse.go +++ b/internal/mutation/traverse.go @@ -6,7 +6,6 @@ import ( "text/template/parse" "github.com/typelate/check" - "github.com/typelate/muxt/internal/load" ) // callSite is one templates.ExecuteTemplate call, which is where a @@ -79,15 +78,15 @@ type trim struct { // input, or two {{template}} invocations passing the same type, would // produce the same mutants and the same verdicts, so the second is // trimmed. -func traverse(lt *load.LoadedTemplates, index map[string]treeLocation) ([]scope, []trim) { +func traverse(lt *checked, index map[string]treeLocation) ([]scope, []trim) { t := &traversal{lt: lt, index: index, visited: make(map[string]callSite)} - for call := range lt.Templates.ExecuteTemplateCalls() { + for _, call := range lt.Calls { site := callSite{ - Position: lt.Package.Fset.Position(call.Call.Pos()), - Template: call.TemplateName, - DataType: call.DataType, + Position: call.Position, + Template: call.Template, + DataType: call.Data, } - t.visit(site, call.TemplateName, call.DataType, false) + t.visit(site, call.Template, call.Data, false) } return t.scopes, t.trimmed } @@ -95,7 +94,7 @@ func traverse(lt *load.LoadedTemplates, index map[string]treeLocation) ([]scope, // traversal is the state of one walk: what it reads from, and what it has // found so far. type traversal struct { - lt *load.LoadedTemplates + lt *checked index map[string]treeLocation // visited records the call site each template and dot was first @@ -147,7 +146,7 @@ type templateCall struct { // // The types come from the checker, which is the only thing that knows how // dot narrows through a range or a with on the way to the invocation. -func templateCalls(lt *load.LoadedTemplates, tree *parse.Tree, dot types.Type) []templateCall { +func templateCalls(lt *checked, tree *parse.Tree, dot types.Type) []templateCall { var found []templateCall // A template that does not check still yields the invocations found // before the failure, which is better than none: muxt check is where @@ -160,18 +159,18 @@ func templateCalls(lt *load.LoadedTemplates, tree *parse.Tree, dot types.Type) [ // checks reports whether tree type checks with dot, which is how a mutant // is told from a mutation that merely breaks the template. -func checks(lt *load.LoadedTemplates, tree *parse.Tree, dot types.Type) bool { +func checks(lt *checked, tree *parse.Tree, dot types.Type) bool { return executeWith(lt, tree, dot, nil) == nil } // executeWith type checks tree with dot, calling inspect on each // {{template}} node it passes, then puts back whatever inspector the // shared checker held before. -func executeWith(lt *load.LoadedTemplates, tree *parse.Tree, dot types.Type, inspect func(*parse.TemplateNode, *parse.Tree, types.Type, check.Definition)) error { - saved := lt.Global.InspectTemplateNode - lt.Global.InspectTemplateNode = inspect - defer func() { lt.Global.InspectTemplateNode = saved }() - return check.Execute(lt.Global, tree, dot) +func executeWith(lt *checked, tree *parse.Tree, dot types.Type, inspect func(*parse.TemplateNode, *parse.Tree, types.Type, check.Definition)) error { + saved := lt.global.InspectTemplateNode + lt.global.InspectTemplateNode = inspect + defer func() { lt.global.InspectTemplateNode = saved }() + return check.Execute(lt.global, tree, dot) } // typeKey identifies a type exactly, for deciding whether a template has diff --git a/internal/source/source.go b/internal/source/source.go index 2fc237a6..c161c7a4 100644 --- a/internal/source/source.go +++ b/internal/source/source.go @@ -7,10 +7,10 @@ // // internal/load builds a Package from a go/packages load; that is the only // step that runs the go command. Everything muxt does after it -- route -// resolution, generation, type checking templates -- reads a Package. It -// is plain data, with no function fields and no loader behind it, so a -// test can write one as a literal or build one from source type checked in -// memory. +// resolution, generation, type checking templates, planning mutations -- +// reads a Package. It is plain data, with no function fields and no loader +// behind it, so a test can write one as a literal or build one from source +// type checked in memory. package source import ( From bd3974843f505706ffb4482ac8149b3a7b665837 Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:29:41 -0700 Subject: [PATCH 4/5] Read and write JSON with encoding/json/v2 The snapshot archives hold the configuration they run with, and reading one is encoding/json/v2: it knows how to read a *regexp.Regexp, which v1 could only do through a second struct carrying the pattern's source. Everywhere else muxt reads or writes JSON went with it, so one library answers "how does muxt read JSON": the module list, the overlay each mutant is delivered through, the generated file's import map, and a command's --format=json result. Two differences the standard library documents, and the integration suite insisted on: - omitempty in v2 omits an empty JSON value -- null, "", [], {} -- and no longer a zero number or a false. The fields that meant the latter say omitzero now, so a mutant that took no measurable time still reports no seconds, and a module listing still leaves out the flags a package does not set. - Reading is case sensitive, and a member the target does not declare is an error rather than silence. Every name muxt reads was already written by muxt or by the go command, so nothing had to change. - Writing makes no promise about map order, writes a nil list or map as [] or {}, and escapes neither <, >, & nor U+2028 and U+2029, where v1 sorted map keys, wrote null and escaped them. --format=json is read by scripts, so it is written with the options that say v1's choices, and TestWriteResultJSON states each one. A listing's import map now writes through the encoder it is handed (MarshalJSONTo), so those options reach it too, rather than a separate Marshal deciding its order. The reports and listings are byte for byte what they were. Assisted-by: Claude:claude-opus-5 gofumpt --- internal/analysis/module.go | 12 +++---- internal/astgen/pkg.go | 11 ++++-- internal/cli/commands.go | 18 ++++++++-- internal/cli/result_json_test.go | 60 ++++++++++++++++++++++++++++++++ internal/mutation/report.go | 4 +-- internal/mutation/runner.go | 2 +- internal/mutation/runner_test.go | 2 +- 7 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 internal/cli/result_json_test.go diff --git a/internal/analysis/module.go b/internal/analysis/module.go index c7d11e1f..d1f56e1f 100644 --- a/internal/analysis/module.go +++ b/internal/analysis/module.go @@ -3,7 +3,7 @@ package analysis import ( "bufio" "bytes" - "encoding/json" + "encoding/json/v2" "io" "os" "os/exec" @@ -38,11 +38,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 { diff --git a/internal/astgen/pkg.go b/internal/astgen/pkg.go index 4cd03f6f..1bcf2a0c 100644 --- a/internal/astgen/pkg.go +++ b/internal/astgen/pkg.go @@ -1,7 +1,8 @@ package astgen import ( - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "go/ast" "go/token" "go/types" @@ -24,8 +25,12 @@ func NewTypeFormatter(outputPkgPath string) *TypeFormatter { } } -func (tf *TypeFormatter) MarshalJSON() ([]byte, error) { - return json.MarshalIndent(tf.Imports, "", " ") +// MarshalJSONTo writes the imports the formatter collected. It writes +// through the encoder it is handed, so whatever the whole document is +// written with -- the indentation, and the key order a reproducible listing +// needs -- holds for the imports too. +func (tf *TypeFormatter) MarshalJSONTo(encoder *jsontext.Encoder) error { + return json.MarshalEncode(encoder, tf.Imports) } func (tf *TypeFormatter) Qualifier(pkg *types.Package) string { diff --git a/internal/cli/commands.go b/internal/cli/commands.go index 0e91341c..f65c5127 100644 --- a/internal/cli/commands.go +++ b/internal/cli/commands.go @@ -3,7 +3,8 @@ package cli import ( "cmp" _ "embed" - "encoding/json" + "encoding/json/jsontext" + "encoding/json/v2" "errors" "fmt" "go/token" @@ -927,6 +928,19 @@ This command is intended for exploratory use only.`, return cmd } +// resultJSON writes a --format=json result as encoding/json wrote it before +// muxt moved to encoding/json/v2, so a script reading it sees no change: +// map members sorted by key, a nil list or map as null, and <, >, &, U+2028 +// and U+2029 escaped. +var resultJSON = json.JoinOptions( + jsontext.WithIndent("\t"), + json.Deterministic(true), + json.FormatNilSliceAsNull(true), + json.FormatNilMapAsNull(true), + jsontext.EscapeForHTML(true), + jsontext.EscapeForJS(true), +) + func writeResult(cmd *cobra.Command, w io.Writer, result io.WriterTo) error { format, err := cmd.Flags().GetString("format") if err != nil { @@ -934,7 +948,7 @@ func writeResult(cmd *cobra.Command, w io.Writer, result io.WriterTo) error { } switch format { case "json": - buf, err := json.MarshalIndent(result, "", "\t") + buf, err := json.Marshal(result, resultJSON) if err != nil { return err } diff --git a/internal/cli/result_json_test.go b/internal/cli/result_json_test.go new file mode 100644 index 00000000..fe3c2edd --- /dev/null +++ b/internal/cli/result_json_test.go @@ -0,0 +1,60 @@ +package cli + +import ( + "bytes" + "fmt" + "io" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/typelate/muxt/internal/astgen" +) + +// jsonResult has the shapes a --format=json result is made of: a list and +// a map a command may leave nil, the import map a listing carries, and +// template source. +type jsonResult struct { + Names []string + Counts map[string]int + Imports *astgen.TypeFormatter + Source string +} + +func (jsonResult) WriteTo(io.Writer) (int64, error) { return 0, nil } + +// TestWriteResultJSON states that --format=json writes a result as +// encoding/json did before muxt moved to encoding/json/v2: map members, +// including a listing's imports, sorted by key on every run; a nil list or +// map as null; and <, >, & and U+2028 escaped. +func TestWriteResultJSON(t *testing.T) { + imports := astgen.NewTypeFormatter("example.com/server") + for i := range 16 { + imports.Imports[fmt.Sprintf("example.com/pkg%02d", 15-i)] = fmt.Sprintf("pkg%02d", 15-i) + } + result := jsonResult{Imports: imports, Source: "

    {{.A}} & {{.B}}

    
"} + + var want strings.Builder + want.WriteString("{\n\t\"Names\": null,\n\t\"Counts\": null,\n\t\"Imports\": {\n") + for i := range 16 { + separator := "," + if i == 15 { + separator = "" + } + fmt.Fprintf(&want, "\t\t\"example.com/pkg%02d\": \"pkg%02d\"%s\n", i, i, separator) + } + want.WriteString("\t},\n\t\"Source\": \"\\u003cp\\u003e{{.A}} \\u0026 {{.B}}\\u003c/p\\u003e\\u2028\"\n}\n") + + cmd := &cobra.Command{} + cmd.Flags().String("format", "json", "") + // Map iteration order changes run to run, so one lucky ordering must not + // pass. + for range 20 { + var got bytes.Buffer + require.NoError(t, writeResult(cmd, &got, result)) + assert.Equal(t, want.String(), got.String()) + } +} diff --git a/internal/mutation/report.go b/internal/mutation/report.go index f700342f..8ba304ec 100644 --- a/internal/mutation/report.go +++ b/internal/mutation/report.go @@ -44,7 +44,7 @@ type Result struct { Reason string `json:"reason,omitempty"` // Seconds is how long the mutant's test run took. - Seconds float64 `json:"seconds,omitempty"` + Seconds float64 `json:"seconds,omitzero"` // mutantIndex locates the mutant this result came from, so the run // does not have to carry the mutants inside the report it prints. @@ -107,7 +107,7 @@ type BaselineResult struct { // Seconds is how long the unmutated run took, which is what the // estimate for the whole run is built from. - Seconds float64 `json:"seconds,omitempty"` + Seconds float64 `json:"seconds,omitzero"` } // Report is the outcome of a whole mutation run. diff --git a/internal/mutation/runner.go b/internal/mutation/runner.go index 5175949f..405a4e34 100644 --- a/internal/mutation/runner.go +++ b/internal/mutation/runner.go @@ -1,7 +1,7 @@ package mutation import ( - "encoding/json" + "encoding/json/v2" "fmt" "io" "os" diff --git a/internal/mutation/runner_test.go b/internal/mutation/runner_test.go index fe84e741..dc4ccb10 100644 --- a/internal/mutation/runner_test.go +++ b/internal/mutation/runner_test.go @@ -1,7 +1,7 @@ package mutation import ( - "encoding/json" + "encoding/json/v2" "errors" "fmt" "os" From 41fd53a0d0f48ad8ac6c6a30f1e99033b5e372a1 Mon Sep 17 00:00:00 2001 From: Christopher Hunter <8398225+crhntr@users.noreply.github.com> Date: Wed, 16 Sep 2026 16:41:06 -0700 Subject: [PATCH 5/5] Say it with slices and maps A few loops spelled out what the slices and maps packages already name: gathering a map's keys and sorting them, asking whether a list holds something, copying a list, and a union of keys de-duplicated by hand. - slices.Sorted(maps.Keys(m)) where keys were gathered and then sorted: the module listing's directories, muxttest.Check's file names, and the template source files generation walks. sort is no longer imported anywhere in muxt's own code. - slices.ContainsFunc where a loop or an IndexFunc whose index went unused only asked whether something is there: marshalJSON refusing the execute callback, and a receiver method already in the interface. - slices.Clone for File.ImportSpecs' copy; its callers only range over the result or take its length. - sortedKeys in the generate snapshot harness appends every map's keys, sorts and compacts, rather than checking Contains before each append. Loops that find an element to change in place, build a different type, or return early with more than a yes or no stay as they were. Every generated file and report is unchanged. Assisted-by: Claude:claude-opus-5 gofumpt --- internal/analysis/module.go | 11 +++-------- internal/generate/file.go | 2 +- internal/generate/marshal_json.go | 9 +++++---- internal/generate/routes.go | 7 +++---- internal/generate/snapshot_test.go | 13 +++++-------- internal/muxt/muxttest/muxttest.go | 8 ++------ 6 files changed, 19 insertions(+), 31 deletions(-) diff --git a/internal/analysis/module.go b/internal/analysis/module.go index d1f56e1f..d4615a92 100644 --- a/internal/analysis/module.go +++ b/internal/analysis/module.go @@ -5,11 +5,12 @@ import ( "bytes" "encoding/json/v2" "io" + "maps" "os" "os/exec" "path/filepath" "regexp" - "sort" + "slices" "strings" "github.com/spf13/pflag" @@ -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) diff --git a/internal/generate/file.go b/internal/generate/file.go index 134dff69..855bcc27 100644 --- a/internal/generate/file.go +++ b/internal/generate/file.go @@ -62,7 +62,7 @@ func (file *File) Import(pkgIdent, pkgPath string) string { } func (file *File) ImportSpecs() []*ast.ImportSpec { - result := append(make([]*ast.ImportSpec, 0, len(file.importSpecs)), file.importSpecs...) + result := slices.Clone(file.importSpecs) slices.SortFunc(result, func(a, b *ast.ImportSpec) int { return strings.Compare(a.Path.Value, b.Path.Value) }) return slices.CompactFunc(result, func(a, b *ast.ImportSpec) bool { return a.Path.Value == b.Path.Value }) } diff --git a/internal/generate/marshal_json.go b/internal/generate/marshal_json.go index 4e40ecc1..c511b9a9 100644 --- a/internal/generate/marshal_json.go +++ b/internal/generate/marshal_json.go @@ -6,6 +6,7 @@ import ( "go/token" "go/types" "net/http" + "slices" "github.com/typelate/muxt/internal/astgen" "github.com/typelate/muxt/internal/muxt" @@ -19,10 +20,10 @@ import ( // result is written as application/json; on any recorded error the rendered // output is sent as the usual text/html fallback. func marshalJSONHandlerFunc(file *File, config RoutesFileConfiguration, def muxt.Definition, sig *types.Signature, resultDataIdent, receiverInterfaceName, bufIdent, statusCodeIdent string) (*ast.FuncLit, error) { - for _, arg := range def.Arguments { - if arg.Type == muxt.ArgumentTypeExecute && arg.Identifier == muxt.TemplateNameScopeIdentifierExecute { - return nil, fmt.Errorf("marshalJSON does not support the execute callback") - } + if slices.ContainsFunc(def.Arguments, func(arg muxt.Argument) bool { + return arg.Type == muxt.ArgumentTypeExecute && arg.Identifier == muxt.TemplateNameScopeIdentifierExecute + }) { + return nil, fmt.Errorf("marshalJSON does not support the execute callback") } return executeHTMLTemplateHandler(file, config, def, sig, resultDataIdent, receiverInterfaceName, bufIdent, statusCodeIdent, marshalJSONRespondStmts(file, resultDataIdent, bufIdent)...) } diff --git a/internal/generate/routes.go b/internal/generate/routes.go index 05ebfd37..50314be1 100644 --- a/internal/generate/routes.go +++ b/internal/generate/routes.go @@ -124,9 +124,8 @@ func TemplateRoutesFiles(wd string, config RoutesFileConfiguration, pkg source.P var ( receiverInterface = &ast.InterfaceType{Methods: new(ast.FieldList)} - templateSourceFiles = slices.Collect(maps.Keys(groups.byFile)) + templateSourceFiles = slices.Sorted(maps.Keys(groups.byFile)) ) - slices.Sort(templateSourceFiles) // Build main routes function routesFunc := &ast.FuncDecl{ @@ -332,9 +331,9 @@ func accumulateReceiverMethods(name string, sig *types.Signature, isMethod bool, if !isMethod { return nil } - if i := slices.IndexFunc(receiverInterface.Methods.List, func(field *ast.Field) bool { + if slices.ContainsFunc(receiverInterface.Methods.List, func(field *ast.Field) bool { return field.Names[0].Name == name - }); i >= 0 { + }) { return nil } exp, err := file.TypeASTExpression(sig) diff --git a/internal/generate/snapshot_test.go b/internal/generate/snapshot_test.go index c2f11092..2027f47f 100644 --- a/internal/generate/snapshot_test.go +++ b/internal/generate/snapshot_test.go @@ -8,6 +8,7 @@ import ( "go/parser" "go/token" "log" + "maps" "os" "path" "path/filepath" @@ -199,17 +200,13 @@ func writeSnapshot(t *testing.T, archivePath string, archive *txtar.Archive, got } } -func sortedKeys(maps ...map[string]string) []string { +func sortedKeys(ms ...map[string]string) []string { var keys []string - for _, m := range maps { - for key := range m { - if !slices.Contains(keys, key) { - keys = append(keys, key) - } - } + for _, m := range ms { + keys = slices.AppendSeq(keys, maps.Keys(m)) } slices.Sort(keys) - return keys + return slices.Compact(keys) } // unusedImports names the imports a generated file declares but does not diff --git a/internal/muxt/muxttest/muxttest.go b/internal/muxt/muxttest/muxttest.go index 86548d41..8053b220 100644 --- a/internal/muxt/muxttest/muxttest.go +++ b/internal/muxt/muxttest/muxttest.go @@ -20,8 +20,8 @@ import ( "go/parser" "go/token" "go/types" + "maps" "slices" - "sort" "testing" "github.com/typelate/muxt/internal/muxt" @@ -37,11 +37,7 @@ var FileSet = token.NewFileSet() // package is declared in it. func Check(t testing.TB, path string, files map[string]string) *types.Package { t.Helper() - names := make([]string, 0, len(files)) - for name := range files { - names = append(names, name) - } - sort.Strings(names) + names := slices.Sorted(maps.Keys(files)) syntax := make([]*ast.File, 0, len(names)) for _, name := range names { file, err := parser.ParseFile(FileSet, name, files[name], parser.SkipObjectResolution)