kaish-kernel 0.16.0

Core kernel for kaish: lexer, parser, interpreter, and runtime
Documentation
//! Small independent correctness/surprise fixes, batched:
//!
//! 1. `grep -c` exits 1 on zero matches (GNU parity) — was exit 0.
//! 2. `$(cmd)` trims only trailing newlines, not all trailing whitespace —
//!    a bare `$()` used `.trim_end()` (stripping spaces/tabs too), diverging
//!    from the interpolation and for-loop paths which trim newlines only.
//! 3. `jq '. / 0'` fails loudly instead of silently returning `null` — jaq
//!    evaluates `n/0` to a non-finite float that JSON can't represent, which
//!    `val_to_json` was coercing to `null` (silent-wrong).

#![cfg(feature = "localfs")]

mod common;

use common::{kernel_at, run};

// ─────────────────────────── grep -c exit code ───────────────────────────

#[tokio::test]
async fn grep_c_zero_matches_exits_1() {
    let tmp = tempfile::tempdir().unwrap();
    let kernel = kernel_at(tmp.path());
    let (out, code) = run(&kernel, "echo 'foo' | grep -c bar").await;
    assert_eq!(out, "0", "count text still printed");
    assert_eq!(code, 1, "zero matches must exit 1 (GNU parity)");
}

#[tokio::test]
async fn grep_c_with_matches_exits_0() {
    let tmp = tempfile::tempdir().unwrap();
    let kernel = kernel_at(tmp.path());
    let (out, code) = run(&kernel, "printf 'foo\\nfoo\\nbar\\n' | grep -c foo").await;
    assert_eq!(out, "2");
    assert_eq!(code, 0);
}

#[tokio::test]
async fn grep_c_multifile_exits_1_only_if_no_file_matches() {
    let tmp = tempfile::tempdir().unwrap();
    std::fs::write(tmp.path().join("a.txt"), "alpha\n").unwrap();
    std::fs::write(tmp.path().join("b.txt"), "beta\n").unwrap();
    let kernel = kernel_at(tmp.path());

    // No file contains "zzz" → exit 1.
    let (_out, code) = run(&kernel, "grep -c zzz a.txt b.txt").await;
    assert_eq!(code, 1, "no match in any file must exit 1");

    // One file matches → exit 0.
    let (_out, code) = run(&kernel, "grep -c alpha a.txt b.txt").await;
    assert_eq!(code, 0, "a match in any file must exit 0");
}

#[tokio::test]
async fn grep_c_multifile_prints_per_file_counts() {
    // GNU parity: `grep -c` over multiple files prints one `name:count` line
    // per file, zero counts included — not a single aggregate total.
    let tmp = tempfile::tempdir().unwrap();
    std::fs::write(tmp.path().join("a.txt"), "alpha\nalpha\n").unwrap();
    std::fs::write(tmp.path().join("b.txt"), "beta\n").unwrap();
    let kernel = kernel_at(tmp.path());

    let (out, code) = run(&kernel, "grep -c alpha a.txt b.txt").await;
    assert_eq!(out, "a.txt:2\nb.txt:0");
    assert_eq!(code, 0);

    // All-zero counts still print per file, with exit 1.
    let (out, code) = run(&kernel, "grep -c zzz a.txt b.txt").await;
    assert_eq!(out, "a.txt:0\nb.txt:0");
    assert_eq!(code, 1);
}

#[tokio::test]
async fn grep_c_recursive_prints_per_file_counts() {
    let tmp = tempfile::tempdir().unwrap();
    std::fs::create_dir(tmp.path().join("d")).unwrap();
    std::fs::write(tmp.path().join("d/a.txt"), "alpha\n").unwrap();
    std::fs::write(tmp.path().join("d/b.txt"), "alpha\nalpha\nbeta\n").unwrap();
    let kernel = kernel_at(tmp.path());

    // Display matches GNU: the operand (`d`) prefixes every name, the same
    // as the match-line display.
    let (out, code) = run(&kernel, "grep -rc alpha d").await;
    assert_eq!(code, 0);
    let mut lines: Vec<&str> = out.lines().collect();
    lines.sort_unstable();
    assert_eq!(lines, vec!["d/a.txt:1", "d/b.txt:2"]);
}

#[tokio::test]
async fn grep_c_precedence_and_max_count_multifile() {
    let tmp = tempfile::tempdir().unwrap();
    std::fs::write(tmp.path().join("a.txt"), "x\nx\nx\nx\nx\n").unwrap();
    std::fs::write(tmp.path().join("b.txt"), "x\ny\n").unwrap();
    let kernel = kernel_at(tmp.path());

    // -q wins over -c: no output at all, exit reports match presence.
    let (out, code) = run(&kernel, "grep -qc x a.txt b.txt").await;
    assert_eq!(out, "", "quiet must suppress count lines");
    assert_eq!(code, 0);
    let (out, code) = run(&kernel, "grep -qc zzz a.txt b.txt").await;
    assert_eq!(out, "");
    assert_eq!(code, 1);

    // -l wins over -c: filenames with matches, not name:count lines.
    let (out, code) = run(&kernel, "grep -lc x a.txt b.txt").await;
    assert_eq!(out, "a.txt\nb.txt");
    assert_eq!(code, 0);

    // --max-count caps each file's count independently (GNU per-file cap).
    let (out, code) = run(&kernel, "grep -c --max-count 2 x a.txt b.txt").await;
    assert_eq!(out, "a.txt:2\nb.txt:1");
    assert_eq!(code, 0);
}

#[tokio::test]
async fn grep_c_unreadable_operand_still_counts_the_readable_ones() {
    let tmp = tempfile::tempdir().unwrap();
    std::fs::write(tmp.path().join("real.txt"), "alpha\n").unwrap();
    let kernel = kernel_at(tmp.path());

    // The missing explicit operand is exit 2 on stderr, and the readable
    // file's count line still prints.
    let (out, code) = run(&kernel, "grep -c alpha missing.txt real.txt").await;
    assert_eq!(code, 2, "an unreadable explicit operand is exit 2");
    assert_eq!(out, "real.txt:1", "the readable file still gets its count");
}

// ───────────────────── $(cmd) trailing-whitespace trim ─────────────────────

#[tokio::test]
async fn cmd_subst_preserves_trailing_spaces() {
    // The trailing spaces sit *inside* the brackets, so the test harness's
    // outer trim can't hide a regression. Before the fix `.trim_end()` ate them.
    let tmp = tempfile::tempdir().unwrap();
    let kernel = kernel_at(tmp.path());
    let (out, code) = run(&kernel, "x=$(printf 'a  b  '); echo \"[$x]\"").await;
    assert_eq!(code, 0, "got: {out}");
    assert_eq!(out, "[a  b  ]", "trailing spaces must survive command subst");
}

#[tokio::test]
async fn cmd_subst_still_strips_trailing_newlines() {
    // The trailing-newline strip (POSIX) is unchanged.
    let tmp = tempfile::tempdir().unwrap();
    let kernel = kernel_at(tmp.path());
    let (out, code) = run(&kernel, "x=$(printf 'hi\\n\\n'); echo \"[$x]\"").await;
    assert_eq!(code, 0, "got: {out}");
    assert_eq!(out, "[hi]", "trailing newlines must still be stripped");
}

// ─────────────────────────── jq division by zero ───────────────────────────

#[tokio::test]
async fn jq_division_by_zero_is_loud() {
    let tmp = tempfile::tempdir().unwrap();
    let kernel = kernel_at(tmp.path());
    let (_out, code) = run(&kernel, "echo '6' | jq '. / 0'").await;
    assert_ne!(code, 0, "division by zero must fail loudly, not return null");
}

#[tokio::test]
async fn jq_zero_over_zero_nan_is_loud() {
    let tmp = tempfile::tempdir().unwrap();
    let kernel = kernel_at(tmp.path());
    let (_out, code) = run(&kernel, "echo '0' | jq '. / 0'").await;
    assert_ne!(code, 0, "0/0 (NaN) must fail loudly, not return null");
}

#[tokio::test]
async fn jq_finite_division_still_works() {
    // Regression guard: ordinary (finite) division is untouched by the
    // non-finite check — exit 0 and a real numeric value, not `null`. The
    // integral result renders as `3` (jq number canonicalization), not `3.0`.
    let tmp = tempfile::tempdir().unwrap();
    let kernel = kernel_at(tmp.path());
    let (out, code) = run(&kernel, "echo '6' | jq '. / 2'").await;
    assert_eq!(code, 0, "got: {out}");
    assert_eq!(out, "3");
}

// ──────────── interpolated arithmetic error swallow (GH #183) ────────────

#[tokio::test]
async fn interpolated_arithmetic_division_by_zero_is_loud() {
    // `"$((1/0))"` inside a double-quoted string used to silently splice in
    // an EMPTY string — the async string-interpolation evaluator's
    // `StringPart::Arithmetic` arm discarded the error entirely
    // (`Err(_) => Ok(String::new())`) — so `echo "value: $((1/0))"` printed
    // "value: " at exit 0 instead of failing. The bare (non-string) form
    // `echo $((1/0))` already failed loud; this brought the interpolated
    // form in line with it.
    let tmp = tempfile::tempdir().unwrap();
    let kernel = kernel_at(tmp.path());
    let result = kernel
        .execute(r#"echo "value: $((1/0))""#)
        .await
        .expect("kernel execute");
    assert_ne!(
        result.code, 0,
        "division by zero inside a string must fail loudly, not silently splice in \"\""
    );
    assert!(
        result.err.contains("division by zero"),
        "got: {}",
        result.err
    );
}

#[tokio::test]
async fn interpolated_arithmetic_still_works_for_valid_expressions() {
    // Regression guard: the loud-error fix must not disturb the ordinary
    // (non-erroring) interpolated-arithmetic path.
    let tmp = tempfile::tempdir().unwrap();
    let kernel = kernel_at(tmp.path());
    let (out, code) = run(&kernel, r#"echo "value: $((2 + 2))""#).await;
    assert_eq!(code, 0, "got: {out}");
    assert_eq!(out, "value: 4");
}