Skip to main content

drep/llm/
json_parsing.rs

1//! Tolerant JSON extraction from LLM responses.
2//!
3//! The model returns prose-wrapped, fenced, or slightly malformed JSON. Port
4//! the strategy ladder from `drep/llm/json_parsing.py` (minus the single-quote
5//! repair and the fuzzy-inference fallback), first success wins:
6//!
7//! 1. Parse the whole response directly - a response that is already JSON is
8//!    the answer, even if it happens to quote a fence inside a string.
9//! 2. Extract a fenced block (with or without a `json` info string) and use
10//!    its body.
11//! 3. Parse the fenced body directly.
12//! 4. Repair trailing commas before `}` or `]` and parse.
13//! 5. Balance unclosed braces/brackets and parse.
14//!
15//! Strategies 1-4 that succeed return [`Extracted::Complete`]. Only strategy 5
16//! returns [`Extracted::Truncated`].
17//!
18//! ## Why `Truncated` is a type, not a log line
19//!
20//! Recovering truncated JSON yields a *partial* findings list. While LLM
21//! findings only inform, under `--fail-on` a truncated response could
22//! silently omit the one blocking finding and the gate would pass. Phase 4
23//! will treat `Truncated` as *unanalyzed* rather than clean when gating.
24//! Losing this distinction reintroduces the exact failure the whole project
25//! exists to prevent.
26//!
27//! ## What is deliberately NOT ported
28//!
29//! - **Single-quote repair.** It corrupts any JSON string containing an
30//!   apostrophe (`"don't"`, `` "`os` imported but unused" ``), which finding
31//!   messages routinely do.
32//! - **`fuzzy_inference`.** It guessed field values out of prose with
33//!   per-field regexes; a wrong finding is worse than a missing one.
34
35use std::sync::LazyLock;
36
37use regex::Regex;
38use serde_json::Value;
39
40/// What `extract_json` returns when it can pull a JSON value out of the model
41/// output.
42#[derive(Debug, Clone, PartialEq, Eq)]
43pub enum Extracted {
44    /// Parsed cleanly: the response is a complete JSON document as far as we
45    /// can tell.
46    Complete(Value),
47    /// Parsed only after closing unbalanced braces/brackets - the response
48    /// was cut off, so the [`Value`] is a PREFIX of what the model meant to
49    /// say. The caller must decide whether to trust a partial result.
50    Truncated(Value),
51}
52
53/// The opening and closing tags of an inline reasoning block.
54const THINK_OPEN: &str = "<think>";
55const THINK_CLOSE: &str = "</think>";
56
57/// Drop a leading `<think>...</think>` block, returning what follows it.
58///
59/// Reasoning models are supposed to stream deliberation on a side channel -
60/// `reasoning_content` on DeepSeek and z.ai, `thinking` blocks on Anthropic -
61/// which the SDK routes away from content before drep ever sees it. Several
62/// OpenAI-compatible servers do not: MiniMax's M-series and most local
63/// llama.cpp and MLX builds of Qwen emit the whole trace inline at the head of
64/// `message.content`, wrapped in these tags.
65///
66/// That breaks the ladder rather than merely adding noise. Deliberation about
67/// a code review quotes code, so the reasoning carries fenced blocks of its
68/// own; `FENCE_RE` takes the *first* fence in the text, so the ladder selected
69/// the reasoning's sample, strategies 2 through 4 all failed on it, and the
70/// file came back `Unparseable` - which by design neither fails over nor
71/// retries, so the configured fallback was never reached and every file failed.
72///
73/// Three properties, each load-bearing:
74///
75/// - **Anchored at the start.** A `<think>` appearing anywhere else is content -
76///   a finding about a file that contains the tag, most obviously - and
77///   stripping it would rewrite the model's answer.
78/// - **A closing tag is required.** Without one the block never ended, so the
79///   answer never arrived; `Unparseable` is then the honest outcome and
80///   swallowing the rest of the response would hide it.
81/// - **Leading whitespace is tolerated** before the opening tag but the tag
82///   itself must come first, because servers differ on whether they emit a
83///   newline ahead of it.
84fn strip_reasoning_block(content: &str) -> &str {
85    let Some(rest) = content.trim_start().strip_prefix(THINK_OPEN) else {
86        return content;
87    };
88    let Some(close) = rest.find(THINK_CLOSE) else {
89        return content;
90    };
91    &rest[close + THINK_CLOSE.len()..]
92}
93
94/// Strip a fenced JSON block out of `content`, returning the body.
95///
96/// Returns `None` if there is no fence. Trims surrounding whitespace from the
97/// body so callers can hand it to `serde_json` without further scrubbing.
98static FENCE_RE: LazyLock<Regex> = LazyLock::new(|| {
99    // ``` (literal) + optional `json` info string + newline + body + newline + ```
100    // Non-greedy body so a second fence in the same text doesn't swallow
101    // everything in between.
102    // (?s) so `.` crosses newlines. Without it only a single-line body matches,
103    // and real model output is pretty-printed - every fenced multi-line response
104    // fell through to `None` and the file was reported unanalyzed.
105    // Also tolerate CRLF, trailing spaces after the info string, and an indented
106    // closing fence, all of which real responses produce.
107    Regex::new(r"(?s)```(?:json)?[ \t]*\r?\n(.*?)\r?\n[ \t]*```")
108        .expect("FENCE_RE is a constant regex")
109});
110
111/// Match a trailing comma before `}` or `]`, capturing the closing delimiter
112/// so `replace_all` can drop just the comma.
113/// Drop commas that sit immediately before a closing `}` or `]`, ignoring any
114/// that appear inside a JSON string.
115///
116/// This was a regex (`,(\s*[}\]])`) applied to the raw text, which had no idea
117/// what a string was: `{"a":",}","b":1,}` was "repaired" into `{"a":"}","b":1}`,
118/// parsing as `Complete` with the string value silently rewritten from `,}` to
119/// `}`. That is the same defect class as the single-quote repair this module
120/// deliberately does not implement - a repair that damages valid input.
121fn strip_trailing_commas(s: &str) -> String {
122    let bytes = s.as_bytes();
123    let mut out = String::with_capacity(s.len());
124    let mut in_string = false;
125    let mut escape = false;
126
127    for (i, ch) in s.char_indices() {
128        if escape {
129            escape = false;
130            out.push(ch);
131            continue;
132        }
133        if in_string {
134            match ch {
135                '\\' => escape = true,
136                '"' => in_string = false,
137                _ => {}
138            }
139            out.push(ch);
140            continue;
141        }
142        if ch == '"' {
143            in_string = true;
144            out.push(ch);
145            continue;
146        }
147        if ch == ',' {
148            // Look ahead past whitespace for a closing delimiter. Only a comma
149            // outside a string can be structural, which is why this check lives
150            // here rather than in a regex over the whole text.
151            let rest = &bytes[i + 1..];
152            let next = rest.iter().find(|b| !b.is_ascii_whitespace());
153            if matches!(next, Some(b'}') | Some(b']')) {
154                continue;
155            }
156        }
157        out.push(ch);
158    }
159    out
160}
161
162/// Run the strategy ladder against one LLM response and return the first thing
163/// that parses.
164///
165/// Returns `None` when every strategy fails. An empty input also returns
166/// `None` - there is nothing to parse.
167pub fn extract_json(content: &str) -> Option<Extracted> {
168    // Strategy 0: drop a leading reasoning block. Must run before the fence
169    // ladder, because the reasoning routinely contains a fence of its own and
170    // `FENCE_RE` takes the *first* one.
171    let content = strip_reasoning_block(content);
172
173    // Strategy 1: parse the whole response first, before looking for a fence.
174    //
175    // The order matters and this is the bug it fixes. A response that is
176    // *already* JSON can still contain a fence inside a string value - a review
177    // of code that discusses code fences says "```" in a finding's message, and
178    // this file's own review is what hit it. `FENCE_RE` then matched that inner
179    // fence, `working` became its body, and strategies 2-4 all ran on prose,
180    // reporting `Unparseable` for a response that was valid JSON from the first
181    // character. drep's own pre-push gate caught it on `json_parsing.rs`.
182    //
183    // Trying the whole thing first costs one `serde_json::from_str` on a string
184    // that is usually not JSON, and cannot mis-fire: if the content parses, it
185    // *is* the answer.
186    if let Ok(value) = serde_json::from_str::<Value>(content.trim()) {
187        return Some(Extracted::Complete(value));
188    }
189
190    // Strategy 2: extract from a markdown fence if one is present. The fence
191    // path applies even when the body itself fails to parse; strategies 3-5
192    // then run on the body alone.
193    let working = FENCE_RE
194        .captures(content)
195        .map(|caps| caps.get(1).unwrap().as_str().trim().to_string())
196        .unwrap_or_else(|| content.to_string());
197
198    // Strategy 3: direct parse of the fenced body.
199    if let Ok(value) = serde_json::from_str::<Value>(&working) {
200        return Some(Extracted::Complete(value));
201    }
202
203    // Strategy 4: repair trailing commas. This is the only repair we attempt
204    // - single-quote substitution is deliberately omitted because it
205    // corrupts apostrophes inside strings.
206    let repaired = strip_trailing_commas(&working);
207    if let Ok(value) = serde_json::from_str::<Value>(&repaired) {
208        return Some(Extracted::Complete(value));
209    }
210
211    // Strategy 5: truncation recovery. Count open vs. close braces/brackets
212    // outside of strings, append the missing closers, and parse. Returning
213    // `Truncated` is the load-bearing signal: a successful parse here means
214    // the response was cut off and the value is a prefix of the intended
215    // document.
216    // Balance first, then strip: a truncated response very often ends mid-element
217    // (`{"a":1,`), where the dangling comma has no closing delimiter after it yet
218    // and so is invisible to the stripper. Appending the closers first turns it
219    // into `{"a":1,}`, which the stripper then repairs. Doing it the other way
220    // round leaves the comma in place and the parse fails.
221    if let Some(balanced) = balance_unclosed(&repaired).map(|b| strip_trailing_commas(&b))
222        && let Ok(value) = serde_json::from_str::<Value>(&balanced)
223    {
224        return Some(Extracted::Truncated(value));
225    }
226
227    None
228}
229
230/// Walk `s` and return it with the missing closing delimiters appended, in the
231/// order that actually closes the structure.
232///
233/// Uses a **stack**, not per-delimiter counters. Counting how many `{` and `[`
234/// are unclosed tells you how many closers to add but not their order, and
235/// order is load-bearing: `{"xs":[{"k":1` is missing `}]}`, while independent
236/// counters would emit `}}]` and fail to parse. Anything nested more than one
237/// level deep hits this.
238///
239/// Returns `None` when there is nothing to do - the input is already balanced -
240/// because the caller invokes this only after strategies 2 and 3 fail, and a
241/// balanced re-parse would be identical to the failed parse. Distinguishing
242/// "already balanced" from "would not parse even balanced" keeps the
243/// `Truncated` signal meaningful: it fires only when a closing delimiter was
244/// genuinely missing.
245fn balance_unclosed(s: &str) -> Option<String> {
246    // Holds the closer each open delimiter is waiting for, innermost last.
247    let mut expected: Vec<char> = Vec::new();
248    let mut in_string = false;
249    let mut escape = false;
250
251    for c in s.chars() {
252        if escape {
253            // The previous character was a backslash inside a string; this
254            // character is the escaped one and has no structural meaning.
255            escape = false;
256            continue;
257        }
258        if in_string {
259            match c {
260                '\\' => escape = true,
261                '"' => in_string = false,
262                _ => {}
263            }
264            continue;
265        }
266        match c {
267            '"' => in_string = true,
268            '{' => expected.push('}'),
269            '[' => expected.push(']'),
270            '}' | ']' => {
271                // A mismatched closer means the document is malformed rather
272                // than truncated; popping regardless lets the re-parse fail,
273                // which is the honest outcome.
274                expected.pop();
275            }
276            _ => {}
277        }
278    }
279
280    // Only `expected.is_empty()` is checked. An unterminated string is also
281    // unrecoverable, but appending closers to it produces JSON that cannot
282    // parse, so the caller falls through to `None` on its own - guarding it
283    // here as well would be a branch no input can distinguish.
284    if expected.is_empty() {
285        return None;
286    }
287
288    let mut balanced = String::with_capacity(s.len() + expected.len());
289    balanced.push_str(s);
290    balanced.extend(expected.iter().rev());
291    Some(balanced)
292}
293#[cfg(test)]
294mod tests;