Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 12 additions & 7 deletions crates/bashkit/docs/jq.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,15 +55,22 @@ shim adds or overrides:
| `leaf_paths` | Defined as `paths(scalars)` since jaq's stdlib lacks it. |
| `match(re; flags)` / `match(re)` | Overridden to add `"name": null` to unnamed captures, matching jq output. |
| `scan(re; flags)` / `scan(re)` | Overridden so `scan` defaults to global ("g") matching, matching jq. |
| `input_filename` | Stub returning `null` (#1486). Bashkit reads inputs as a single concatenated stream via shell redirection, so per-input filenames are not tracked. |
| `input_line_number` | Stub returning `0` (#1486). Per-line input tracking is not implemented. |
| `input` / `inputs` | Real jaq implementations, pull from the shared input iterator. |
| `input_filename` | Name of the file the current value came from (`null` for stdin). |
| `input_line_number` | Lines read so far, as jq counts them (jq reads a line at a time). |
| `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.). |

## Errors

Filter parse failures and runtime errors return exit code `3` and `5`
respectively, matching jq. Long error operands are summarised so failures
Filter compile failures exit `3`. A runtime error is reported as
`jq: error (at FILE:LINE): ...` (`<stdin>`, or `<unknown>` under `-n`) and jq
moves on to the next input; the exit status follows the last input (`5` if
it failed). `error("msg")` prints `msg` unquoted, other values get jq's
`(not a string)` marker. Values before a JSON syntax error are processed,
then the error exits `5`. A file that cannot be opened prints
`Could not open file F: reason`, is skipped, and makes the exit status `2`.
With `-e` the last output decides: `1` for `null`/`false`, `4` when nothing
was output. Long error operands are summarised so failures
do not blow up an LLM context window, see
[#1485](https://github.com/everruns/bashkit/issues/1485).

Expand All @@ -86,8 +93,6 @@ need a larger `max_live_intermediate_bytes`.
Bashkit's jq is intentionally minimal in places where the host model differs
from upstream jq:

- File inputs are concatenated into a single stream by the shell, so
per-file metadata (`input_filename`, `input_line_number`) is stubbed.
- Exotic numeric formatting modes (`@base32`, `@base64d`, etc.) follow
whatever `jaq-json` ships.

Expand Down
35 changes: 19 additions & 16 deletions crates/bashkit/src/builtins/jq/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -287,36 +287,39 @@ fn format_f64_canonical(f: f64) -> String {
/// Parse multiple JSON values from a stream (handles NDJSON, multi-line,
/// concatenated). Each value is depth-checked and keeps its key order.
pub(super) fn parse_json_stream(input: &str) -> std::result::Result<Vec<JqJson>, String> {
use serde::de::DeserializeSeed;

let trimmed = input.trim();
if trimmed.is_empty() {
return Ok(Vec::new());
match parse_json_stream_partial(input) {
(vals, None) => Ok(vals),
(_, Some(e)) => Err(e),
}
}

/// Parse a whitespace-separated JSON stream, returning the values before
/// the first syntax error together with that error.
pub(super) fn parse_json_stream_partial(input: &str) -> (Vec<JqJson>, Option<String>) {
use serde::de::DeserializeSeed;

let too_deep = std::cell::Cell::new(None);
let mut de = serde_json::Deserializer::from_str(trimmed);
let mut vals = Vec::new();
loop {
// Skip inter-value whitespace; stop at end of input.
if de.end().is_ok() {
break;
}
// Split into values first so a literal glued to junk (`1\u{1}2`) is one
// invalid token, as in jq, instead of a valid `1` followed by an error.
for (start, end) in super::input::value_spans(input) {
let too_deep = std::cell::Cell::new(None);
let mut de = serde_json::Deserializer::from_str(&input[start..end]);
let seed = JqSeed {
depth: 0,
max: MAX_JQ_JSON_DEPTH,
too_deep: &too_deep,
};
match seed.deserialize(&mut de) {
match seed.deserialize(&mut de).and_then(|v| de.end().map(|()| v)) {
Ok(v) => vals.push(v),
Err(e) => {
return Err(too_deep
let msg = too_deep
.take()
.unwrap_or_else(|| format!("jq: invalid JSON: {e}")));
.unwrap_or_else(|| format!("jq: invalid JSON: {e}"));
return (vals, Some(msg));
}
}
}
Ok(vals)
(vals, None)
}

#[cfg(test)]
Expand Down
27 changes: 22 additions & 5 deletions crates/bashkit/src/builtins/jq/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -79,16 +79,33 @@ pub(super) fn format_load_errors<P>(errs: jaq_core::load::Errors<&str, P>) -> St
format!("jq: error: {}\n", truncate_text(&body, MAX_JQ_DIAG_CHARS))
}

/// Format jaq runtime errors without dumping full input values into stderr.
/// Format a jaq runtime error the way jq reports an error while filtering
/// one input: `jq: error (at <file>:<line>): ...`. `error("msg")` prints the
/// message as is, `error({...})` adds jq's `(not a string)` marker.
///
/// stderr is agent-facing API: value-bearing diagnostics summarize operand
/// types and keep generic fallbacks bounded.
pub(super) fn format_runtime_error(error: &jaq_core::Error<Val>) -> String {
pub(super) fn format_runtime_error_at(error: &jaq_core::Error<Val>, location: &str) -> String {
// THREAT[TM-DOS-110]: an error quoting a huge or deeply shared value
// would render it in full; stop rendering past what the message keeps.
let message = render_capped(error, 64 * 1024);
let body = humanize(&message)
.unwrap_or_else(|| capitalize_first(&truncate_text(&message, MAX_JQ_RUNTIME_ERROR_CHARS)));
format!("jq: error: {body}\n")
let thrown = super::convert::val_to_jq_capped(&error.clone().into_val(), 64 * 1024);
match thrown {
Some(super::convert::JqJson::String(s)) if message.starts_with('"') => format!(
"jq: error (at {location}): {}\n",
truncate_text(&s, MAX_JQ_RUNTIME_ERROR_CHARS)
),
Some(super::convert::JqJson::String(_)) | None => {
let body = humanize(&message).unwrap_or_else(|| {
capitalize_first(&truncate_text(&message, MAX_JQ_RUNTIME_ERROR_CHARS))
});
format!("jq: error (at {location}): {body}\n")
}
Some(_) => format!(
"jq: error (at {location}) (not a string): {}\n",
truncate_text(&message, MAX_JQ_RUNTIME_ERROR_CHARS)
),
}
}

/// `Display` into a string, stopping after `max` bytes.
Expand Down
109 changes: 109 additions & 0 deletions crates/bashkit/src/builtins/jq/input.rs
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,93 @@ pub(super) fn normalize<'a>(
})
}

/// Byte spans of the top-level values in a JSON text stream, split the way
/// jq tokenizes: strings, bracketed values, and literal tokens that run to
/// the next whitespace or structural character. The parser validates each
/// span; this only finds boundaries.
pub(super) fn value_spans(text: &str) -> Vec<(usize, usize)> {
let b = text.as_bytes();
let mut out = Vec::new();
let mut i = 0usize;
loop {
while i < b.len() && b[i].is_ascii_whitespace() {
i += 1;
}
if i >= b.len() {
break;
}
let start = i;
let scalar = !matches!(b[i], b'[' | b'{' | b'"');
let (mut depth, mut in_str, mut esc) = (0usize, false, false);
while i < b.len() {
let c = b[i];
i += 1;
if in_str {
if esc {
esc = false;
} else if c == b'\\' {
esc = true;
} else if c == b'"' {
in_str = false;
if depth == 0 {
break;
}
}
continue;
}
if scalar {
if c.is_ascii_whitespace()
|| matches!(c, b'[' | b'{' | b'"' | b']' | b'}' | b',' | b':')
{
i -= 1;
if i == start {
// A stray structural character: its own (invalid) span.
i += 1;
}
break;
}
continue;
}
match c {
b'"' => in_str = true,
b'[' | b'{' => depth += 1,
b']' | b'}' => {
depth = depth.saturating_sub(1);
if depth == 0 {
break;
}
}
_ => {}
}
}
out.push((start, i));
}
out
}

/// Line number jq reports for each top-level value of `text`: jq reads
/// input a line at a time, so a value counts every newline up to the end
/// of the line it ends on.
pub(super) fn value_lines(text: &str) -> Vec<usize> {
let b = text.as_bytes();
let (mut counted, mut lines) = (0usize, 0usize);
value_spans(text)
.into_iter()
.map(|(_, end)| {
let last = end.saturating_sub(1);
let eol = b[last..]
.iter()
.position(|&c| c == b'\n')
.map_or(b.len(), |p| last + p + 1);
if eol > counted {
lines += b[counted..eol].iter().filter(|&&c| c == b'\n').count();
counted = eol;
}
lines
})
.collect()
}

#[cfg(test)]
mod tests {
use std::sync::Arc;
Expand Down Expand Up @@ -173,4 +260,26 @@ mod tests {
))
));
}

#[test]
fn value_spans_split_like_jq() {
let text = "1 \"a b\" [1, [2]] {\"k\": \"}\"}2[3]";
let parts: Vec<&str> = super::value_spans(text)
.into_iter()
.map(|(a, b)| &text[a..b])
.collect();
assert_eq!(
parts,
["1", "\"a b\"", "[1, [2]]", "{\"k\": \"}\"}", "2", "[3]"]
);
let glued = "1\u{1}2";
assert_eq!(super::value_spans(glued), [(0, glued.len())]);
}

#[test]
fn value_lines_count_lines_read() {
assert_eq!(super::value_lines("1\n2\n3"), [1, 2, 2]);
assert_eq!(super::value_lines("[\n1\n]\n2"), [3, 3]);
assert_eq!(super::value_lines("1 2"), [0, 0]);
}
}
Loading
Loading