Skip to content

printf refuses an operand it cannot read as a number - #428

Merged
tobert merged 3 commits into
mainfrom
fix/printf-numeric-refusal
Sep 1, 2026
Merged

printf refuses an operand it cannot read as a number#428
tobert merged 3 commits into
mainfrom
fix/printf-numeric-refusal

Conversation

@tobert

@tobert tobert commented Aug 31, 2026

Copy link
Copy Markdown
Owner

AGENTS.md names printf '%d' 0xff printing 0 as the shape to refuse, and printf was printing it. The conversions read a value through s.parse().unwrap_or(0) and *f as i64, so 0xff, abc and 1e3 all answered 0, 1.5 answered 1, and %c of 4294967296 truncated through as u32 into NUL. 007 was the worst of them: it answered 7, a plausible number, while every other number position in kaish had just been taught that a leading zero is text.

printf now reads a number by JSON's rules, the same ones fromjson uses, so the answer does not depend on which builtin is asking. Each refusal names the spelling that works:

printf '%d' 0xff   → printf: `0xff` is not a number; write `255`, or `$(( 0xff ))` to read the base
printf '%d' -007   → printf: `-007` has a leading zero — kaish reads no octal; write `-8#7` for octal or `-7` for decimal
printf '%d' 0b101  → printf: `0b101` is not a kaish base spelling; write `2#101` for binary
printf '%d' 1e3    → 1000

A MISSING operand still converts as 0, which POSIX requires and which is not the fallback being removed; only a value that is present and unreadable refuses, and it refuses before any output so a caller never reads a half-written line as a whole answer.

The seam worth knowing about is FormatArg, which printf and awk share. They disagree here on purpose: in POSIX awk a non-numeric string IS 0, and that is awk being correct, not awk being unfixed. The trait's numeric conversions became fallible so each caller answers for itself — awk's impl is always Ok. Two tests pin awk's coercion as the control that the refusal did not leak across the trait.

A review of the first pass found the refusal had left one silent answer standing. printf '%d' '-9223372036854775809' printed -9223372036854775808 and exited 0: not an i64, so it fell to the JSON reader, which read it as f64, where it rounds to exactly i64::MIN, which the range guard then accepted — correctly, because i64::MIN is in range. Every step was locally right and the answer was wrong by one. printf now uses the same is_i64_overflow_shape guard value_to_num already had, refusing an integer-shaped operand before a float can round it.

That review also corrected four messages that named the wrong fix or none (+007 dodged the leading-zero rule entirely and answered 7), and two tests that were passing through the wrong code path. Nothing in the existing suite changed behavior: 6485 → 6514 tests, all additions.

This diverges from GNU for 0xff, which GNU reads as 255 — deliberate, and the same divergence $(( )) already documents. For abc GNU also errors.

Gates: clippy -D warnings, cargo test --all (204 suites, 6514 passed, 0 failed), insta --check, no-default-features, the WASI build, and rustdoc -D warnings all clean.

tobert and others added 3 commits August 31, 2026 17:50
AGENTS.md names `printf '%d' 0xff` printing `0` as the shape to refuse, and
printf was printing it. The conversions read a value through
`s.parse().unwrap_or(0)` and `*f as i64`, so `0xff`, `abc` and `1e3` all
answered `0`, `1.5` answered `1`, and `%c` of 4294967296 truncated through
`as u32` into NUL. `007` was the worst of them: it answered `7`, a plausible
number, while every other number position in kaish had just been taught that
a leading zero is text.

The rule printf now reads by is JSON's, the same one `fromjson` uses, so the
answer does not depend on which builtin is asking: `1e3` is a number, `0xff`
and `007` are not. Each refusal names the spelling that works — `255` or
`$(( 0xff ))` for a based numeral, `8#7` or `7` for a leading zero, with the
sign carried into both suggestions so a fix cannot flip the value.

The seam worth explaining is `FormatArg`. printf and awk share the format
engine, and they disagree about this on purpose: in POSIX awk a non-numeric
string IS 0, and that is awk being correct, not awk being unfixed. So the
trait's numeric conversions became fallible and each caller answers for
itself — awk's impl is always `Ok`, printf's refuses. Two tests pin awk's
coercion as the control that the refusal did not leak across the trait.

A MISSING operand still converts as 0, which POSIX requires and which is not
the fallback being removed here; only a value that is present and unreadable
refuses. The refusal happens before any output, so a caller never reads a
half-written line as a whole answer. This diverges from GNU for `0xff`, which
GNU reads as 255 — deliberate, and the same divergence `$(( ))` already
documents; for `abc` GNU also errors.

Gates: clippy -D warnings, cargo test --all (204 suites, 6501 passed; +16 new,
no existing test changed), insta --check, no-default-features, wasi build,
rustdoc -D warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`printf '%d' "$x"` with `x` unset is the common way an empty operand reaches
a number position, and the general refusal answered "`` is not a number" —
it quoted nothing, so it named nothing the reader could act on.

Refusing is the right answer and is not in question here: the arithmetic
rewrite already made an unset or empty operand an error rather than 0, and
printf reading it as 0 would put the two number positions back into
disagreement. Only the wording changes.

Also pins the two integer bounds, which the range guard's comparison against
2^63 exists to get right: `i64::MIN` is representable in f64 and must not be
refused, and `i64::MAX` must survive the i64 parse path rather than being
rounded up into the refusal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review found the refusal had left one silent answer standing, at the one
boundary where rounding hides it. `printf '%d' '-9223372036854775809'`
printed `-9223372036854775808` and exited 0: the operand is not an i64, so
it fell through to the JSON reader, which has no arbitrary precision and
read it as f64, where it rounds to exactly `i64::MIN`. The range guard then
accepted it, correctly, because `i64::MIN` IS in range. Every step was
locally right and the answer was wrong by one, which is the shape this whole
conversion exists to refuse.

The kernel already had the guard: `value_to_num` refuses an integer-shaped
string that failed its i64 parse before any float sees it, because overflow
is the only way that parse can fail for that shape. printf now makes the
same call through the same predicate, so `is_i64_overflow_shape` gains a
third caller and the three number positions name the 64-bit limit with one
sentence. The positive side already refused, but by a longer route; it now
gives the canonical message too.

The same review found four messages that named the wrong fix or none. A
leading `+` reached neither the leading-zero nor the overflow check, so
`+007` answered 7 where `007` refuses — the sign is now split off first,
with `-` carried into every suggestion and `+` dropped, since `+7` and `7`
are the same number but `-7` is not. `007.5` was offered `8#7.5`, which is
not a numeral in any base; a fractional value now gets only its decimal
spelling. `0b101` and `0o17` said only "is not a number" where the
arithmetic lexer names `2#101` and `8#17`, and now say the same thing it
does. `1e999` said "is not a number" when the problem is magnitude; it names
the range, while the word `inf` still does not, having no numeral in it.

Tests: the two bounds cases were passing through the typed-integer arm
rather than the reader under test, and every refusal case had an empty
buffer at the moment it refused, so none of them could have caught a partial
write. Both are pinned properly now — a literal and a good operand before
the bad one, and a refusal in a second cycling pass — along with the
negative boundary, the `+` spellings, the base spellings, and a
missing-operand default for every numeric conversion rather than `%d` alone.

Gates: clippy -D warnings, cargo test --all (204 suites, 6514 passed),
insta --check, no-default-features, wasi build, rustdoc -D warnings.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tobert
tobert merged commit 1f24b9e into main Sep 1, 2026
3 checks passed
@tobert
tobert deleted the fix/printf-numeric-refusal branch September 1, 2026 11:44
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