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;