From 544e28df8a41dab573622b010c9a6dbfda3aa716 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 22:53:21 +0000 Subject: [PATCH] feat(builtins): implement jq setpath, leaf_paths, fix match/scan Prepend custom jq definitions to every filter to patch jaq limitations: - setpath(p; v): recursive path-setting, not in jaq stdlib - leaf_paths: paths to scalar leaves via paths(scalars) - match: adds "name":null to unnamed captures (jaq omitted it) - scan: uses "g" flag for global matching (jq default behavior) The // alternative operator on null remains skipped (jaq core limitation). Enable 4 previously-skipped spec tests. Skip count: 18 -> 14. Closes #328 https://claude.ai/code/session_01QbjrsMFJbHy5XfHCzA6TjM --- crates/bashkit/src/builtins/jq.rs | 31 +++++++++++++++++++ crates/bashkit/tests/spec_cases/jq/jq.test.sh | 26 ++++++++++------ crates/bashkit/tests/spec_tests.rs | 8 ++--- 3 files changed, 50 insertions(+), 15 deletions(-) diff --git a/crates/bashkit/src/builtins/jq.rs b/crates/bashkit/src/builtins/jq.rs index be265a6b..3ad2eb46 100644 --- a/crates/bashkit/src/builtins/jq.rs +++ b/crates/bashkit/src/builtins/jq.rs @@ -22,6 +22,32 @@ use crate::interpreter::ExecResult; /// produce deeply nested parse trees in jaq. const MAX_JQ_JSON_DEPTH: usize = 100; +/// Custom jq definitions prepended to every filter to patch jaq limitations: +/// - `setpath(p; v)`: recursive path-setting (not in jaq stdlib) +/// - `leaf_paths`: paths to scalar leaves (not in jaq stdlib) +/// - `match` override: adds `"name":null` to unnamed captures +/// - `scan` override: uses "g" flag for global matching (jq default) +const JQ_COMPAT_DEFS: &str = r#" +def setpath(p; v): + if (p | length) == 0 then v + else p[0] as $k | + (if . == null then + if ($k | type) == "number" then [] else {} end + else . end) | + .[$k] |= setpath(p[1:]; v) + end; +def leaf_paths: paths(scalars); +def match(re; flags): + matches(re; flags)[] | + .[0] as $m | + { offset: $m.offset, length: $m.length, string: $m.string, + captures: [.[1:][] | { offset: .offset, length: .length, string: .string, + name: (if has("name") then .name else null end) }] }; +def match(re): match(re; ""); +def scan(re; flags): matches(re; "g" + flags)[] | .[0].string; +def scan(re): scan(re; ""); +"#; + /// RAII guard that restores process env vars when dropped. /// Ensures cleanup even on early-return error paths. struct EnvRestoreGuard(Vec<(String, Option)>); @@ -297,6 +323,11 @@ impl Builtin for Jq { let loader = load::Loader::new(jaq_std::defs().chain(jaq_json::defs())); let arena = load::Arena::default(); + // Prepend compatibility definitions (setpath, leaf_paths, match, scan) + // to override jaq's defaults with jq-compatible behavior. + let compat_filter = format!("{}{}", JQ_COMPAT_DEFS, filter); + let filter = compat_filter.as_str(); + // Parse the filter let program = load::File { code: filter, diff --git a/crates/bashkit/tests/spec_cases/jq/jq.test.sh b/crates/bashkit/tests/spec_cases/jq/jq.test.sh index d3415536..e6dd342c 100644 --- a/crates/bashkit/tests/spec_cases/jq/jq.test.sh +++ b/crates/bashkit/tests/spec_cases/jq/jq.test.sh @@ -512,10 +512,13 @@ echo '{"a":{"b":1}}' | jq 'getpath(["a","b"])' ### end ### jq_setpath -### skip: setpath not available in jaq standard library +# Set value at path echo '{"a":1}' | jq 'setpath(["b"];2)' ### expect -{"a":1,"b":2} +{ + "a": 1, + "b": 2 +} ### end ### jq_del @@ -577,10 +580,15 @@ echo '{"a":{"b":1}}' | jq '[paths]' ### end ### jq_leaf_paths -### skip: leaf_paths not available in jaq standard library +# Get paths to leaf (scalar) values echo '{"a":{"b":1}}' | jq '[leaf_paths]' ### expect -[["a","b"]] +[ + [ + "a", + "b" + ] +] ### end ### jq_any @@ -758,15 +766,15 @@ true ### end ### jq_match -### skip: jaq omits capture name field (real jq includes "name":null) -echo '"hello"' | jq 'match("e(ll)o")' +### bash_diff: jaq/serde_json sorts object keys alphabetically vs jq insertion order +echo '"hello"' | jq -c 'match("e(ll)o")' ### expect -{"offset":1,"length":4,"string":"ello","captures":[{"offset":2,"length":2,"string":"ll","name":null}]} +{"captures":[{"length":2,"name":null,"offset":2,"string":"ll"}],"length":4,"offset":1,"string":"ello"} ### end ### jq_scan -### skip: jaq scan requires explicit "g" flag for global match -echo '"hello hello"' | jq '[scan("hel")]' +# Scan for all regex matches +echo '"hello hello"' | jq -c '[scan("hel")]' ### expect ["hel","hel"] ### end diff --git a/crates/bashkit/tests/spec_tests.rs b/crates/bashkit/tests/spec_tests.rs index 85c3ce54..01b47315 100644 --- a/crates/bashkit/tests/spec_tests.rs +++ b/crates/bashkit/tests/spec_tests.rs @@ -8,7 +8,7 @@ //! - `### skip: reason` - Skip test entirely (not run in any test) //! - `### bash_diff: reason` - Known difference from real bash (runs in spec tests, excluded from comparison) //! -//! ## Skipped Tests (15 total) +//! ## Skipped Tests (14 total) //! //! Actual `### skip:` markers across spec test files: //! @@ -21,12 +21,8 @@ //! - [ ] od output format varies //! - [ ] hexdump -C output format varies //! -//! ### jq.test.sh (5 skipped) +//! ### jq.test.sh (1 skipped) //! - [ ] jaq errors on .foo applied to null instead of returning null for // -//! - [ ] setpath not available in jaq standard library -//! - [ ] leaf_paths not available in jaq standard library -//! - [ ] jaq omits capture name field (real jq includes "name":null) -//! - [ ] jaq scan requires explicit "g" flag for global match //! //! ### python.test.sh (8 skipped) //! - [ ] Monty does not support set & and | operators yet