diff --git a/crates/bashkit/docs/jq.md b/crates/bashkit/docs/jq.md index 116fc778b..61eacdc04 100644 --- a/crates/bashkit/docs/jq.md +++ b/crates/bashkit/docs/jq.md @@ -60,6 +60,16 @@ shim adds or overrides: | `input` / `inputs` | Pull from the same stream as the main loop; with `-n` the whole stream is theirs. `input` fails with `No more inputs` at the end. | | Most other 1.7/1.8 stdlib filters | Forwarded from `jaq-std` (`getpath`, `paths`, `to_entries`, `group_by`, `ltrimstr`/`rtrimstr`, `splits`, `test`, `now`, `debug`, `limit`, etc.). | +## Numbers + +Computed numbers print like jq: `pow(2;10)` is `1024`, `1e17*1` is `1e+17`, +`0.00001*1` is `1e-05`, NaN is `null` and infinities print as the largest +double. Input literals print as read until arithmetic changes them (`1.0` +stays `1.0`). Division by zero is an error, `%` truncates its operands to +integers, a fractional array index is truncated, and `gamma` is the log-gamma +function, all as in jq. Math and index errors use jq's wording, so +`try ("a"+1) catch .` gives `string ("a") and number (1) cannot be added`. + ## Errors Filter compile failures exit `3`. A runtime error is reported as diff --git a/crates/bashkit/src/builtins/jq/convert.rs b/crates/bashkit/src/builtins/jq/convert.rs index fb79523d3..2a5bf221b 100644 --- a/crates/bashkit/src/builtins/jq/convert.rs +++ b/crates/bashkit/src/builtins/jq/convert.rs @@ -184,8 +184,12 @@ pub(super) fn jq_to_val(v: &JqJson) -> Val { { return Val::from(i); } - if let Ok(f) = s.parse::() { - return Val::from(f); + // Other literals keep their token (`1.0` prints `1.0`, as in jq) + // until arithmetic turns them into floats. + if s.parse::().is_ok() { + return Val::Num(super::jaq_json::Num::Dec(super::jaq_json::Rc::new( + s.clone(), + ))); } Val::from(0isize) } diff --git a/crates/bashkit/src/builtins/jq/jaq_json/defs.jq b/crates/bashkit/src/builtins/jq/jaq_json/defs.jq index 71d8ce8db..32055f08b 100644 --- a/crates/bashkit/src/builtins/jq/jaq_json/defs.jq +++ b/crates/bashkit/src/builtins/jq/jaq_json/defs.jq @@ -15,3 +15,25 @@ def rindex($i): indices($i)[-1]; # Formatting def @json: tojson; + +# BASHKIT PATCH: jq refuses to divide by zero, so jaq-std's `0 / 0` and +# `1 / 0` definitions (and the filters built on them) are redefined. +# `gamma` is jq's log-gamma (C gamma()). +def infinite: 1e1000 * 1; +def nan: infinite - infinite; +def isnan: . < nan and nan < .; +def isinfinite: . == infinite or . == -infinite; +def isfinite: isnumber and (isinfinite | not); +def isnormal: isnumber and ((. == 0 or isnan or isinfinite) | not); +def logb: + if . == 0.0 then -infinite + elif isinfinite then infinite + elif isnan then . + else ilogb | . + 0.0 end; +def significand: + if isinfinite or isnan then . + elif . == 0.0 then 0.0 + else scalbln(.; ilogb | -1 * .) end; +def finites: select(isfinite); +def normals: select(isnormal); +def gamma: lgamma; diff --git a/crates/bashkit/src/builtins/jq/jaq_json/jqmsg.rs b/crates/bashkit/src/builtins/jq/jaq_json/jqmsg.rs new file mode 100644 index 000000000..23cbeab65 --- /dev/null +++ b/crates/bashkit/src/builtins/jq/jaq_json/jqmsg.rs @@ -0,0 +1,87 @@ +// BASHKIT PATCH: bashkit-only module (not from upstream jaq-json). +//! jq's wording for value errors, so `try ... catch .` sees the same text as +//! jq (`string ("a") and number (1) cannot be added`). + +use super::{Num, Val}; +use jaq_core::{ops, Error}; +use std::string::{String, ToString}; + +fn type_name(v: &Val) -> &'static str { + match v { + Val::Null => "null", + Val::Bool(_) => "boolean", + Val::Num(_) => "number", + Val::BStr(_) | Val::TStr(_) => "string", + Val::Arr(_) => "array", + Val::Obj(_) => "object", + } +} + +/// jq dumps an operand into a 15-byte buffer: longer dumps keep 11 bytes +/// and end in `...`. +fn dump_trunc(v: &Val) -> String { + let mut s = String::new(); + // Render at most a little past the cut, whatever the value's size. + for c in v.to_string().chars() { + s.push(c); + if s.len() > 14 { + break; + } + } + if s.len() > 14 { + let mut end = 11; + while !s.is_char_boundary(end) { + end -= 1; + } + s.truncate(end); + s.push_str("..."); + } + s +} + +fn described(v: &Val) -> String { + std::format!("{} ({})", type_name(v), dump_trunc(v)) +} + +/// `l op r` failed; `zero` marks a zero divisor. +pub(crate) fn math(l: Val, op: ops::Math, r: Val, zero: bool) -> Error { + let verb = match op { + ops::Math::Add => "added", + ops::Math::Sub => "subtracted", + ops::Math::Mul => "multiplied", + ops::Math::Div => "divided", + ops::Math::Rem => "divided (remainder)", + }; + let why = if zero { " because the divisor is zero" } else { "" }; + Error::str(std::format!( + "{} and {} cannot be {verb}{why}", + described(&l), + described(&r) + )) +} + +/// `l[r]` is not defined. +pub(crate) fn index(l: Val, r: Val) -> Error { + let right = match &r { + Val::BStr(_) | Val::TStr(_) => std::format!("string {}", r), + other => type_name(other).to_string(), + }; + Error::str(std::format!("Cannot index {} with {right}", type_name(&l))) +} + +pub(crate) fn is_zero(v: &Val) -> bool { + matches!(v, Val::Num(n) if n.as_f64() == 0.0) +} + +/// jq's `%` works on integers: fractional operands are truncated +/// (saturating at the 64-bit range). +pub(crate) fn trunc_int(n: &Num) -> Num { + if n.is_int() { + return n.clone(); + } + let f = n.as_f64(); + if f.is_nan() { + return Num::Int(0); + } + Num::Int(f.trunc() as i64 as isize) +} diff --git a/crates/bashkit/src/builtins/jq/jaq_json/mod.rs b/crates/bashkit/src/builtins/jq/jaq_json/mod.rs index 46cceb6bb..58d951a05 100644 --- a/crates/bashkit/src/builtins/jq/jaq_json/mod.rs +++ b/crates/bashkit/src/builtins/jq/jaq_json/mod.rs @@ -12,6 +12,8 @@ mod funs; +// BASHKIT PATCH: jq's wording for math and index errors. +mod jqmsg; // BASHKIT PATCH: live-memory meter (TM-DOS-110). pub mod meter; mod num; @@ -526,6 +528,22 @@ impl Val { let indices = iw.filter_map(|(i, w)| (w == ***y).then_some(i)); Some(indices.map(Val::from).collect()) } + // BASHKIT PATCH: jq truncates a fractional array index. + (Val::Arr(a), Val::Num(n @ (Num::Float(_) | Num::Dec(_)))) => { + let f = n.as_f64(); + if !f.is_finite() { + None + } else { + let i = Num::Int(f.trunc() as isize); + i.as_pos_usize() + .and_then(|i| abs_index(i, a.len())) + .map(|i| a[i].clone()) + } + } + // BASHKIT PATCH: jq objects have string keys only. + (s @ Val::Obj(_), i) if !matches!(i, Val::TStr(_) | Val::BStr(_)) => { + return Err(jqmsg::index(s, index.clone())) + } (Val::Obj(o), i) => o.get(i).cloned(), (v @ (Val::BStr(_) | Val::TStr(_) | Val::Arr(_)), Val::Obj(o)) => { use jaq_core::ValT; @@ -533,7 +551,7 @@ impl Val { let end = o.get(&Val::utf8_str("end")); return v.range(start..end).map(Some); } - (s, _) => return Err(Error::index(s, index.clone())), + (s, _) => return Err(jqmsg::index(s, index.clone())), // BASHKIT PATCH }) } } @@ -627,7 +645,7 @@ impl core::ops::Add for Val { o.resync(); Ok(Obj(l)) } - (l, r) => Err(Error::math(l, ops::Math::Add, r)), + (l, r) => Err(jqmsg::math(l, ops::Math::Add, r, false)), // BASHKIT PATCH } } } @@ -645,7 +663,7 @@ impl core::ops::Sub for Val { a.resync(); // BASHKIT PATCH (TM-DOS-110) Ok(Self::Arr(l)) } - (l, r) => Err(Error::math(l, ops::Math::Sub, r)), + (l, r) => Err(jqmsg::math(l, ops::Math::Sub, r, false)), // BASHKIT PATCH } } } @@ -691,7 +709,7 @@ impl core::ops::Mul for Val { obj_merge(&mut l, r); Ok(Obj(l)) } - (l, r) => Err(Error::math(l, ops::Math::Mul, r)), + (l, r) => Err(jqmsg::math(l, ops::Math::Mul, r, false)), // BASHKIT PATCH } } } @@ -718,10 +736,14 @@ impl core::ops::Div for Val { split(&x, &y).map(|s| into(x.slice_ref(s))).collect() }; match (self, rhs) { + // BASHKIT PATCH: jq refuses to divide by zero. + (l @ Self::Num(_), r @ Self::Num(_)) if jqmsg::is_zero(&r) => { + Err(jqmsg::math(l, ops::Math::Div, r, true)) + } (Self::Num(x), Self::Num(y)) => Ok(Self::Num(x / y)), (Self::TStr(x), Self::TStr(y)) => Ok(fs(x.into_bytes(), y.into_bytes(), Val::utf8_str)), (Self::BStr(x), Self::BStr(y)) => Ok(fs(x.into_bytes(), y.into_bytes(), Val::byte_str)), - (l, r) => Err(Error::math(l, ops::Math::Div, r)), + (l, r) => Err(jqmsg::math(l, ops::Math::Div, r, false)), // BASHKIT PATCH } } } @@ -731,10 +753,16 @@ impl core::ops::Rem for Val { fn rem(self, rhs: Self) -> Self::Output { meter::tick(); // BASHKIT PATCH (TM-DOS-110) match (self, rhs) { - (Self::Num(x), Self::Num(y)) if !(x.is_int() && y.is_int() && y == Num::Int(0)) => { - Ok(Self::Num(x % y)) + // BASHKIT PATCH: jq truncates both operands to integers and + // refuses a zero divisor. + (Self::Num(x), Self::Num(y)) => { + let (l, r) = (jqmsg::trunc_int(&x), jqmsg::trunc_int(&y)); + match (l, r) { + (_, Num::Int(0)) => Err(jqmsg::math(Self::Num(x), ops::Math::Rem, Self::Num(y), true)), + (l, r) => Ok(Self::Num(l % r)), + } } - (l, r) => Err(Error::math(l, ops::Math::Rem, r)), + (l, r) => Err(jqmsg::math(l, ops::Math::Rem, r, false)), // BASHKIT PATCH } } } diff --git a/crates/bashkit/src/builtins/jq/jaq_json/num.rs b/crates/bashkit/src/builtins/jq/jaq_json/num.rs index 5c6b8d1f2..25db7927c 100644 --- a/crates/bashkit/src/builtins/jq/jaq_json/num.rs +++ b/crates/bashkit/src/builtins/jq/jaq_json/num.rs @@ -398,11 +398,75 @@ impl fmt::Display for Num { match self { Self::Int(i) => write!(f, "{i}"), Self::BigInt(i) => write!(f, "{i}"), - Self::Float(x) if x.is_nan() => write!(f, "NaN"), - Self::Float(f64::INFINITY) => write!(f, "Infinity"), - Self::Float(f64::NEG_INFINITY) => write!(f, "-Infinity"), - Self::Float(x) => ryu::Buffer::new().format_finite(*x).fmt(f), + // BASHKIT PATCH: print floats like jq 1.7 (`1024`, `1e+17`, + // `1e-05`, `-0`; NaN as null, infinities as the largest double). + Self::Float(x) => f.write_str(&jq_format_f64(*x)), Self::Dec(n) => write!(f, "{n}"), } } } + +/// BASHKIT PATCH: jq's double formatting (`jvp_dtoa_fmt`): shortest +/// round-trip digits, plain notation unless the decimal exponent is below +/// -3 or more than 15 past the last digit, then `d.ddde±XX`. +pub(crate) fn jq_format_f64(x: f64) -> String { + if x.is_nan() { + return "null".into(); + } + let x = if x.is_infinite() { f64::MAX.copysign(x) } else { x }; + let sign = if x.is_sign_negative() { "-" } else { "" }; + if x == 0.0 { + return format!("{sign}0"); + } + let sci = format!("{:e}", x.abs()); + let (mant, exp) = sci.split_once('e').unwrap_or((&sci, "0")); + let digits: String = mant.chars().filter(|c| *c != '.').collect(); + let nd = digits.len() as i64; + let decpt = exp.parse::().unwrap_or(0) + 1; + if decpt <= -4 || decpt > nd + 15 { + let e = decpt - 1; + let (head, tail) = digits.split_at(1); + let frac = if tail.is_empty() { String::new() } else { format!(".{tail}") }; + let esign = if e < 0 { '-' } else { '+' }; + format!("{sign}{head}{frac}e{esign}{:02}", e.abs()) + } else if decpt <= 0 { + format!("{sign}0.{}{digits}", "0".repeat((-decpt) as usize)) + } else if decpt >= nd { + format!("{sign}{digits}{}", "0".repeat((decpt - nd) as usize)) + } else { + let (a, b) = digits.split_at(decpt as usize); + format!("{sign}{a}.{b}") + } +} + +#[test] +fn jq_format_f64_matches_jq() { + let cases: &[(f64, &str)] = &[ + (1e17, "1e+17"), + (1e16, "1e+16"), + (1e15 + 1.0, "1000000000000001"), + (1e19, "1e+19"), + (123456789012345678.0, "123456789012345680"), + (0.0001, "0.0001"), + (0.00001, "1e-05"), + (3.0, "3"), + (1.0 / 3.0, "0.3333333333333333"), + (1e300, "1e+300"), + (1.5e300, "1.5e+300"), + (0.1 + 0.2, "0.30000000000000004"), + (1e20, "1e+20"), + (12345678901234567890123.0, "12345678901234568000000"), + (f64::MAX, "1.7976931348623157e+308"), + (5e-324, "5e-324"), + (1e15 + 0.5, "1000000000000000.5"), + (-2.5, "-2.5"), + (-0.0, "-0"), + (0.0, "0"), + (f64::INFINITY, "1.7976931348623157e+308"), + (f64::NEG_INFINITY, "-1.7976931348623157e+308"), + (f64::NAN, "null"), + ]; + for (x, want) in cases { + assert_eq!(jq_format_f64(*x), *want, "{x}"); + } +} diff --git a/crates/bashkit/src/builtins/jq/jaq_json/tests/funs.rs b/crates/bashkit/src/builtins/jq/jaq_json/tests/funs.rs index 816105b86..70d4dee37 100644 --- a/crates/bashkit/src/builtins/jq/jaq_json/tests/funs.rs +++ b/crates/bashkit/src/builtins/jq/jaq_json/tests/funs.rs @@ -10,10 +10,11 @@ yields!(bsearch_absent2, "[1, 3] | bsearch(2)", -2); yields!(bsearch_absent3, "[1, 3] | bsearch(4)", -3); yields!(bsearch_present, "[1, 3] | [bsearch(1, 3)]", [0, 1]); +// BASHKIT PATCH: floats print like jq (infinities as the largest double). yields!( fromjson_inf, r#""Infinity +Infinity -Infinity" | [fromjson | tostring]"#, - ["Infinity", "Infinity", "-Infinity"] + ["1.7976931348623157e+308", "1.7976931348623157e+308", "-1.7976931348623157e+308"] ); yields!(fromjson_uint, r#"" 1" | fromjson"#, 1); yields!(fromjson_pint, r#""+1" | fromjson"#, 1); @@ -72,9 +73,10 @@ yields!(length_float_neg, "-2.5 | length", 2.5); yields!(tojson_fl0, "1.0 | tojson", "1.0"); yields!(tojson_fl1, "1.1 | tojson", "1.1"); -yields!(tojson_nan, "0.0 / 0.0 | tojson", "NaN"); -yields!(tojson_inf, "1.0 / 0.0 | tojson", "Infinity"); -yields!(tojson_ninf, "-1.0 / 0.0 | tojson", "-Infinity"); +// BASHKIT PATCH: NaN renders as null and infinities as the largest double, as in jq. +yields!(tojson_nan, "nan | tojson", "null"); +yields!(tojson_inf, "infinite | tojson", "1.7976931348623157e+308"); +yields!(tojson_ninf, "-infinite | tojson", "-1.7976931348623157e+308"); #[test] fn tonumber() { @@ -104,69 +106,48 @@ fn toboolean() { #[test] fn math_rem() { - // generated with this command with modification for errors and float rounding - // cargo run -- -rn 'def f: -2, -1, 0, 2.1, 3, 2000000001; f as $a | f as $b | "give!(json!(null), \"\($a) / \($b)\", \(try ($a % $b) catch tojson));"' - // TODO: use fail!()? - give(json!(null), "-2 % -2", json!(0)); - give(json!(null), "-2 % -1", json!(0)); - give( - json!(null), - "try (-2 % 0) catch .", - json!("cannot calculate -2 % 0"), - ); - give(json!(null), "-2 % 2.1", json!(-2.0)); - give(json!(null), "-2 % 3", json!(-2)); - give(json!(null), "-2 % 2000000001", json!(-2)); - give(json!(null), "-1 % -2", json!(-1)); - give(json!(null), "-1 % -1", json!(0)); - give( - json!(null), - "try (-1 % 0) catch .", - json!("cannot calculate -1 % 0"), - ); - give(json!(null), "-1 % 2.1", json!(-1.0)); - give(json!(null), "-1 % 3", json!(-1)); - give(json!(null), "-1 % 2000000001", json!(-1)); - give(json!(null), "0 % -2", json!(0)); - give(json!(null), "0 % -1", json!(0)); - give( - json!(null), - "try (0 % 0) catch .", - json!("cannot calculate 0 % 0"), - ); - give(json!(null), "0 % 2.1", json!(0.0)); - give(json!(null), "0 % 3", json!(0)); - give(json!(null), "0 % 2000000001", json!(0)); - give(json!(null), "2.1 % -2 | . * 1000 | round", json!(100)); - give(json!(null), "2.1 % -1 | . * 1000 | round", json!(100)); - give(json!(null), "2.1 % 0 | isnan", json!(true)); - give(json!(null), "2.1 % 2.1", json!(0.0)); - give(json!(null), "2.1 % 3", json!(2.1)); - give(json!(null), "2.1 % 2000000001", json!(2.1)); - give(json!(null), "3 % -2", json!(1)); - give(json!(null), "3 % -1", json!(0)); - give( - json!(null), - "try (3 % 0) catch .", - json!("cannot calculate 3 % 0"), - ); - give(json!(null), "3 % 2.1 | . * 1000 | round", json!(900)); - give(json!(null), "3 % 3", json!(0)); - give(json!(null), "3 % 2000000001", json!(3)); - give(json!(null), "2000000001 % -2", json!(1)); - give(json!(null), "2000000001 % -1", json!(0)); - give( - json!(null), - "try (2000000001 % 0) catch .", - json!("cannot calculate 2000000001 % 0"), - ); - give( - json!(null), - "2000000001 % 2.1 | . * 1000 | round", - json!(1800), // 1000 in jq - ); - give(json!(null), "2000000001 % 3", json!(0)); - give(json!(null), "2000000001 % 2000000001", json!(0)); + // BASHKIT PATCH: jq truncates operands to integers and refuses a zero + // divisor; expectations generated with jq 1.7: + // jq -nc 'try ($a % $b) catch .' over -2, -1, 0, 2.1, 3, 2000000001 + // (jq 1.7 prints the dividend's absolute value in the zero-divisor + // message for negative dividends and `-0` for some zero results; bashkit + // keeps the operand and prints 0). + give(json!(null), "try (-2 % -2) catch .", json!(-0)); + give(json!(null), "try (-2 % -1) catch .", json!(-0)); + give(json!(null), "try (-2 % 0) catch .", json!("number (-2) and number (0) cannot be divided (remainder) because the divisor is zero")); + give(json!(null), "try (-2 % 2.1) catch .", json!(-0)); + give(json!(null), "try (-2 % 3) catch .", json!(-2)); + give(json!(null), "try (-2 % 2000000001) catch .", json!(-2)); + give(json!(null), "try (-1 % -2) catch .", json!(-1)); + give(json!(null), "try (-1 % -1) catch .", json!(-0)); + give(json!(null), "try (-1 % 0) catch .", json!("number (-1) and number (0) cannot be divided (remainder) because the divisor is zero")); + give(json!(null), "try (-1 % 2.1) catch .", json!(-1)); + give(json!(null), "try (-1 % 3) catch .", json!(-1)); + give(json!(null), "try (-1 % 2000000001) catch .", json!(-1)); + give(json!(null), "try (0 % -2) catch .", json!(0)); + give(json!(null), "try (0 % -1) catch .", json!(0)); + give(json!(null), "try (0 % 0) catch .", json!("number (0) and number (0) cannot be divided (remainder) because the divisor is zero")); + give(json!(null), "try (0 % 2.1) catch .", json!(0)); + give(json!(null), "try (0 % 3) catch .", json!(0)); + give(json!(null), "try (0 % 2000000001) catch .", json!(0)); + give(json!(null), "try (2.1 % -2) catch .", json!(0)); + give(json!(null), "try (2.1 % -1) catch .", json!(0)); + give(json!(null), "try (2.1 % 0) catch .", json!("number (2.1) and number (0) cannot be divided (remainder) because the divisor is zero")); + give(json!(null), "try (2.1 % 2.1) catch .", json!(0)); + give(json!(null), "try (2.1 % 3) catch .", json!(2)); + give(json!(null), "try (2.1 % 2000000001) catch .", json!(2)); + give(json!(null), "try (3 % -2) catch .", json!(1)); + give(json!(null), "try (3 % -1) catch .", json!(0)); + give(json!(null), "try (3 % 0) catch .", json!("number (3) and number (0) cannot be divided (remainder) because the divisor is zero")); + give(json!(null), "try (3 % 2.1) catch .", json!(1)); + give(json!(null), "try (3 % 3) catch .", json!(0)); + give(json!(null), "try (3 % 2000000001) catch .", json!(3)); + give(json!(null), "try (2000000001 % -2) catch .", json!(1)); + give(json!(null), "try (2000000001 % -1) catch .", json!(0)); + give(json!(null), "try (2000000001 % 0) catch .", json!("number (2000000001) and number (0) cannot be divided (remainder) because the divisor is zero")); + give(json!(null), "try (2000000001 % 2.1) catch .", json!(1)); + give(json!(null), "try (2000000001 % 3) catch .", json!(0)); + give(json!(null), "try (2000000001 % 2000000001) catch .", json!(0)); } yields!( diff --git a/crates/bashkit/src/builtins/jq/tests.rs b/crates/bashkit/src/builtins/jq/tests.rs index 5eba77f14..c437acd29 100644 --- a/crates/bashkit/src/builtins/jq/tests.rs +++ b/crates/bashkit/src/builtins/jq/tests.rs @@ -1919,3 +1919,119 @@ async fn values_before_a_parse_error_are_processed() { assert_eq!(result.stdout, ""); assert_eq!(result.exit_code, 5); } + +// ========================================================================= +// Numbers and value errors (behavior checked against jq 1.7) +// ========================================================================= + +#[tokio::test] +async fn computed_floats_print_like_jq() { + let out = run_jq_with_args( + &[ + "-nc", + "[pow(2;10), (17/5|floor), (1.5*2), 1e17*1, 0.00001*1, (0.1+0.2), (2.5*2.5)]", + ], + "", + ) + .await + .unwrap(); + assert_eq!(out, "[1024,3,3,1e+17,1e-05,0.30000000000000004,6.25]\n"); +} + +#[tokio::test] +async fn input_number_literals_keep_their_form_until_computed() { + let out = run_jq_with_args(&["-c", "[.[], (.[0] + 1)]"], "[1.0, 2.50]") + .await + .unwrap(); + assert_eq!(out, "[1.0,2.5,2]\n"); +} + +#[tokio::test] +async fn nan_and_infinities_render_like_jq() { + let out = run_jq_with_args( + &[ + "-nc", + "[nan, infinite, -infinite, (nan|isnan), (infinite|isinfinite)]", + ], + "", + ) + .await + .unwrap(); + assert_eq!( + out, + "[null,1.7976931348623157e+308,-1.7976931348623157e+308,true,true]\n" + ); +} + +#[tokio::test] +async fn math_errors_use_jq_wording() { + let out = run_jq_with_args( + &[ + "-c", + "[(try (\"a\"+1) catch .), (try ({\"aaaaaaa\":\"bbbbbbbbbbb\"}+1) catch .), (try ([1]*{}) catch .), (try (1/0) catch .), (try (5%0) catch .)]", + ], + "null", + ) + .await + .unwrap(); + assert_eq!( + out, + concat!( + "[\"string (\\\"a\\\") and number (1) cannot be added\",", + "\"object ({\\\"aaaaaaa\\\":...) and number (1) cannot be added\",", + "\"array ([1]) and object ({}) cannot be multiplied\",", + "\"number (1) and number (0) cannot be divided because the divisor is zero\",", + "\"number (5) and number (0) cannot be divided (remainder) because the divisor is zero\"]\n" + ) + ); +} + +#[tokio::test] +async fn division_by_zero_is_a_runtime_error() { + let result = run_jq_result_with_args(&["-c", "1 / .x"], "{\"x\": 2} {\"x\": 0} {\"x\": 4}") + .await + .unwrap(); + assert_eq!(result.stdout, "0.5\n0.25\n"); + assert_eq!( + result.stderr, + "jq: error (at :0): number (1) and number (0) cannot be divided because the divisor is zero\n" + ); +} + +#[tokio::test] +async fn remainder_truncates_operands() { + let out = run_jq_with_args(&["-nc", "[5 % 3, -5 % 3, 5 % -3, 5.9 % 3, 1e20 % 7]"], "") + .await + .unwrap(); + assert_eq!(out, "[2,-2,2,2,0]\n"); +} + +#[tokio::test] +async fn index_rules_follow_jq() { + let out = run_jq_with_args( + &["-c", ".[-1], .[-10], .[1.7], (try .[\"a\"] catch .)"], + "[1, 2, 3]", + ) + .await + .unwrap(); + assert_eq!( + out, + "3\nnull\n2\n\"Cannot index array with string \\\"a\\\"\"\n" + ); + let result = run_jq_result_with_args(&["getpath([\"a\",0])"], "{\"a\":{\"b\":1}}") + .await + .unwrap(); + assert_eq!( + result.stderr, + "jq: error (at :0): Cannot index object with number\n" + ); + assert_eq!(result.exit_code, 5); +} + +#[tokio::test] +async fn gamma_is_log_gamma() { + let out = run_jq_with_args(&["-nc", "[8 | gamma, significand, logb]"], "") + .await + .unwrap(); + assert_eq!(out, "[8.525161361065413,1,3]\n"); +} diff --git a/knowledge/log.md b/knowledge/log.md index 35d5541d4..29c8fcf23 100644 --- a/knowledge/log.md +++ b/knowledge/log.md @@ -2,6 +2,7 @@ ## 2026-10-07 +* **Fix**: jq numbers and value errors follow jq 1.7: computed floats print with jq's formatting (`1024`, `1e+17`, `1e-05`, NaN as null, infinities as the largest double), input literals keep their token until computed, division by zero and `% 0` are errors, `%` truncates operands, fractional array indexes truncate, indexing an object with a non-string is an error, `gamma` is log-gamma, and math/index errors use jq's wording (also inside `catch`). Patched in the vendored jaq-json (`jqmsg.rs`, `jq_format_f64`, `defs.jq`); L-JQ-004 records the literal-preservation gap. * **Fix**: jq input stream follows jq 1.7: `-n` leaves the stream to `input`/`inputs`, `-s` on empty input gives `[]`, each FILE is read separately (`input_filename`, `input_line_number` as jq counts lines), a missing file is reported and skipped (exit 2), a runtime error prints `jq: error (at FILE:LINE): ...` and moves on to the next input, `input` fails with `No more inputs`, `-e` uses the last output, values before a JSON syntax error are processed. Debian-oracle jq score 67.5% to 74.2%. * **Fix**: awk `for (k in a)` without `PROCINFO["sorted_in"]` now visits keys in gawk's order (integer arrays ascending with other keys first; string keys in gawk's hash-bucket order), so unsorted loops print like Debian awk. All gawk cases in the Debian-oracle awk set now pass (L-AWK-001 narrowed to post-`delete` order). * **Feature**: awk rewritten to target gawk 5.2 (Debian's `awk`). New lexer, parser, async evaluator, ERE translation with POSIX leftmost-longest matching, gawk number formatting and `printf`, gawk's `rand()` sequence, `substr`/`getline`/`typeof` edge cases, `-f`/`-e`/`--` options and gawk's error messages. `system()`, `print | cmd` and `cmd | getline` now run through the sandbox shell (execution-plan driver). Debian-oracle score for awk went from 51% to 94% of 260 cases; the remaining gawk gap is unsorted `for (k in a)` order (L-AWK-001). All resource caps kept; new TM-DOS-128 for awk commands. See foundations/builtins "awk". diff --git a/knowledge/operations/limitations.md b/knowledge/operations/limitations.md index ce0172649..81ff00d2b 100644 --- a/knowledge/operations/limitations.md +++ b/knowledge/operations/limitations.md @@ -152,6 +152,7 @@ pass in CI); only divergences and boundaries are recorded here. | L-JQ-001 | jq | Alternative `//`: jaq errors on `.foo` applied to null instead of returning null (upstream jaq divergence) | 1 skipped spec test | | L-JQ-002 | jq | Regex natives compile the pattern per filter invocation; mapping `test`/`match`/`split` over many inputs can repeat compilation because jaq's native callback has no per-run cache state | `regex_compat.rs::re_native` | | L-JQ-003 | jq | User-defined recursion is bounded by a 64 live-evaluator-context ceiling for host stack safety: non-tail recursive defs (`def s: if length==0 then 0 else .[0] + (.[1:]|s) end`) fail past ~62 levels with `jq: error: recursion limit (64) exceeded` (exit 5). Tail-recursive defs are unaffected and run to thousands of levels; unbounded recursion stops at the execution timeout | `jq_non_tail_recursion_is_bounded_on_a_two_mib_stack`, `jq_recursive_filters_stop_without_host_abort_or_hang`, `finite_user_recursion_stays_available` | +| L-JQ-004 | jq | Input number literals are read as 64-bit floats, so jq 1.7's literal preservation is partial: `1.0` and `2.5` print as written, but `1.50` prints `1.5`, `1e2` prints `100.0`, `-0` prints `0` and integers past 2^64 lose digits. Computed numbers print like jq (`1024`, `1e+17`). Integer arithmetic stays exact where jq rounds to doubles | `input_number_literals_keep_their_form_until_computed` | | L-FMT-001 | fmt | Line breaks come from the uutils Knuth-Plass breaker, not GNU's cost function, so a paragraph can wrap at different words than GNU `fmt` (same width limits, same paragraphs). `-t` indents continuation lines like uutils. GNU `fmt.c` is GPL and cannot be ported into MIT bashkit | `fmt.test.sh` cases marked `bash_diff` | | L-YQ-001 | yq | Expressions are Bashkit jq expressions; mikefarah/yq-only node, comment, style, anchor, tag, filename, and eval-all operators are not implemented | stance | | L-YQ-002 | yq | YAML conversion follows YAML 1.1, deterministically sorts mapping keys at the JSON-value boundary, drops comments/style/anchors, and rejects aliases, custom tags, non-string mapping keys, and non-finite numbers rather than expanding graphs or silently corrupting data | `yaml_aliases_are_rejected_before_expansion`, `yaml_tags_and_non_string_keys_fail_closed`, `yaml_duplicate_keys_and_lossy_numbers_fail_closed`, `inplace_update_is_atomic_and_suppresses_stdout` | diff --git a/knowledge/runtimes/jaq-json-vendor.md b/knowledge/runtimes/jaq-json-vendor.md index 30335ab8a..f8282b72e 100644 --- a/knowledge/runtimes/jaq-json-vendor.md +++ b/knowledge/runtimes/jaq-json-vendor.md @@ -98,6 +98,20 @@ tripped, and a non-emitting loop is not interrupted. very deeply nested values overflow the stack. This predates the vendoring and is a stack-depth issue, not memory growth. +## jq compatibility patches + +Besides the meter, a few `BASHKIT PATCH` hunks make values behave like jq +1.7 where jaq differs, so `catch` and `tostring` see jq's text too: + +- `num.rs` `jq_format_f64`: floats print like jq (`1024`, `1e+17`, `1e-05`, + NaN as `null`, infinities as the largest double). +- `jqmsg.rs` (bashkit-only): jq's wording for math and index errors + (`string ("a") and number (1) cannot be added`, operands cut at 11 bytes). +- `mod.rs`: division and `%` by zero fail, `%` truncates operands, a + fractional array index truncates, an object indexed by a non-string fails. +- `defs.jq`: `nan`/`infinite` without dividing by zero (and the jaq-std + filters built on them), `gamma` as log-gamma. + ## Syncing upstream Part of the [maintenance](../operations/maintenance.md) pass: