Migration: Go templates → write-style¶
Goal¶
Remove infra/template_engine.go so the surface DSL is one
language (Capy inner-DSL + write interpolation), not two.
Progress¶
This session migrated 40 sample libraries from raw Go-template
file_template: / per-function template: blocks to write-style
file_template ... end blocks. All migrated samples produce
byte-identical golden output. The full list of migrated libs is
visible in git log (feat(samples): write-style migration — batch
1..10).
Remaining work, broken into phases¶
Phase A — finish sample migration (~70 files)¶
Blocked on engine features (7 YAML libs use these; can't be hand-converted until the engine supports them):
| Lib | Blocker |
|---|---|
transpile-blog/lib.yaml |
{{ range $k, $v := .map }} map iteration |
transpile-systemd/lib.yaml |
same |
transpile-makefile/lib.yaml |
same |
transpile-gh-actions/lib.yaml |
{{ range $i, $t := .list }} index iteration |
transpile-xstate-machine/lib.yaml |
nested ranges + {{ $var := . }} capture |
interactive-breakout/lib.yaml |
large; many nested ranges |
interactive-snake/lib.yaml |
same |
Inner-DSL extensions needed before we can finish:
1. for k, v in MAP ... end — two-var iteration over maps.
2. for i, x in LIST ... end — index iteration over lists.
3. (Optional) let X = EXPR inside the renderer scope, to capture
outer-loop values from inner ones (xstate uses this).
Mechanical .capy migrations remaining (62 files still use
{{ ... }} syntax in their file_template: or per-function
template:). These can be converted by hand following the patterns
established in this session — see "Conversion patterns" below.
Phase B — engine swap¶
orchestrator/features/translate_new_shape.go currently
translates the inner-DSL write-style body into Go-template
source text, which is then handed to infra/template_engine.go.
This indirection is what keeps template_engine.go load-bearing
even after the surface migration completes.
To drop the engine entirely:
- Reimplement helpers as Go funcs callable from the inner
evaluator:
unquote,toQuoted,indent,pascalCase,toJSON,toJSONIndent,snakeCase,dasherize,upper,lower,trimSuffix,trimPrefix,join,split,len,add,sub,mul,percent,stars,camelCase,nonEmpty,unescape. (Seeinfra/template_engine.go'sfuncsmap for the full list.) - Replace
translateNewShape's "emit Go template syntax" path with a direct evaluator that walks the inner-DSL AST (WriteStmt,IfStmt,LoopStmt, etc.) and writes output directly to a buffer. - Delete
infra/template_engine.go,infra/template_engine_test.go,orchestrator/features/make_template_renderer.go, and alltplE := infra.TemplateEngine{}+MakeTemplateRendererwiring inapp.go,run.go,capy.go,orchestrator/features/make_evaluator.go.
Phase C — cleanup¶
Delete the legacy file_template: (colon form) parsing path from
infra/capy_lib_parser.go once no sample uses it. Same for the
legacy template: field on RawFunction once every function
declares its output via write in the function body.
Conversion patterns (used to migrate the 40 libs in this session)¶
File template body¶
file_template: |
<h1>{{ .context.title | unquote }}</h1>
{{ range .context.items }}- {{ . }}
{{ end }}
becomes
file_template
write `<h1>${unquote context.title}</h1>
`
for it in context.items
write `- ${it}
`
end
end
Per-function template¶
becomes (inside a function greet ... end block):
Multi-arm if/else if¶
Inner DSL supports else if chains natively:
{{- if eq .kind "cube" }}
geo = new BoxGeometry();
{{- else if eq .kind "sphere" }}
geo = new SphereGeometry();
{{- else }}
geo = new PlaneGeometry();
{{- end }}
becomes
if eq kind "cube"
write `geo = new BoxGeometry();
`
else if eq kind "sphere"
write `geo = new SphereGeometry();
`
else
write `geo = new PlaneGeometry();
`
end
Helpers as function-call interpolation¶
{{ .x | helper }} → ${helper x}. Helpers chain by nesting:
{{ .x | upper | snakeCase }} → ${snakeCase (upper x)}. (None
of the 40 libs in this session needed the chained form; all
were single-helper.)
Gotchas surfaced during migration¶
- Backslash-n inside backticks:
\nunescapes to a real newline. To emit a literal\n(e.g. forprintf "%s\n"in bash) write\\n. - Trailing newline matters: many libs' YAML
file_template: |style strips one trailing newline. The write-style equivalent must include the final\nexplicitly in the backtick if the golden expects one. optionsdeclaration: in .capy types, use positional stringsoptions "a" "b" "c", NOT a YAML-style listoptions ["a", "b", "c"]. The .capy parser tokenises the latter incorrectly.- Dotted function names like
scene.create_spherework fine infunction NAMEdeclarations. - Reserved-looking names:
function import,function if,function endall work — inside afunction NAMEblock the name is just a key, not a manifest-level directive.
Running total¶
| Status | Count |
|---|---|
| Migrated total | 67 / ~107 lib files |
Remaining .capy with {{}} (body) |
~15 |
| Remaining YAML | 2 (interactive-breakout, interactive-snake) |
template_engine.go deletable? |
No — still the rendering backend |
Engine improvements landed this session¶
Three gaps from the previous analysis are now closed:
- Two-var
for—for k, v in MAP(sorted keys) andfor i, x in LIST(with index). domain.LoopStmt.KeyVar threads through parser → translator → evaluator. - Chained / nested helpers in interpolations:
${a | upper | toQuoted}→toQuoted (upper .a)${toQuoted (upper a)}→toQuoted (upper .a)- Tokeniser keeps parenthesised groups as one atom; pipe form
rewritten to nested prefix calls (
splitInterpPipe). context.Xreferences the ROOT even when used inside a range. Translator emits$.context.Xnot.context.X. (Previously, nested loops looking up.context.fieldsagainst the iteration variable silently produced empty output.)
Plus one related fix:
- Brace adjacency escape — a literal
{that ended up adjacent (after expansion) to a${...}interpolation or a{{/}}escape would produce{{...in the output and trip Go template's lexer with "unexpected{in command". The translator now looks ahead vianextEmitStartsWithBraceand escapes the leading brace via{{"{"}}whenever there's a collision. Bug surfaced in transpile-websocket-server'sstruct{}{}patterns inside the Go output.
Inner-DSL gaps still blocking remaining libs¶
1. Two-var for — LANDED¶
forClosed this session. See "Engine improvements" above.
2. Chained / nested helper calls — LANDED¶
Closed this session. See "Engine improvements" above.
3. Arithmetic in expression positions (3+ libs)¶
Today: set context.x (add a b) → inner call "add" not allowed in
expression. add / sub / mul / percent exist only in template
helpers.
Needed: a small set of arithmetic primitives callable from inner-DSL expressions (not just templates).
Blockers:
- reading-log — {{ $total = add $total .pages }} accumulator
- Anything that wants to compute totals/percentages before rendering
4. State mutation inside file_template (1+ libs)¶
Today: file_template translator rejects residual set/append/let.
Needed: relax the rule, OR provide a let X = EXPR inside the renderer
that doesn't count as state mutation.
Blockers:
- lib-composition — separator-unless-first via a first flag
- Workaround for libs that need a small loop-local counter
5. Multi-file (file "X":) bodies (6 libs)¶
Today: legacy Go-template form parses fine. Write-style form
(\x00NEW_SHAPE\x00 sentinel + translation) works but each lib
has several big bodies that need converting.
Mechanical work, no engine blocker. Done last because each is large.
Blockers:
- android-app, ios-app — 6-7 file blocks each
- multi-file-project, libtorch-train — 5-6 file blocks
- backend-with-tests, webapp-trio — 3 each
6. transpile-threejs weirdness (1 lib)¶
Threejs file_template, when converted to write-style with nested
for / if eq m.kind "X" chains, produced state-mutation
statements aren't allowed in file_template despite none being
visible in source. Some statement inside the JS body string is
being parsed as a CallStmt. Needs investigation before threejs can
migrate.
Cleanest reproducer: take the migrated threejs lib (in git history at
the feat(samples/threejs) commit, reverted shortly after) and bisect
the file_template body to find the offending fragment.