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