Skip to content

fix: fmt rejects printf-style format specs instead of silently passing them through#297

Merged
danieljohnmorris merged 6 commits into
mainfrom
fix/fmt-format-spec
May 16, 2026
Merged

fix: fmt rejects printf-style format specs instead of silently passing them through#297
danieljohnmorris merged 6 commits into
mainfrom
fix/fmt-format-spec

Conversation

@danieljohnmorris
Copy link
Copy Markdown
Collaborator

Summary

fmt parsed {} as a placeholder but every richer {...} sequence fell through char-by-char as literal text, so fmt "{:06d}" 42 returned the string "{:06d}". Every persona reaching for Python-style format specs (zero-pad sort keys, decimal precision, etc.) got silent wrong output and minutes of debugging.

Manifesto framing: a silent wrong string corrupts every downstream token in the program; an explicit error costs one retry. Composing existing builtins (fmt2 for precision, padl for padding) keeps the surface area small.

Repro before/after

$ echo 'main>t;fmt "x={:06d}" 42' | ilo -

Before: x={:06d} (silent wrong output)

After:

ILO-T013: 'fmt' only supports bare `{}` placeholders, got `{:06d}`
hint: for decimal precision use `fmt "...{}" (fmt2 v 2)`; for width / padding use `padl (str n) 6` (space-pad)

What's in the diff (per commit)

  1. interpreter: runtime rejection (ILO-R009) for {:...} specs. Single source of truth for tree, VM (via tree-bridge), and Cranelift.
  2. verify: literal-template fast path (ILO-T013) so the dominant case (typed-in templates) errors at parse time, not runtime.
  3. vm: jit_call_builtin_tree surfaces Fmt bridge errors through JIT_RUNTIME_ERROR so Cranelift renders the same diagnostic as tree/VM. Scoped narrowly to Builtin::Fmt to preserve the surrounding nil-sweep contract.
  4. test: 15 cross-engine cases across tree/VM/Cranelift, covering literal + computed templates, bare {} regression guard, and lone { JSON-like text.
  5. example: examples/fmt-format-spec.ilo shows the idiomatic substitutes so future agents see the supported shape.
  6. docs: SPEC.md, ai.txt, SKILL.md all spell out the bare-{} contract.

Test plan

  • cargo build --release --features cranelift (clean)
  • cargo fmt --check (clean)
  • cargo clippy --release --features cranelift --all-targets -- -D warnings (clean)
  • cargo test --release --features cranelift --test regression_fmt_format_spec (15/15)
  • cargo test --release --features cranelift --test examples_engines (1131/1131)
  • cargo test --release --features cranelift (full suite, all 127 binaries green)
  • Manual repro before/after on --run-tree, --run-vm, --run-cranelift

Follow-ups

  • Wider promotion of the bridge's error channel for every tree-bridge builtin (currently scoped to Fmt) is on the nil-sweep roadmap, not blocking here.
  • padl accepting an explicit pad char (padl s w c) is the natural way to express zero-pad and was noted as #padl-padchar in the assessment doc; deferred to its own PR.

@codecov
Copy link
Copy Markdown

codecov Bot commented May 16, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ All tests successful. No failed tests found.

📢 Thoughts on this report? Let us know!

@danieljohnmorris danieljohnmorris force-pushed the fix/fmt-format-spec branch 3 times, most recently from 5a90e6f to 37da4be Compare May 16, 2026 10:16
fmt's placeholder scanner matched only the exact pair `{}` and let every
other `{...}` sequence fall through as literal text. So `fmt "{:06d}" 42`
returned the string `"{:06d}"` rather than `"000042"`, with no warning.
Personas reaching for Python format specs got silent wrong output and
debugging time.

Now `{:...}` raises ILO-R009 from the interpreter, with a hint pointing
at `fmt2 v 2` for decimal precision and `padl (str n) 6` for padding.
Single source of truth for tree, VM, and Cranelift (via the tree-bridge).
When the template is a string literal we can catch the unsupported spec
at verify time, before the program ever runs. This is the dominant case
(personas type the format string directly), so failing early saves the
compile/run round-trip and surfaces the hint at the call site.

Mirrors the runtime error message and hint, so the diagnostic is the
same whether the verifier or the interpreter catches it.
jit_call_builtin_tree swallows every bridge RuntimeError as TAG_NIL by
design, deferring typed propagation to the nil-sweep series. That leaves
Cranelift diverging from tree and VM: tree/VM raise ILO-R009 on
`fmt "{:06d}" 42`, Cranelift silently returns nil and the program
continues with corrupt data.

Scope this narrowly to `Builtin::Fmt` so the surrounding nil-sweep
contract is preserved. Cranelift now sets JIT_RUNTIME_ERROR on a fmt
error and renders the same diagnostic as tree/VM. Wider promotion of
the bridge's error channel stays on the nil-sweep roadmap.
15 cases across tree, VM, and Cranelift covering:
  - verify-time ILO-T013 on literal `{:06d}` and `{:.3f}` templates
  - runtime ILO-R009 when the template is computed at runtime
  - bare `{}` still works
  - lone `{` followed by non-`:` non-`}` stays a literal (JSON-like text)
Shows the two idiomatic shapes for callers who arrived at fmt looking
for printf-style specs:
  fmt "...{}" (fmt2 v 2)   -- decimal precision
  padl (str n) 6           -- width / padding (space-pad)

Exercised across every engine by tests/examples_engines.rs.
Each surface that documents fmt now notes the bare-`{}`-only contract
and points at the supported substitutes (`fmt2` for decimal precision,
`padl` for padding). Stops future agents from re-discovering the same
silent-wrong-output footgun.
@danieljohnmorris danieljohnmorris merged commit 1d073fe into main May 16, 2026
5 checks passed
@danieljohnmorris danieljohnmorris deleted the fix/fmt-format-spec branch May 16, 2026 11:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant