Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c4db140
fix: preserve source text for non-canonical numeral argv words
tobert Aug 24, 2026
b9434a9
lexer: NumericLiteralData was standing inside HereDocData's docs
tobert Aug 24, 2026
faf9992
lexer: a leading zero is not a number, it is a string
tobert Aug 24, 2026
39dcf8d
kernel: ToolArgs gets its own raw-text field for builtin argv
tobert Aug 24, 2026
0306daf
docs: teach the seam between JSON number rules and quoting
tobert Aug 24, 2026
1cdbffb
Merge remote-tracking branch 'origin/main' into fix/dash-zero-render
tobert Aug 24, 2026
08c3f90
Merge remote-tracking branch 'origin/main' into fix/dash-zero-render
tobert Aug 25, 2026
4fe53eb
Where kaish needs a number, a leading zero is an error
tobert Aug 25, 2026
8bc548a
docs: teach the second half of the leading-zero rule
tobert Aug 25, 2026
48b6755
A slice bound is a number position too
tobert Aug 25, 2026
47b24ac
The count message was answering for errors it never judged
tobert Aug 25, 2026
3c09780
Cut the leading-zero comments to the house length
tobert Aug 27, 2026
192e723
Name a private item, do not link it
tobert Aug 27, 2026
2db38b5
A negative-zero loop count still parses
tobert Aug 27, 2026
22719d1
exec hands over the same argv as the direct spelling
tobert Aug 27, 2026
6d758ed
A numeric comparison is a number position too
tobert Aug 27, 2026
4273c3f
An integer past 64 bits names the limit and the fix
tobert Aug 27, 2026
d732e27
Retire two shell_compat pins the refusal made unrunnable
tobert Aug 27, 2026
0c9a38e
A leading-zero numeral is text before it is an overflow
tobert Aug 27, 2026
0997086
Arithmetic overflow names the same 64-bit limit the lexer does
tobert Aug 27, 2026
cae09fd
Pin -0's argv-vs-variable split with a test
tobert Aug 27, 2026
d3a02db
break's leading-zero recovery no longer suggests a fraction
tobert Aug 27, 2026
a691a55
Split an over-length changelog bullet in two
tobert Aug 27, 2026
5a8ee0f
value_to_num no longer rounds an overflowing string through f64
tobert Aug 27, 2026
fca9175
test's leading-zero operand is refused, not read as decimal
tobert Aug 27, 2026
dabcfa5
A numeric operand is a JSON number: no inf, no nan, and overflow
tobert Aug 27, 2026
eb443d2
Two doc comments sat on the wrong item
tobert Aug 27, 2026
0ac6f7f
A third misattached doc comment, found sweeping the same file
tobert Aug 27, 2026
4ac9c00
Merge remote-tracking branch 'origin/main' into fix/dash-zero-render
tobert Aug 27, 2026
6b2bdc9
The validator reads a numeral by its value, not its spelling
tobert Aug 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,41 @@ breaking entries are marked **BREAKING**.
parameter's aliases. `help kj` and every wrapped command showed "No
parameters." before.

- **A numeral argv word like `-0` or `007` lost its sign or leading zeros**
when re-serialized from its typed value; `xargs -0 rm -f` ran as
`xargs 0 rm -f`. External-command argv and `plan`/`--plan` now keep the
source text exactly.

- **`007` is now the string `007`, not the number 7** — a leading zero is
not a JSON number, and `fromjson` already refused it. `-0`, `0.10`, and
`1.0` are unaffected; they stay numbers.

- **Where kaish needs a number, a leading zero is an error** — `break 007`,
`$((010 + 1))`, `[[ 010 -eq 10 ]]`, and a list index name the number to
write; kaish reads no octal.

- **An integer past 64 bits names the limit and the fix** instead of
"invalid number", in both a bare numeral and `$(( ))`.

- **Arithmetic refuses a leading zero however it arrives** — `$((010 + 1))`
and `x=010; $((x))` both name the decimal to write. Reading the text as
decimal answered 10 where bash answers 8.

- **A record key like `"007"` could not be read back** — `${r[007]}` resolved
to index 7 and `r[007]=v` was a parse error. Read and write now classify
every numeral subscript alike, including `[-0]` and `[1.0]`.

- **A slice bound with a leading zero was silent** — `${xs[007:2]}` sliced
from 7, inverted the range, and returned an empty list with exit 0.

- **`echo hi > -0` created a file named `0`** while `--plan` reported the
target as `-0`. Redirect targets keep the source text, so a plan document
and the run it describes agree.

- **A builtin's argv lost a numeral's exact text too** — `echo -0` printed
`0`, `echo 0.10` printed `0.1`. `echo`, function calls, and script
`$1`/`$2` now keep the source word, matching the external-command fix
above.
- **File tests on virtual and real paths** — `-w` claimed read-only mounts and
root-owned files were writable, and `-x` denied that a memory-backed
directory is searchable. `-w`/`-r`/`-x` now answer from the owning mount plus
Expand Down
27 changes: 27 additions & 0 deletions crates/kaish-help/content/en/syntax.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,33 @@ PI=3.14159 # float
ENABLED=true # boolean (only true/false)
```

## Numbers

```sh
echo 007 # the string 007 — a leading zero is not a JSON number
chmod 0644 f # the string 0644 — a mode keeps every digit you typed
echo -0 # the number 0 — valid JSON; -0 and 0 are the same number
echo "-0" # the string -0 — quote it to keep those two characters
break 7 # a loop count is a number — `break 007` is an error
```

`007`, `010`, and `00` are strings — `fromjson '007'` already refuses them
as invalid JSON, and a bare number now agrees. `-0`, `0.10`, and `1.0` are
numbers; kaish prints back the word you typed wherever it crosses into argv
or a plan. Once the number moves through a variable, arithmetic, or
`--json`, it is a plain typed number again: `x=-0; echo $x` prints `0`.
Quote a number to keep it a string on purpose.

Where kaish needs a number, a leading zero is an error that names the
number to write: a `break`/`continue` count, arithmetic, a numeric
comparison, and a list index. kaish reads no octal, so `$((010 + 1))` is
an error rather than 9 (bash's answer) or 11 (the decimal one). A record
key is text, so `${r[007]}` reads the `"007"` key.

A bare integer must fit in 64 bits (`-9223372036854775808` to
`9223372036854775807`); a longer numeral is an error naming the limit —
quote it to keep the text.

## Expansion

```sh
Expand Down
28 changes: 28 additions & 0 deletions crates/kaish-help/src/fragments.rs
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,34 @@ COUNT=42 # integer
PI=3.14159 # float
ENABLED=true # boolean (only true/false)
```"#,
),
syntax_section(
"numbers",
"Numbers",
r#"```sh
echo 007 # the string 007 — a leading zero is not a JSON number
chmod 0644 f # the string 0644 — a mode keeps every digit you typed
echo -0 # the number 0 — valid JSON; -0 and 0 are the same number
echo "-0" # the string -0 — quote it to keep those two characters
break 7 # a loop count is a number — `break 007` is an error
```

`007`, `010`, and `00` are strings — `fromjson '007'` already refuses them
as invalid JSON, and a bare number now agrees. `-0`, `0.10`, and `1.0` are
numbers; kaish prints back the word you typed wherever it crosses into argv
or a plan. Once the number moves through a variable, arithmetic, or
`--json`, it is a plain typed number again: `x=-0; echo $x` prints `0`.
Quote a number to keep it a string on purpose.

Where kaish needs a number, a leading zero is an error that names the
number to write: a `break`/`continue` count, arithmetic, a numeric
comparison, and a list index. kaish reads no octal, so `$((010 + 1))` is
an error rather than 9 (bash's answer) or 11 (the decimal one). A record
key is text, so `${r[007]}` reads the `"007"` key.

A bare integer must fit in 64 bits (`-9223372036854775808` to
`9223372036854775807`); a longer numeral is an error naming the limit —
quote it to keep the text."#,
),
syntax_section(
"expansion",
Expand Down
42 changes: 40 additions & 2 deletions crates/kaish-kernel/src/arithmetic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,19 @@ pub fn eval_arithmetic(expr: &str, scope: &Scope) -> Result<i64> {
Ok(result)
}

/// The decimal a leading-zero numeral was probably meant to be — `010` becomes
/// `10`, `-007` becomes `-7`. `None` when the text is not one.
///
/// The suggestion keeps the sign: `-007` is not fixed by writing `7`.
pub(crate) fn leading_zero_decimal(text: &str) -> Option<String> {
if !crate::lexer::is_leading_zero_numeral(text) {
return None;
}
let sign = if text.starts_with('-') { "-" } else { "" };
let digits = text.trim_start_matches('-').trim_start_matches('0');
Some(format!("{sign}{}", if digits.is_empty() { "0" } else { digits }))
}

/// Simple recursive descent parser for arithmetic expressions.
struct ArithParser<'a> {
input: &'a str,
Expand Down Expand Up @@ -311,7 +324,19 @@ impl<'a> ArithParser<'a> {
}
}
let num_str = &self.input[start..self.pos];
num_str.parse().context("invalid number in arithmetic expression")
// bash reads `010` as octal and answers 9; decimal would answer 11.
// Refuse rather than answer a third number.
if crate::lexer::is_leading_zero_numeral(num_str) {
let trimmed = num_str.trim_start_matches('0');
let trimmed = if trimmed.is_empty() { "0" } else { trimmed };
bail!(
"`{num_str}` is text (leading zero) and kaish reads no octal — write \
`{trimmed}` for the decimal value"
);
}
// The loop above admits only `[0-9]+`, so overflow is the only way
// this parse fails — same limit the lexer names, same words.
num_str.parse().context(crate::lexer::INTEGER_OUT_OF_RANGE)
}

fn parse_identifier(&mut self) -> Result<String> {
Expand All @@ -335,6 +360,12 @@ impl<'a> ArithParser<'a> {
// Name is just the digits when called from `$1` or `${1}` parsing
if let Ok(index) = name.parse::<usize>() {
if let Some(pos_val) = self.scope.get_positional(index) {
if let Some(decimal) = leading_zero_decimal(pos_val) {
anyhow::bail!(
"${index} holds `{pos_val}`, which is text (leading zero) — kaish reads \
no octal; write `{decimal}` for the decimal value"
);
}
return pos_val.parse().with_context(|| {
format!("${} has non-numeric value: {:?}", index, pos_val)
});
Expand Down Expand Up @@ -427,7 +458,14 @@ impl<'a> ArithParser<'a> {
match value {
Value::Int(n) => Ok(*n),
Value::String(s) => {
// Try to parse string as integer
// `x=007` stores the string, and reading it back is a number
// position like any other.
if let Some(decimal) = leading_zero_decimal(s) {
anyhow::bail!(
"variable '{name}' holds `{s}`, which is text (leading zero) — kaish \
reads no octal; write `{decimal}` for the decimal value"
);
}
s.parse().with_context(|| format!(
"variable '{}' has non-numeric value: {:?}", name, s
))
Expand Down
4 changes: 4 additions & 0 deletions crates/kaish-kernel/src/ast/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -537,6 +537,7 @@ fn collect_expr<'a>(expr: &'a Expr, background: bool, out: &mut Collected<'a>) {
// Special forms ($1, $@, $#, $?, $$) are not session variables; an
// embedder cannot peek them with `get_var`, so they are not listed.
Expr::Literal(_)
| Expr::NumericLiteral { .. }
| Expr::Positional(_)
| Expr::AllArgs
| Expr::ArgCount
Expand Down Expand Up @@ -896,6 +897,9 @@ pub(crate) fn render_expr(expr: &Expr) -> String {
format!("${{{}:-{}}}", render_varpath(path), render_parts(default))
}
Expr::Arithmetic(e) => format!("$(({e}))"),
// Render the source text, not `value`'s canonical form — that is what
// this variant is for.
Expr::NumericLiteral { raw, .. } => raw.clone(),
Expr::Command(cmd) => render_command(cmd),
Expr::LastExitCode => "$?".to_string(),
Expr::CurrentPid => "$$".to_string(),
Expand Down
3 changes: 3 additions & 0 deletions crates/kaish-kernel/src/ast/sexpr.rs
Original file line number Diff line number Diff line change
Expand Up @@ -272,6 +272,9 @@ pub fn format_expr(expr: &Expr) -> String {
match expr {
Expr::Not(inner) => format!("(not {})", format_expr(inner)),
Expr::Literal(value) => format_value(value),
Expr::NumericLiteral { value, raw } => {
format!("(numeric-literal {} raw={:?})", format_value(value), raw)
}
Expr::VarRef(path) => format!("(varref {})", format_varpath(path)),
Expr::Interpolated(parts) => {
let parts_str: Vec<String> = parts
Expand Down
11 changes: 11 additions & 0 deletions crates/kaish-kernel/src/ast/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,17 @@ pub enum Expr {
/// Record literal: `{name: amy, role: maintainer}`, `{port:8080}` (colon
/// may be spaced or unspaced). Value-position only, same as `ListLiteral`.
RecordLiteral(Vec<RecordEntry>),
/// A numeral whose own `Display` does not reproduce the source text it
/// was written as — `-0`, `0.10`, `1.0`. `value` is the typed value:
/// arithmetic, comparisons, `set x = -0`, and `--json` all see a real
/// `Int`/`Float`. `raw` is the exact source text, used by argv and plan
/// rendering, so `xargs -0 rm -f` keeps its `-0`.
///
/// A canonical numeral (`-1`, `42`) stays a plain `Literal`, and a leading
/// zero (`007`) is a `Literal(String)` — see `lexer::Token::NumericLiteral`,
/// which this mirrors, and `docs/LANGUAGE.md`, "A bare number follows JSON
/// rules".
NumericLiteral { value: Value, raw: String },
}

/// One element of a list literal.
Expand Down
3 changes: 3 additions & 0 deletions crates/kaish-kernel/src/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,9 @@ impl BackendDispatcher {
Expr::Literal(Value::String(s)) => argv.push(s.clone()),
Expr::Literal(Value::Int(i)) => argv.push(i.to_string()),
Expr::Literal(Value::Float(f)) => argv.push(f.to_string()),
// Kept in sync with kernel.rs::build_args_flat, which
// pushes `raw` for the same reason.
Expr::NumericLiteral { raw, .. } => argv.push(raw.clone()),
Expr::VarRef(path) => {
if let Ok(v) = ctx.scope.resolve_path(path) {
if let Some(msg) = crate::interpreter::structured_boundary_error("a command argument", &v) {
Expand Down
71 changes: 62 additions & 9 deletions crates/kaish-kernel/src/interpreter/eval.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,9 @@ impl<'a> Evaluator<'a> {
// condition holds no command substitution.
Expr::Not(inner) => Ok(Value::Bool(!is_truthy(&self.eval(inner)?))),
Expr::Literal(value) => self.eval_literal(value),
// Typed evaluation only needs `value`. `raw` is for argv and plan
// text sinks, which read the `Expr` directly.
Expr::NumericLiteral { value, .. } => self.eval_literal(value),
Expr::VarRef(path) => self.eval_var_ref(path),
Expr::Interpolated(parts) => self.eval_interpolated(parts),
Expr::HereDocBody { parts, strip_tabs } => {
Expand Down Expand Up @@ -600,7 +603,6 @@ impl<'a> Evaluator<'a> {

}

/// Convert a Value to its string representation for interpolation.
/// Coerce a Value into an exit code (i64) for `return`/`exit`.
///
/// Bash semantics: `return $(echo 42)` works because the captured text "42"
Expand All @@ -614,7 +616,11 @@ pub fn value_to_exit_code(value: &Value) -> anyhow::Result<i64> {
Value::String(s) => {
let trimmed = s.trim();
trimmed.parse::<i64>().map_err(|_| {
anyhow::anyhow!("numeric argument required: {:?}", s)
if is_i64_overflow_shape(trimmed) {
anyhow::anyhow!("`{trimmed}`, which {}", crate::lexer::INTEGER_OUT_OF_RANGE)
} else {
anyhow::anyhow!("numeric argument required: {:?}", s)
}
})
}
Value::Null | Value::Json(_) | Value::Bytes(_) => {
Expand All @@ -623,6 +629,14 @@ pub fn value_to_exit_code(value: &Value) -> anyhow::Result<i64> {
}
}

/// True for a string shaped like `-?[0-9]+` — the only shape whose `i64`
/// parse can fail exclusively by overflow. Shared by `value_to_exit_code`
/// and `value_to_num` so both name the same 64-bit limit the same way.
fn is_i64_overflow_shape(t: &str) -> bool {
let digits = t.strip_prefix('-').unwrap_or(t);
!digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
}

/// Length of a value for `${#…}`: element count for a list, key count for a
/// record, and the CHARACTER count (Unicode scalar values) of the string form
/// for any scalar (unchanged for non-collections). The single source of truth
Expand Down Expand Up @@ -778,6 +792,7 @@ pub fn scalar_test_operand_error(op_symbol: &str, value: &Value) -> Option<Strin
}
}

/// Convert a Value to its string representation for interpolation.
pub fn value_to_string(value: &Value) -> String {
match value {
Value::Null => "null".to_string(),
Expand Down Expand Up @@ -1176,25 +1191,63 @@ fn compare_values(left: &Value, right: &Value) -> EvalResult<std::cmp::Ordering>
}
}

/// Coerce a value to a number for arithmetic test ops (`-eq`/`-gt`/…).
///
/// `String` operands are parsed as `i64` then `f64` (matching POSIX `[[ ]]`
/// arithmetic context). Non-numeric strings and non-numeric types error.
/// An integer or float result from `value_to_num`.
enum Num {
Int(i64),
Float(f64),
}

/// Coerce a value to a number for arithmetic test ops (`-eq`/`-gt`/…).
///
/// A `String` operand: a leading zero refuses; then `i64`; then `f64`, but
/// only for a float spelling (`.`/`e`/`E`) and only when the result is
/// finite. An all-digit string that overflows `i64` names the 64-bit
/// limit rather than falling through to `f64`. Other strings and
/// non-numeric types error.
fn value_to_num(value: &Value) -> EvalResult<Num> {
match value {
Value::Int(n) => Ok(Num::Int(*n)),
Value::Float(f) => Ok(Num::Float(*f)),
Value::String(s) => {
let t = s.trim();
// Same refusal as `$((010))`: a leading zero is text, not decimal 10 or octal 8.
if let Some(decimal) = arithmetic::leading_zero_decimal(t) {
return Err(EvalError::TypeError {
expected: "a number",
got: format!(
"`{t}`, which is text (leading zero) — kaish reads no octal; write \
`{decimal}` for the decimal value"
),
});
}
if let Ok(n) = t.parse::<i64>() {
Ok(Num::Int(n))
} else if let Ok(f) = t.parse::<f64>() {
Ok(Num::Float(f))
return Ok(Num::Int(n));
}
// A float spelling (`1.5`, `1e3`) falls to f64 as before. An
// integer-shaped string that only failed above by overflow must
// not silently round through f64 — both sides of a comparison
// past i64::MAX round to the same f64, so `9223372036854775808
// -eq 9223372036854775807` would answer true.
let looks_like_float = t.contains(['.', 'e', 'E']);
if looks_like_float
&& let Ok(f) = t.parse::<f64>()
{
// kaish numbers are JSON numbers, and JSON has no infinity —
// `1e309` parses to f64::INFINITY without an Err, so a
// magnitude past the f64 range needs its own check here.
if !f.is_finite() {
return Err(EvalError::TypeError {
expected: "a number",
got: format!("`{t}`, which is outside the 64-bit float range"),
});
}
return Ok(Num::Float(f));
}
if is_i64_overflow_shape(t) {
Err(EvalError::TypeError {
expected: "a number",
got: format!("`{t}`, which {}", crate::lexer::INTEGER_OUT_OF_RANGE),
})
} else {
Err(EvalError::TypeError {
expected: "numeric operand",
Expand Down
Loading