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;