kaish-kernel 0.17.0

Core kernel for kaish: lexer, parser, interpreter, and runtime
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
//! A leading zero makes a word text; where kaish needs a number, it is an error.
//!
//! `007` is not a JSON number (RFC 8259 admits `0` or a nonzero leading digit),
//! and `fromjson '007'` has always refused it. The lexer now agrees, so a
//! leading-zero numeral is text everywhere a word is text — `chmod 0644`,
//! `echo 007` — and the positions that need a real number say so instead of
//! reinterpreting the digits.
//!
//! The failure this replaces was silent in both directions: `echo 007` printed
//! `7`, and `$((010 + 1))` answered 11 where bash answers 9.
#![allow(clippy::unwrap_used, clippy::expect_used)]

use kaish_kernel::{Kernel, KernelConfig};

mod common;

async fn run(source: &str) -> (i64, String, String) {
    let k = Kernel::new(KernelConfig::isolated()).expect("kernel");
    let r = k.execute(source).await.expect("kernel execute");
    (r.code, r.text_out().trim().to_string(), r.err.clone())
}

/// Every diagnostic a failing statement produces, however it refused.
async fn err_of(source: &str) -> String {
    let k = Kernel::new(KernelConfig::isolated()).expect("kernel").into_arc();
    match k.execute(source).await {
        Ok(r) => {
            assert!(!r.ok(), "{source:?} should fail");
            format!("{}{}", r.text_out(), r.err)
        }
        Err(e) => format!("{e:?}"),
    }
}

// ── A word is text, and keeps the text that was typed ──────────────────────

#[tokio::test]
async fn a_leading_zero_word_keeps_its_digits() {
    for (source, expected) in
        [("echo 007", "007"), ("echo 0644", "0644"), ("echo 00", "00"), ("echo 007.5", "007.5")]
    {
        let (code, out, err) = run(source).await;
        assert_eq!(code, 0, "{source:?} must run: {err:?}");
        assert_eq!(out, expected, "{source:?} lost the text that was typed");
    }
}

/// The mode operand is the case that decides this: it is by far the most
/// common leading-zero word in real shell, and it must cost nothing.
#[tokio::test]
async fn a_mode_operand_survives_as_typed() {
    let (code, out, _) = run("echo 0755 0644 0000").await;
    assert_eq!(code, 0);
    assert_eq!(out, "0755 0644 0000");
}

#[tokio::test]
async fn a_leading_zero_word_is_a_string_not_a_number() {
    let (_, out, _) = run("echo $(typeof 007)").await;
    assert_eq!(out, "string", "007 must not type as a number");
    let (_, out, _) = run("echo $(typeof 7)").await;
    assert_eq!(out, "number", "an ordinary numeral is untouched");
}

// ── Where kaish needs a number, the leading zero is named ──────────────────

#[tokio::test]
async fn break_and_continue_name_the_leading_zero() {
    for (source, fix) in [
        ("for i in 1 2; do break 007; done", "write `break 7`"),
        ("for i in 1 2; do continue 010; done", "write `continue 10`"),
    ] {
        let text = err_of(source).await;
        assert!(text.contains("(leading zero)"), "must name the cause: {text:?}");
        assert!(text.contains(fix), "must name the fix: {text:?}");
    }
}

/// The validator that produces the message above runs only after the grammar
/// has already failed. A loop count that parses must never reach it.
#[tokio::test]
async fn an_ordinary_loop_count_still_parses() {
    let (code, out, err) = run("for i in 1 2 3; do break 2; done; echo done").await;
    assert_eq!(code, 0, "break 2 must still parse: {err:?}");
    assert_eq!(out, "done");
}

/// `-0` is a valid count and a valid JSON number, but its source text does not
/// round-trip, so it lexes as `NumericLiteral` rather than `Int`. The count
/// grammar matched only `Int`, which turned `break -0` into a parse error --
/// a regression this rule introduced and nothing else caught.
#[tokio::test]
async fn a_negative_zero_count_still_parses() {
    for source in [
        "for i in 1 2 3; do break -0; done; echo done",
        "for i in 1 2 3; do continue -0; done; echo done",
    ] {
        let (code, out, err) = run(source).await;
        assert_eq!(code, 0, "-0 is a number, not a leading zero: {source} {err:?}");
        assert_eq!(out, "done", "{source}");
    }
}

/// The message may reword a diagnosis and must never author one. `break` is
/// also a bareword an argument list accepts, and `echo break 007` fails ON the
/// numeral exactly like the statement does, so position in the token stream is
/// not enough to tell them apart.
#[tokio::test]
async fn the_count_message_never_speaks_for_an_argument() {
    for source in ["echo break 007", "echo continue 007"] {
        let text = err_of(source).await;
        assert!(
            !text.contains("takes a loop count"),
            "{source:?} is not a loop statement: {text:?}"
        );
    }
}

/// And it must not answer a real error elsewhere on the line with this one.
#[tokio::test]
async fn a_real_error_elsewhere_still_wins() {
    let text = err_of("if true; then echo hi; done
break 007").await;
    assert!(text.contains("found 'done'"), "the grammar's own error must stand: {text:?}");
    assert!(!text.contains("takes a loop count"), "must not mask it: {text:?}");
}

/// Every statement-start context the gate admits, so narrowing it cannot
/// quietly stop diagnosing the case it exists for.
#[tokio::test]
async fn the_count_message_reaches_every_statement_position() {
    for source in [
        "for i in 1 2; do break 007; done",
        "for i in 1 2; do echo a; break 007; done",
        "while true; do
break 007
done",
        "for i in 1 2; do if true; then break 007; fi; done",
        "for i in 1 2; do true && break 007; done",
        "for i in 1 2; do continue 007; done",
    ] {
        let text = err_of(source).await;
        assert!(text.contains("takes a loop count"), "{source:?} must be diagnosed: {text:?}");
    }
}

/// `break -022` is not fixed by writing `break 22`.
#[tokio::test]
async fn the_suggested_count_keeps_its_sign() {
    let text = err_of("for i in 1 2; do break -022; done").await;
    assert!(text.contains("write `break -22`"), "the sign must survive: {text:?}");
}

/// `break 007.5` used to suggest `break 7.5` — the fraction survives the
/// zero-trim, but the count grammar takes only a whole number, so the
/// suggestion was itself a parse error. The message must not repeat that
/// mistake, and `break 007` (a genuine integer) must be unaffected.
#[tokio::test]
async fn the_suggested_count_is_never_a_fraction() {
    let text = err_of("for i in 1 2; do break 007.5; done").await;
    assert!(text.contains("whole-number"), "must say a whole number is needed: {text:?}");
    assert!(!text.contains("write `break 7.5`"), "must not suggest a fraction: {text:?}");

    let text = err_of("for i in 1 2; do break 007; done").await;
    assert!(text.contains("write `break 7`"), "an integer count is unaffected: {text:?}");
}

/// bash reads `010` as octal and answers 9; kaish reads no octal and would
/// answer 11. Answering a different number than the shell the author learned
/// is the outcome worth refusing.
#[tokio::test]
async fn arithmetic_refuses_a_leading_zero_rather_than_reading_decimal() {
    let text = err_of("echo $((010 + 1))").await;
    assert!(text.contains("leading zero"), "must name the cause: {text:?}");
    assert!(text.contains("no octal"), "must say kaish reads no octal: {text:?}");
    assert!(text.contains("8#10"), "must name the octal fix: {text:?}");
    assert!(text.contains("or `10`"), "must name the decimal fix: {text:?}");
    assert!(!text.contains("11"), "must not answer 11: {text:?}");
}

#[tokio::test]
async fn ordinary_arithmetic_is_untouched() {
    let (code, out, err) = run("echo $((10 + 1))").await;
    assert_eq!(code, 0, "{err:?}");
    assert_eq!(out, "11");
}

#[tokio::test]
async fn a_list_index_names_the_leading_zero() {
    let text = err_of("xs=[10 20 30]; echo ${xs[007]}").await;
    assert!(text.contains("(leading zero)"), "must name the cause: {text:?}");
    assert!(text.contains("${xs[7]}"), "must name the fix: {text:?}");
}

// ── Read and write agree on the same subscript ─────────────────────────────

/// `${r[007]}` read fine while `r[007]=v` was a parse error, and the read
/// resolved to index 7 — so a record whose key really is "007" could not be
/// reached by the name it was stored under.
#[tokio::test]
async fn a_record_key_that_is_a_leading_zero_numeral_round_trips() {
    let (code, out, err) = run(r#"r={"007":9}; echo ${r[007]}"#).await;
    assert_eq!(code, 0, "reading a 007 key must work: {err:?}");
    assert_eq!(out, "9");

    let (code, out, err) = run("r={}; r[007]=nine; echo ${r[007]}").await;
    assert_eq!(code, 0, "writing a 007 key must work: {err:?}");
    assert_eq!(out, "nine", "the write and the read must name the same key");
}

/// A slice carries two number positions, and the leading zero was silent in
/// both: `${xs[007:2]}` sliced from 7, inverted the range, and returned an
/// empty list. An empty result is the one wrong answer a caller cannot tell
/// from a correct one.
#[tokio::test]
async fn a_slice_bound_refuses_a_leading_zero() {
    for (source, fix) in [
        ("xs=[1 2 3]; echo ${xs[007:2]}", "${xs[7:2]}"),
        ("xs=[1 2 3]; echo ${xs[0:007]}", "${xs[0:7]}"),
    ] {
        let text = err_of(source).await;
        assert!(text.contains("(leading zero)"), "must name the cause: {text:?}");
        assert!(text.contains(fix), "must name the fix: {text:?}");
    }
}

/// The slice grammar is easy to break while refusing one spelling of it.
#[tokio::test]
async fn every_ordinary_slice_spelling_still_works() {
    for (source, expected) in [
        ("xs=[1 2 3]; echo ${xs[0:2]}", "[1,2]"),
        ("xs=[1 2 3]; echo ${xs[:2]}", "[1,2]"),
        ("xs=[1 2 3]; echo ${xs[1:]}", "[2,3]"),
        ("xs=[1 2 3]; echo ${xs[-2:]}", "[2,3]"),
    ] {
        let (code, out, err) = run(source).await;
        assert_eq!(code, 0, "{source:?} must run: {err:?}");
        assert_eq!(out, expected, "{source:?}");
    }
}

/// A variable holding `010` is a number position when it reaches arithmetic,
/// and parsing it decimal answers 10 where bash answers 8. The literal case
/// was already refused; this is the same numeral arriving by another road.
#[tokio::test]
async fn arithmetic_refuses_a_leading_zero_that_arrives_in_a_variable() {
    for source in ["x=010; echo $((x))", "x=007; echo $((x + 1))"] {
        let text = err_of(source).await;
        assert!(text.contains("(leading zero)"), "must name the cause: {text:?}");
        assert!(text.contains("10#$x"), "must name the decimal fix: {text:?}");
        assert!(text.contains("8#$x"), "must name the octal fix: {text:?}");
    }
    let (code, out, err) = run("x=10; echo $((x + 1))").await;
    assert_eq!(code, 0, "an ordinary variable must still work: {err:?}");
    assert_eq!(out, "11");
}

/// `${r[-0]}` read as index 0 while `r[-0]=v` was a parse error, and `${r[1.0]}`
/// read as a key while the write refused. Neither is a leading-zero numeral —
/// they are numerals whose source text does not round-trip — but they reach
/// the subscript through the same token, so the two sides must classify alike.
#[tokio::test]
async fn read_and_write_classify_every_numeral_subscript_alike() {
    let read = err_of("r={}; echo ${r[-0]}").await;
    let write = err_of("r={}; r[-0]=v").await;
    assert!(read.contains("integer index on a record"), "read: {read:?}");
    assert!(write.contains("integer index on a record"), "write must agree: {write:?}");

    let (code, out, err) = run("r={}; r[1.0]=v; echo ${r[1.0]}").await;
    assert_eq!(code, 0, "a 1.0 key must round-trip: {err:?}");
    assert_eq!(out, "v");
}

#[tokio::test]
async fn an_ordinary_index_is_untouched() {
    let (code, out, err) = run("xs=[10 20 30]; echo ${xs[1]}").await;
    assert_eq!(code, 0, "{err:?}");
    assert_eq!(out, "20");
}

// ── The plan document and the run agree ────────────────────────────────────

/// `--plan` reported the redirect target as `-0` while execution created a
/// file named `0`. A plan that describes a write that never happens is the
/// one failure a plan consumer cannot detect for itself.
#[cfg(feature = "localfs")]
#[tokio::test]
async fn a_redirect_target_writes_the_file_the_plan_names() {
    for target in ["-0", "007", "0.10", "1.0"] {
        let dir = tempfile::tempdir().expect("tempdir");
        let kernel = common::kernel_at(dir.path());

        let planned = kernel
            .plan_program(&format!("echo hi > {target}"))
            .expect("plan")
            .first()
            .map(|s| s.plan.rendered.clone())
            .expect("one statement");
        assert!(
            planned.ends_with(target),
            "the plan must name {target:?} as typed, got {planned:?}"
        );

        let (_, code) = common::run(&kernel, &format!("echo hi > {target}")).await;
        assert!(
            dir.path().join(target).exists(),
            "the plan promised {target:?} (exit {code}); the run created {:?}",
            std::fs::read_dir(dir.path())
                .expect("read_dir")
                .filter_map(|e| e.ok().map(|e| e.file_name()))
                .collect::<Vec<_>>()
        );
    }
}

// ── A comparison operand is a number position too ──────────────────────────

/// `[[ 010 -eq 10 ]]` was true: the string parsed as decimal 10, the number
/// arithmetic refuses to answer. bash answers 8 here (octal), so this is the
/// same three-answers case as `$((010))`, and it takes the same refusal.
#[tokio::test]
async fn numeric_comparison_refuses_a_leading_zero_rather_than_reading_decimal() {
    for source in [
        "[[ 010 -eq 10 ]]",
        "test 010 -eq 10",
        "[[ 10 -lt 0100 ]]",
        "x=010; [[ $x -eq 10 ]]",
        "x=-007; test $x -eq -7",
        r#"[[ "01" -eq "1" ]]"#,
        r#"X="01"; [[ "$X" -eq 1 ]]"#,
    ] {
        let text = err_of(source).await;
        assert!(text.contains("(leading zero)"), "{source:?} must name the cause: {text:?}");
        assert!(text.contains("no octal"), "{source:?} must say kaish reads no octal: {text:?}");
        assert!(
            text.contains("write `10`")
                || text.contains("write `100`")
                || text.contains("write `-7`")
                || text.contains("write `1`"),
            "{source:?} must name the fix: {text:?}"
        );
    }
}

#[tokio::test]
async fn ordinary_numeric_comparison_is_untouched() {
    for source in [
        "[[ 10 -eq 10 ]]",
        "[[ 0 -eq 0 ]]",
        "[[ -0 -eq 0 ]]",
        "[[ 0.5 -gt 0 ]]",
        "[[ 0.10 -lt 1 ]]",
        "test 100 -gt 10",
        "x=$(fromjson 10); [[ $x -eq 10 ]]",
    ] {
        let (code, _, err) = run(source).await;
        assert_eq!(code, 0, "{source:?} must be true: {err:?}");
    }
}

// ── A numeral kaish cannot hold names the limit and the fix ────────────────

/// One past `i64::MAX` was "invalid number" with nothing to do about it. The
/// numeral is a valid JSON number, so the error names the limit kaish adds and
/// the quoting that keeps the text.
#[tokio::test]
async fn an_integer_past_64_bits_names_the_limit_and_the_fix() {
    for source in ["echo 9223372036854775808", "echo -9223372036854775809", "x=18446744073709551616"] {
        let text = err_of(source).await;
        assert!(text.contains("64-bit"), "{source:?} must name the limit: {text:?}");
        assert!(text.contains("quote"), "{source:?} must name the fix: {text:?}");
        assert!(!text.contains("invalid number"), "{source:?} must not say only 'invalid': {text:?}");
    }
    let (code, out, _) = run("echo \"18446744073709551616\"").await;
    assert_eq!((code, out.as_str()), (0, "18446744073709551616"), "the quoted form is the fix");
    let (code, out, _) = run("echo 9223372036854775807 -9223372036854775808").await;
    assert_eq!((code, out.as_str()), (0, "9223372036854775807 -9223372036854775808"));
}

/// A leading zero makes a word text before overflow ever gets to matter —
/// `09223372036854775808` is one past `i64::MAX`, but it is text (leading
/// zero) first, same as `007`, not a 64-bit refusal.
#[tokio::test]
async fn a_leading_zero_numeral_past_64_bits_is_still_text() {
    let (code, out, err) = run("echo 09223372036854775808").await;
    assert_eq!(code, 0, "must run: {err:?}");
    assert_eq!(out, "09223372036854775808", "leading zero wins over overflow");
    let (_, out, _) = run("echo $(typeof 09223372036854775808)").await;
    assert_eq!(out, "string", "09223372036854775808 must not type as a number");

    // The un-zeroed overflow still refuses.
    let text = err_of("echo 9223372036854775808").await;
    assert!(text.contains("64-bit"), "must still name the 64-bit limit: {text:?}");
}

/// `$(( … ))` parses its own numerals separately from the lexer, and used to
/// say only "invalid number" for an overflowing literal. It now names the
/// same 64-bit limit the lexer does.
#[tokio::test]
async fn arithmetic_overflow_literal_names_the_64_bit_limit() {
    let text = err_of("echo $((9223372036854775808))").await;
    assert!(text.contains("64-bit"), "must name the limit: {text:?}");
    assert!(!text.contains("invalid number"), "must not say only 'invalid': {text:?}");

    // Overflow from addition is a different failure and keeps its own wording:
    // the 0.16 rewrite names the operands and the 64-bit limit directly
    // rather than the word "overflow".
    let text = err_of("echo $((9223372036854775807 + 1))").await;
    assert!(text.contains("does not fit"), "addition overflow must name the limit: {text:?}");
}

// ── `value_to_num` does not round an out-of-range string through f64 ───────

/// `value_to_num`'s `String` arm fell to an `f64` parse whenever the `i64`
/// parse failed — including on an integer-shaped string that only failed
/// because it overflowed. Both sides of the comparison below round to 2^63
/// in f64, so the comparison answered true for two numbers that are not
/// equal. An all-digit string that overflows i64 must refuse instead.
#[tokio::test]
async fn an_overflowing_integer_string_is_refused_not_rounded_through_float() {
    let text =
        err_of(r#"x="9223372036854775808"; [[ $x -eq 9223372036854775807 ]]"#).await;
    assert!(text.contains("64-bit"), "must name the 64-bit limit: {text:?}");

    // A genuine float spelling still falls to f64 as before.
    let (code, _, err) = run(r#"[[ "1.5" -gt 1 ]]"#).await;
    assert_eq!(code, 0, "a float string must still compare: {err:?}");
    let (code, _, err) = run(r#"[[ "1e3" -eq 1000 ]]"#).await;
    assert_eq!(code, 0, "an exponent string must still compare: {err:?}");
}

// ── `-0` keeps source text at argv, canonicalizes once it moves ────────────

/// `-0` is a valid JSON number, not a leading-zero refusal (`has_invalid_
/// leading_zero` only fires past one digit). `docs/LANGUAGE.md` documents
/// this split — argv keeps the typed word, a variable canonicalizes — but
/// it had no test pinning either half.
#[tokio::test]
async fn negative_zero_keeps_source_text_at_argv_and_canonicalizes_through_a_variable() {
    let (code, out, err) = run("echo -0").await;
    assert_eq!((code, out.as_str()), (0, "-0"), "argv keeps the typed word: {err:?}");

    let (code, out, err) = run("x=-0; echo $x").await;
    assert_eq!((code, out.as_str()), (0, "0"), "a variable prints the canonical form: {err:?}");

    let (code, out, err) = run("x=-0; typeof $x").await;
    assert_eq!((code, out.as_str()), (0, "number"), "-0 is a number, not text: {err:?}");
}