$(( )) reads a number in any base and does checked 64-bit arithmetic - #419
Open
tobert wants to merge 15 commits into
Open
$(( )) reads a number in any base and does checked 64-bit arithmetic#419tobert wants to merge 15 commits into
tobert wants to merge 15 commits into
Conversation
The docs come first on this pass and the code will be built to them. $(( )) was the one place kaish could read a number in another base and it read none: $((0xff)) was an error, fromjson refused hex as invalid JSON, and printf '%d' 0xff printed 0. Users reached for sed or bc. The decision was to make $(( )) the home for bases rather than add a cast builtin or a --base flag: fromjson's contract is JSON, which has one number grammar, and bash already spells explicit bases as 0xff and base#digits. Leading-zero octal stays refused; the error now names 8#10 and 10#$x as the fixes, since a 5-model panel showed 4 of 5 writing base#$var and 2 of 5 tripping on a month from date +%m. The panel also settled two spellings: no model wrote 0b or 0o, so bash spellings are the only ones taught; two models wrote bare (( expr )) as a loop condition, so it is documented as a command. Divergences from bash are stated where they apply: overflow is an error, an unset variable is an error, an empty $(( )) is an error, a string is a value and never an expression, and a $(cmd) on the skipped side of && || ?: does not run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nce-climbing parser
The old $(( )) evaluator was a single recursive-descent parser mixing
lexing, precedence, and evaluation in one pass over the source text
with no bitwise, power, ternary, or base-reading support, and no
notion of a token span for error text. The new documentation for
$(( )) promises another-base reading (0x, base#digits, base#$var),
the full C precedence table through ?:, checked overflow on every
operator, and specific error text naming the exact fix. Retrofitting
all of that onto the old character-at-a-time parser would have meant
threading base and span state through every recursive call.
Split into three stages instead: tokenize() turns the raw text into
spanned tokens (numbers already validated in their base, $-expansions
already carrying pre-parsed statements or nested expressions,
`0b`/`0o`/`<<<`/`++`/`,` and the rest of the non-operators diagnosed
at the token boundary where the offending text is still in view), a
precedence-climbing parse() builds the ArithExpr tree with a 256-form
depth cap, and eval_sync() walks it with the checked i64 arithmetic
shared between binary and unary operators. Command substitution
inside $(( )) parses eagerly (Expansion::CommandSubst carries a
Vec<Stmt> already) but evaluates lazily — eval_sync errors loudly if
it actually reaches one, which only happens when a caller runs
arithmetic with no async evaluator available. The async path that
actually runs $(...) lands in a follow-up commit; this one keeps the
sync callers (interpreter/eval.rs, scheduler/pipeline.rs) working
exactly as before, since parse()+eval_sync() together are a drop-in
replacement for the old eval_arithmetic() signature.
Three pre-existing integration tests pinned the old error wording
("division by zero", "(leading zero)" on a bare literal, "overflow")
that the new spec text deliberately changed to "divides by zero", the
parenthesized form reserved for a variable's held value, and "does
not fit in a 64-bit integer" naming the actual limit. A fourth pinned
old permissive whitespace handling ($ COUNT expanding like $COUNT)
that the new tokenizer refuses on purpose, matching the same "no
whitespace inside a token" rule that already refused `16 # ff`.
find_cmd_subst_close in parser.rs is now pub(crate): the arithmetic
tokenizer reuses it verbatim to find where a $(...) operand closes,
rather than writing a second paren/quote-aware scanner.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…mmand $(( )) has always been sync-only: eval_arithmetic() takes a &Scope and returns i64 straight through, so a $(...) operand had nowhere to run a command from. The kernel's async evaluator (kernel.rs) already owns command execution, so this adds a boxed recursive async walk over the parsed ArithExpr tree — eval_arith_expr_async and eval_arith_expansion_async — that mirrors eval_expr_async's own Pin<Box<dyn Future>> recursion pattern for the same reason: the recursion is unbounded by the type system, so it can't live in a fixed-depth stack frame. Laziness (a $(...) on the unselected side of &&/||/?: must not run) falls out of the walk's own control flow rather than needing separate machinery: the short-circuit arms simply never call eval_arith_expr_async on the branch they don't take, so its $(...) never executes. Each leaf takes its own short scope read lock instead of one held across the whole walk, because Expansion::CommandSubst runs through execute_block_capturing, which takes its own scope lock internally — a lock held here across that await would deadlock. A tree with no $(...) anywhere (ArithExpr::contains_command_subst()) still takes the sync fast path under one lock. Building this surfaced a real bug in the based-expansion path (base#<expansion>, e.g. 10#$m): resolve_based_sync converted the expansion through the FULL arithmetic coercion first and then stringified the result, so a leading-zero variable (m="08") hit the leading-zero refusal before 10#$m ever got a chance to read it as decimal — defeating the one thing that form exists to fix. Rewritten as expansion_text_sync/eval_arith_expansion_text_async: the expansion's rendered VALUE, never re-coerced through the numeral rules. Also found while writing the integration tests: the tokenizer had no float/exponent detection at all, so $((1.5)) and $((1e3)) failed on the '.' or 'e' as "cannot start a value" instead of the documented "arithmetic is integer-only" — consume_digits (used for hex/based digit bodies, where an invalid letter IS an error) was also being used for the plain decimal run, where a trailing e/x/b/o needs to be a clean stop, not an error. Split into consume_decimal_digits for that case and added the float-shape check ahead of it. Bare (( expr )) as a command/condition is the sibling of [[ ]]: a new Stmt::Arith/Expr::Arith AST pair, parsed from a new ArithCond token the lexer's existing scanner produces the same way it already produces Arithmetic for $((( — extract_arithmetic now takes a marker_len (2 for bare (( vs 3 for $(() and an is_condition flag that ScanOutput.arithmetics carries through to marker resolution. Exit 0 when nonzero, 1 when zero; an evaluation fault (division by zero, an unset variable) is exit 2 with the arithmetic error as the message rather than aborting the statement list — a deliberate divergence from [[ ]]'s condition errors, which do abort, because a bare (( )) command has an exit-code channel a condition doesn't: in if/while position an evaluation fault still propagates hard, same as [[ ]] already does there. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two reviewers derived edge cases directly from the spec text — base
boundaries, the i64::MIN/MAX seam, precedence combinations, and
laziness beyond the $(...) case already covered — meant to catch
exactly the blind spots a fresh implementation has. Checking each
row against the spec (rather than trusting the row) before pinning
it found two real bugs and two rows that disagreed with the spec:
- i64::checked_rem returns None for MIN % -1, not Some(0) — it is
defined in terms of the division, which overflows, even though
division by a divisor of magnitude 1 never has a remainder for any
dividend. apply_binary's Rem arm now special-cases r == -1 before
calling checked_rem, since checked_rem is simply wrong on this input.
- 8#$(echo 17) as a based-expansion's $(...) operand failed to close
("$( has no closing )") whenever the substitution was followed by
more arithmetic text, e.g. $(( $(echo 17) + 1 )). The scanner
tokenized the raw remainder with the general kaish lexer to find
the matching ')' (the pattern parser.rs's own quoted-string
interpolation uses for the same job) — but text after the
substitution inside $(( )) is ARITHMETIC syntax ('+', '**', …),
which the general lexer doesn't tokenize outside $(( )) at all, so
it errored on the operators past the close before ever finding it.
Replaced with a character-level scan (paren depth plus single/
double-quote awareness, matching the risk profile $((' own
extract_arithmetic already accepts) that never asks the general
lexer to make sense of arithmetic text.
- digits=-ff; echo $((16#$digits)) — a reviewer expected the sign to
be refused the way 16#-ff (a literal sign after #) is. The spec's
coercion table says a based-expansion's text is "digits only
(optional sign)": the sign is part of the accepted VALUE text, a
different rule from a sign appearing in SOURCE after #. Pinned the
spec's answer (-255), noted in the test.
- echo $(( true + false )) — a reviewer expected 1 (true/false as
literals). The grammar has no keyword form inside $(( )): a bare
word is always `reference = identifier`, a variable name. true and
false are builtin commands in kaish, not auto-bound session
variables, so bare true is simply unset — pinned the spec's answer
(an error naming true as unset) over the reviewer's expectation.
Every other row (i64::MIN edges across every base, precedence towers,
sign+base variable coercion, the parens-break-the-unary-exception
case) already matched on the first run and needed no code change,
which is itself the useful signal: they're now pinned so a future
change that breaks one says so immediately instead of surfacing as a
one-off bug report.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Added and Changed bullets for the arithmetic rewrite — bases, the full operator set, (( )) as a command, and the deliberate bash divergences (overflow/unset/empty as errors, strings as values, lazy $(...) in a skipped branch). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
x=5; echo $((x++)) named a hardcoded "name" placeholder instead of
x, and the wrong fix shape for the compound and plain-assignment
forms: reject_compound_or built its message from the operator symbol
alone, never looking at the identifier the operator was actually
attached to. The tokenizer already emits x as its own Ident token
before seeing ++/+=/=, so the name is sitting right there in the
token stream (out, the vec of tokens already produced) by the time
the error fires - it was just never read. preceding_name reads it
for the postfix/compound/plain forms (x++, x += 2, x = 2);
consume_following_name scans forward for the prefix form (++x, where
the name has not been tokenized yet). Each error now quotes the real
matched source and substitutes the real name into the fix: x=$((x +
1)) for x++/++x, x=$((x - 1)) for x--/--x, x=$((x + rhs-source-text))
for x += rhs, and x=rhs, or `==` to compare for x = rhs.
$((1 + )) and $(( + )) both fell back to the same "$(( )) has no
expression" text the fully-empty $(( )) uses, which is misleading -
the expression is not empty, an operand is missing after a real
operator. left_assoc, parse_power's `**` handling, and parse_unary's
four prefix operators now check whether the operator they just
consumed was the last token, and if so raise a specific error naming
the operator and (for a binary operator, which has a left side to
show) the source text consumed so far: `+` has no right operand in
`1 + `; add an integer expression after `+`. A bare unary operator
with nothing after it (parsed with no left-hand context at all) gets
the sibling wording without the "right"/"in {expr}" clause: `+` has
no operand; add an integer expression after `+`. Parser now carries
the arithmetic source text alongside its token stream so these
messages can quote it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
$((08#17)) and $((010#5)) both read the base as decimal (8, 10) and ran, silently accepting a leading zero on the base itself - the 2..=36 range check never looked at how the base number was spelled, only its value. lex_number now checks the base's own source text for a leading zero before consuming '#', naming it: `08` is not a base spelling; write the base without a leading zero. d="-ff"; echo $((16#$d)) evaluated to -255. The literal form, 16#-ff, has always been refused (naming the fix -16#ff) because a sign after # puts the sign in the wrong place - it belongs outside the base prefix, negating the whole based number, not inside the digit run. based_value's coercion of an EXPANDED value never applied that same rule: it stripped a leading sign from the expansion's text and applied it, so the same "sign inside #" mistake succeeded whenever it arrived through a variable or $(...) instead of source text. There is one rule for the text after #, not two: digits only, whichever way they arrive. based_value now refuses a leading sign there too, and expansion_label names where the value came from for the message - `d` holds `-ff`; the digits after `#` take no sign; write `-16#ff` for a variable, `$(...)` printed `-ff`; ... for a command's output. (The coercion table's "digits only, optional sign" line describes a plain STRING's own coercion for a bare $((x)) - x="-ff" is still -255 - not a base#<expansion> operand; the two share `base#digits` syntax but are different rules, and conflating them was the earlier mistake.) The adversarial test this surfaced in now asserts the refusal instead of -255. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
read_arithmetic scanned $(( ))'s raw text for anything identifier-
shaped and called each one a variable read. That is wrong wherever an
identifier-looking substring isn't one: echo $((16#ff + 1)) reported
free_variables: ["ff"], and $((0xff)) would report ["xff"] the same
way - a base literal's own digits, not a variable. free_variables is
an embedder contract (kaijutsu reads it to decide what a script needs
before running it), so this was a wrong answer a consumer could act
on, not a cosmetic one.
Parses the text with the real arithmetic parser and walks the tree
instead: every Ref name (bare or $-prefixed) is a read, including a
bare subscript's root AND its index expression (xs[i] reads both xs
and i - Decision B, the index is itself arithmetic, unlike ${xs[i]},
where i is a literal key and only xs is read). A based literal's own
digits (16#ff, 0xff) contribute nothing - they were never a Ref to
begin with. base#$var still reads var; $?/$$ are excluded the same
way Expr::LastExitCode/CurrentPid already are elsewhere in this file,
and so is a bare digit name ($1) - a positional parameter, not a
session variable, matching Expr::Positional's exclusion. RANDOM and
SECONDS are ordinary bare names here: planning is static and cannot
know they will be unset at eval time, and a plan consumer may set
them, so they are reported like any other read, not special-cased
out. A $(...) operand's own reads ride along through collect_block,
the same walker a bare $(...) already goes through outside
arithmetic, so the two never drift apart by hand. A ${...}/${...:-...}
operand reuses parse_varpath + the existing read_path, the same path
${x} takes everywhere else in this file.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
consume_digits and consume_decimal_digits (arithmetic.rs:495,544)
both wrote loop { let Some(c) = self.peek() else { break }; ... },
which clippy::while_let_loop flags as a while-let loop wearing a
loop's clothes; rewritten as while let Some(c) = self.peek() { ... }
with no behavior change (the body is identical, just no longer
re-testing a break condition the while-let already expresses).
The sign-after-# diagnostic (arithmetic.rs:655) matched
matches!(self.peek(), Some('+') | Some('-')) and then called
self.peek().unwrap() to get the char back, which clippy::unwrap_used
denies even though the unwrap can't panic here - the guard makes it
provably safe, which is exactly the case the lint can't see and the
project's house rule doesn't special-case. Restructured so the value
is in hand from the match itself: if let Some(sign @ ('+' | '-')) =
self.peek() binds sign directly, no unwrap needed.
extract_arithmetic (lexer.rs:1974) had grown to 9 parameters as this
branch added marker_len and is_condition to support bare (( )) -
one over clippy::too_many_arguments' limit of 7. The three scanner
output buffers (out, arithmetics, replacements) it appends to are
always passed together at all three call sites, so they group
naturally: a new ScanBuffers<'a> struct holds all three behind one
&mut, dropping the signature to 7.
Rustdoc (-D warnings) failed separately: crates/kaish-kernel/src/
arithmetic.rs's module doc linked [`tokenize`], [`Tok`], [`parse`],
[`ArithExpr`], [`eval_sync`] - all private items, so `cargo doc`
(public docs only) can't resolve them. Swept every `///`/`//!` this
branch added (grep for `[\`` and `](` across every touched file) for
the same shape: a handful more in arithmetic.rs, kernel.rs,
interpreter/eval.rs, and ast/plan.rs linked private methods the same
way. None of those broke the gate (the containing items are private
too, so rustdoc never rendered them), but they were the same mistake
waiting to surface if visibility ever changed, so rewritten as plain
code spans throughout rather than left half-fixed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
value_to_arith's Float branch checked `*f > i64::MAX as f64`, but
i64::MAX (2^63 - 1) has no exact f64 representation this close to the
limit and rounds UP to 2^63 when cast — the same value 2^63 itself
is. A strict `>` against that rounded bound let a Float holding
exactly 2^63 through as in-range, and the saturating `as i64` cast
then silently answered i64::MAX instead of refusing. The suggested
round-trip fix (cast to i64, cast back, compare) turns out not to
catch this specific input either: i64::MAX and 2^63 collide to the
same f64 bit pattern near this magnitude (verified directly against
rustc), so the saturated i64::MAX round-trips right back to 2^63 and
looks equal. The actual fix is the boundary itself: compare against
the literal 9_223_372_036_854_775_808.0 (2^63, exactly representable)
with `>=` instead of `>` against the rounded i64::MAX. Any float this
large cannot distinguish i64::MAX from one past it, so refusing the
whole ambiguous boundary is correct, not just convenient — there is
no way to recover which one the caller meant.
contains_command_subst treated Expansion::BracedDefault as never
containing a `$(...)`, so `${x:-$(cmd)}` inside `$(( ))` picked the
sync fast path and failed with "needs the async evaluator" - the
default's text is unparsed at that point, and the check never looked
inside it. expansion_has now parses the default and asks the same
question of the result. That surfaced a second bug once the async
path was actually reached: BracedDefault's fallback evaluated the
default text as a full arithmetic expression unconditionally, so
`${m:-$(echo 08)}` used as a base#<expansion> operand ran the command
through the same leading-zero refusal a bare arithmetic operand gets
- defeating the whole point of base# reading raw digit text. A
default that is itself a single expansion now stays in TEXT mode
(matching `expansion_text_sync`'s existing `$var`/`$(...)` handling)
and only gets evaluated as a real expression when it actually
contains operators (`${x:-1 + 2}`).
needs_async's message named itself ("`$(...)` needs the async
evaluator") - an internal detail, not something a user should read.
The fix above makes it unreachable from every path this crate wires
up (contains_command_subst now routes anything holding a `$(...)`,
including inside a default, to the async walker before eval_sync ever
runs); it stays reachable only if some future caller invokes eval_sync
directly on such a tree without that check. Reworded to match
EvalError::NoExecutor's existing wording for the identical situation
elsewhere in the interpreter, with a comment explaining why it should
no longer fire.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Coverage gaps review found: 0 ** 0 = 1, 0 ** 5 = 0, 1 ** 999 = 1, and (-1) ** 3 = -1 with and without parens (unary binds tighter than **, same rule -2 ** 2 = 4 already pins) — all already correct, now pinned. 2 ** 4294967296 (one past the u32 exponent cap) already named the 64-bit limit. A Value::Bytes variable (head -c off the synthetic /dev/urandom, no localfs/subprocess feature needed) already refused — coercion_list_record_bytes claimed to cover this in its own name and didn't. Bare (( $(echo 3) > 2 )) already exits 0 - the condition form needed the same async-path fix $(( )) as a value already got. Corrected a false claim in read_arithmetic's doc comment: it said an unparsable arithmetic body could not reach a plan because the statement's own parse would have already failed. It doesn't - the shell parser and validator both defer arithmetic to runtime, so a syntactically-valid statement with a broken $(( )) body reaches the plan walk fine. Corrected to say what actually happens: no free variables from that expression, and the statement itself fails loudly when it runs. Trimmed three comments per CLAUDE.md's no-narrative rule: the $(...) scan's history (why it isn't a re-tokenize) down to the rule itself; the Rem/checked_rem rationale down to one line; and preceding_name's doc comment, which had picked up two sentences that belong to reject_compound_or (already stated correctly on reject_compound_or itself) from an earlier round's edit. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`coercion_list_record_bytes` reached the `Value::Bytes` arm by capturing `$(head -c 4 /dev/urandom)`, on the theory that four random bytes are not valid UTF-8. They are, often enough: the capture then binds a String and the error reads "which is not a number" instead of naming bytes. Twelve runs of the command produced one such miss, so the test failed roughly once in twelve — it went red once during this branch's own verification while nothing in the tree had changed. No shell command in kaish produces Bytes deterministically: `printf` does not interpret `\xff` as a raw byte, and `/dev/zero` decodes to a String of NULs. The arm is reachable without a shell at all, so the assertion moves to the unit tests beside the list and record cases, where `err_with` sets `x` to `Value::Bytes` directly. The integration test keeps list and record and says in a comment why bytes is not tested there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`base#digits` range-checked the base by narrowing `base_mag: u64` to `u32` with `as u32`, then testing the narrowed value against 2..=36. For any base of the form k*2^32 + b with b in 2..=36, the truncation drops the high bits and the narrowed value passes the check — kaish then evaluated the literal in base b instead of refusing it. Verified on the built binary before the fix: `$((4294967298#10))` printed `2`, `$((4294967330#10))` printed `34`, `$((8589934594#10))` printed `2` again — silent wrong answers, the exact class kaish refuses. The error text on the next line already named the true `base_mag`, so the full-value check was clearly intended and just ran after the damage. Fix: range-check `base_mag` (the u64) against 2..=36 before narrowing. The same tokenizer path backs the typed literal, `base#$VAR` expansion, and string-variable coercion, so one fix covers all three; tests added for each. While in the area: `read_numeral` (the string-to-number coercion behind a variable held as arithmetic, e.g. `x=0b101; echo $((x))`) caught every tokenizer `Err` and flattened it to a generic "is not a number", discarding a fix the tokenizer had already worked out — `0b101` names `2#101`, `1_000` names the `_` to remove, `1e3` names kaish's integer-only rule, and none of that reached the user. Genuine non-numerals like `abc` have no tokenizer fix to lose and keep the generic message unchanged. `Numeral` gained a `NotANumberWithFix` variant carrying the tokenizer's message, and `parse_numeric_string` now composes it with the variable's name and value instead of discarding it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Scatter/gather's reduced sync arg binder handles a bare `$(...)` flag
value correctly: `scatter --limit $(echo 2)` fails with a message
naming the fix ("assign it to a variable first"). But when a `$(...)`
sits inside a `$((...))` instead (`--limit $(( $(echo 2) ))`), the
same binder called `arithmetic::eval_sync` directly, without first
checking whether the parsed expression could even reach a command
substitution. `eval_sync` refuses that shape with its own internal
message ("`$(...)` must be resolved by the async evaluator before
sync evaluation"), written for a caller that is expected to have
already routed around it — never meant to reach a user, and it did:
`printf "a\nb\n" | scatter --as H --limit $(( $(echo 2) )) | echo
"$H" | gather` printed that sentence verbatim as `scatter: arithmetic
error: ...`.
Fix: both arithmetic arms (the bare `Expr::Arithmetic` flag value and
the quoted `StringPart::Arithmetic` interpolated value) now parse the
expression, check `contains_command_subst`, and report the same
message their sibling bare-`$(...)` arm already gives, so the two
spellings behave alike. The messages were lifted into two shared
helpers (`command_subst_flag_value_message`,
`command_subst_interpolated_value_message`) reused by both the
arithmetic and the plain command-substitution arms, rather than
writing the text a second time.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Propagating the tokenizer's error through a variable (the previous commit) made a pre-existing truncation visible: `x="1_000"` reported "`1_` contains `_`", quoting only as far as the offending character. The typed form had always done the same — `$((1_000))` said `1_`, and `$((12_345_6))` said `12_`. Both underscore sites sliced the literal at `self.pos + 1`, the scan position, rather than at the end of the numeral. `numeral_run_end` walks the rest of the run so the message names what was written. The same slice appeared in the two INTEGER_OUT_OF_RANGE messages in the same functions, and is corrected with it: `16#ffffffffffffffffff` was reported one digit short, because the scan stops on the digit that overflows. A refused value is quoted whole, whatever the reason for the refusal. The old test asserted only that the message contained an underscore, so it passed against `1_`. It now pins the whole literal for the decimal, multi-group, and based spellings. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
$(( ))could not read a number in any base but decimal:$((0xff))was an error andprintf '%d' 0xffa silent 0, so users reached forsedorbc. The evaluator was a character-walking parser with+ - * / %and six comparisons — no bitwise, no**, no ternary — and it read an unset variable as 0.This is a clean-sheet rewrite: a tokenizer, a precedence-climbing parser to a small AST, and a checked i64 evaluator. The docs were written first (
docs/LANGUAGE.md, "Arithmetic") and the code was built to them.Where bash gives a wrong or hidden answer, kaish refuses and names the fix:
$((010))names8#10and10; an unset variable names${x:-0};$RANDOMnames$(random --max 100);x++namesx=$((x + 1)). Overflow,1 << 64, an empty$(( )), and a float are errors, never a wrapped or truncated number. A string is a value, never an expression. A$(cmd)on the skipped side of&&,||, or? :does not run.A plan document's
free_variablesnow come from the parsed expression, so16#ffno longer reportsffas a variable.143 new tests and no test removed: the 32 that covered the old evaluator all still pass. 47 of them are adversarial cases derived from the specification, which caught
MIN % -1,$(...)scanning, andbase#$varcoercion.🤖 Generated with Claude Code