Skip to content

Commit 428fa20

Browse files
committed
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
1 parent 72768ec commit 428fa20

File tree

3 files changed

+50
-15
lines changed

3 files changed

+50
-15
lines changed

crates/bashkit/src/builtins/jq.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,32 @@ use crate::interpreter::ExecResult;
2222
/// produce deeply nested parse trees in jaq.
2323
const MAX_JQ_JSON_DEPTH: usize = 100;
2424

25+
/// Custom jq definitions prepended to every filter to patch jaq limitations:
26+
/// - `setpath(p; v)`: recursive path-setting (not in jaq stdlib)
27+
/// - `leaf_paths`: paths to scalar leaves (not in jaq stdlib)
28+
/// - `match` override: adds `"name":null` to unnamed captures
29+
/// - `scan` override: uses "g" flag for global matching (jq default)
30+
const JQ_COMPAT_DEFS: &str = r#"
31+
def setpath(p; v):
32+
if (p | length) == 0 then v
33+
else p[0] as $k |
34+
(if . == null then
35+
if ($k | type) == "number" then [] else {} end
36+
else . end) |
37+
.[$k] |= setpath(p[1:]; v)
38+
end;
39+
def leaf_paths: paths(scalars);
40+
def match(re; flags):
41+
matches(re; flags)[] |
42+
.[0] as $m |
43+
{ offset: $m.offset, length: $m.length, string: $m.string,
44+
captures: [.[1:][] | { offset: .offset, length: .length, string: .string,
45+
name: (if has("name") then .name else null end) }] };
46+
def match(re): match(re; "");
47+
def scan(re; flags): matches(re; "g" + flags)[] | .[0].string;
48+
def scan(re): scan(re; "");
49+
"#;
50+
2551
/// RAII guard that restores process env vars when dropped.
2652
/// Ensures cleanup even on early-return error paths.
2753
struct EnvRestoreGuard(Vec<(String, Option<String>)>);
@@ -297,6 +323,11 @@ impl Builtin for Jq {
297323
let loader = load::Loader::new(jaq_std::defs().chain(jaq_json::defs()));
298324
let arena = load::Arena::default();
299325

326+
// Prepend compatibility definitions (setpath, leaf_paths, match, scan)
327+
// to override jaq's defaults with jq-compatible behavior.
328+
let compat_filter = format!("{}{}", JQ_COMPAT_DEFS, filter);
329+
let filter = compat_filter.as_str();
330+
300331
// Parse the filter
301332
let program = load::File {
302333
code: filter,

crates/bashkit/tests/spec_cases/jq/jq.test.sh

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -512,10 +512,13 @@ echo '{"a":{"b":1}}' | jq 'getpath(["a","b"])'
512512
### end
513513

514514
### jq_setpath
515-
### skip: setpath not available in jaq standard library
515+
# Set value at path
516516
echo '{"a":1}' | jq 'setpath(["b"];2)'
517517
### expect
518-
{"a":1,"b":2}
518+
{
519+
"a": 1,
520+
"b": 2
521+
}
519522
### end
520523

521524
### jq_del
@@ -577,10 +580,15 @@ echo '{"a":{"b":1}}' | jq '[paths]'
577580
### end
578581

579582
### jq_leaf_paths
580-
### skip: leaf_paths not available in jaq standard library
583+
# Get paths to leaf (scalar) values
581584
echo '{"a":{"b":1}}' | jq '[leaf_paths]'
582585
### expect
583-
[["a","b"]]
586+
[
587+
[
588+
"a",
589+
"b"
590+
]
591+
]
584592
### end
585593

586594
### jq_any
@@ -758,15 +766,15 @@ true
758766
### end
759767

760768
### jq_match
761-
### skip: jaq omits capture name field (real jq includes "name":null)
762-
echo '"hello"' | jq 'match("e(ll)o")'
769+
### bash_diff: jaq/serde_json sorts object keys alphabetically vs jq insertion order
770+
echo '"hello"' | jq -c 'match("e(ll)o")'
763771
### expect
764-
{"offset":1,"length":4,"string":"ello","captures":[{"offset":2,"length":2,"string":"ll","name":null}]}
772+
{"captures":[{"length":2,"name":null,"offset":2,"string":"ll"}],"length":4,"offset":1,"string":"ello"}
765773
### end
766774

767775
### jq_scan
768-
### skip: jaq scan requires explicit "g" flag for global match
769-
echo '"hello hello"' | jq '[scan("hel")]'
776+
# Scan for all regex matches
777+
echo '"hello hello"' | jq -c '[scan("hel")]'
770778
### expect
771779
["hel","hel"]
772780
### end

crates/bashkit/tests/spec_tests.rs

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88
//! - `### skip: reason` - Skip test entirely (not run in any test)
99
//! - `### bash_diff: reason` - Known difference from real bash (runs in spec tests, excluded from comparison)
1010
//!
11-
//! ## Skipped Tests (18 total)
11+
//! ## Skipped Tests (14 total)
1212
//!
1313
//! Actual `### skip:` markers across spec test files:
1414
//!
@@ -21,12 +21,8 @@
2121
//! - [ ] od output format varies
2222
//! - [ ] hexdump -C output format varies
2323
//!
24-
//! ### jq.test.sh (5 skipped)
24+
//! ### jq.test.sh (1 skipped)
2525
//! - [ ] jaq errors on .foo applied to null instead of returning null for //
26-
//! - [ ] setpath not available in jaq standard library
27-
//! - [ ] leaf_paths not available in jaq standard library
28-
//! - [ ] jaq omits capture name field (real jq includes "name":null)
29-
//! - [ ] jaq scan requires explicit "g" flag for global match
3026
//!
3127
//! ### python.test.sh (8 skipped)
3228
//! - [ ] Monty does not support set & and | operators yet

0 commit comments

Comments
 (0)