Skip to main content

lc_core/structured_output/
parser.rs

1// src/core/structured_output/parser.rs
2//! Incremental JSON parser for handling partial/incomplete JSON from streaming LLM output.
3
4use serde_json::Value;
5
6/// Errors produced by `PartialJsonParser`.
7#[derive(Debug, Clone, thiserror::Error)]
8pub enum PartialJsonError {
9    /// The buffer does not yet contain parseable JSON.
10    #[error("Incomplete JSON: {0}")]
11    Incomplete(String),
12
13    /// The accumulated text is not valid JSON even after repair attempts.
14    #[error("Invalid JSON: {0}")]
15    Invalid(String),
16}
17
18/// Incremental JSON parser that can handle partial/incomplete JSON.
19///
20/// Builds up a string token by token and attempts to parse at each step,
21/// returning the best partial result possible. This is designed for streaming
22/// LLM output where JSON arrives in small chunks and may be incomplete until
23/// the stream finishes.
24///
25/// # Strategy
26///
27/// 1. Accumulate tokens into an internal buffer.
28/// 2. On each `push_and_parse`, attempt to parse the buffer as complete JSON.
29/// 3. If that fails, try to repair the partial JSON by closing unclosed
30///    brackets/braces and truncating incomplete string values.
31/// 4. If repair yields valid JSON, return it; otherwise return
32///    `PartialJsonError::Incomplete`.
33///
34/// # Example
35///
36/// ```ignore
37/// let mut parser = PartialJsonParser::new();
38/// // Simulating token-by-token LLM output
39/// let _ = parser.push_and_parse(r#"{"name":"#); // Incomplete
40/// let v = parser.push_and_parse(r#""Alice","age":30}"#); // Ok({"name":"Alice","age":30})
41/// ```
42pub struct PartialJsonParser {
43    buffer: String,
44    depth: usize,
45    in_string: bool,
46    escape_next: bool,
47}
48
49impl PartialJsonParser {
50    /// Create a new, empty parser.
51    pub fn new() -> Self {
52        Self {
53            buffer: String::new(),
54            depth: 0,
55            in_string: false,
56            escape_next: false,
57        }
58    }
59
60    /// Push a new token and attempt to parse the accumulated buffer.
61    ///
62    /// Returns `Ok(value)` if the buffer (after optional repair) yields valid
63    /// JSON, or `Err(PartialJsonError::Incomplete)` if it does not yet form
64    /// any parseable JSON.
65    pub fn push_and_parse(&mut self, token: &str) -> Result<Value, PartialJsonError> {
66        // Update parser state by scanning the new token
67        for ch in token.chars() {
68            if self.escape_next {
69                self.escape_next = false;
70                continue;
71            }
72            if ch == '\\' && self.in_string {
73                self.escape_next = true;
74                continue;
75            }
76            if ch == '"' {
77                self.in_string = !self.in_string;
78                continue;
79            }
80            if !self.in_string {
81                match ch {
82                    '{' | '[' => self.depth += 1,
83                    '}' | ']' => {
84                        if self.depth > 0 {
85                            self.depth -= 1;
86                        }
87                    }
88                    _ => {}
89                }
90            }
91        }
92
93        // Ensure we only push at character boundaries (M37: UTF-8 boundary check)
94        if token.is_char_boundary(0) {
95            self.buffer.push_str(token);
96        } else {
97            // Find the first valid char boundary
98            let mut pos = 0;
99            while pos < token.len() && !token.is_char_boundary(pos) {
100                pos += 1;
101            }
102            self.buffer.push_str(&token[pos..]);
103        }
104
105        // H4: 模型常把 JSON 包在 ```json ... ``` 围栏里(或先输出"结果是:"等
106        // 前导文本)。解析时先剥掉围栏/前导文本,只看真正的 JSON 值;否则带
107        // 围栏的合法 JSON 会被判为无法解析,流式结构化输出整条路径不可用。
108        let json = Self::strip_markdown_fence(&self.buffer);
109
110        // Fast path: try full parse first
111        if let Ok(value) = serde_json::from_str::<Value>(json) {
112            return Ok(value);
113        }
114
115        // Only attempt repair if we have at least opened a structure
116        let trimmed = json.trim();
117        if self.depth > 0 || trimmed.starts_with('{') || trimmed.starts_with('[') {
118            let repaired = Self::repair_partial_json(json);
119            if let Ok(value) = serde_json::from_str::<Value>(&repaired) {
120                return Ok(value);
121            }
122        }
123
124        Err(PartialJsonError::Incomplete(format!(
125            "Buffer has {} chars, depth={}",
126            self.buffer.len(),
127            self.depth
128        )))
129    }
130
131    /// Get the final complete value.
132    ///
133    /// Call this when the stream has ended. It first tries to parse the
134    /// fence-stripped buffer, then falls back to the repaired version.
135    pub fn finalize(self) -> Result<Value, PartialJsonError> {
136        // H4: 同样先剥 ```json 围栏,否则带围栏的完整 JSON 会被误判为 Invalid。
137        let json = Self::strip_markdown_fence(&self.buffer);
138
139        // Try full parse
140        if let Ok(value) = serde_json::from_str::<Value>(json) {
141            return Ok(value);
142        }
143
144        // Try repaired
145        let repaired = Self::repair_partial_json(json);
146        serde_json::from_str::<Value>(&repaired).map_err(|e| {
147            PartialJsonError::Invalid(format!(
148                "Failed to parse final buffer ({} chars): {}. Buffer: {}",
149                self.buffer.len(),
150                e,
151                &self.buffer[..std::cmp::min(200, self.buffer.len())]
152            ))
153        })
154    }
155
156    /// Return a reference to the current buffer contents.
157    pub fn buffer(&self) -> &str {
158        &self.buffer
159    }
160
161    /// Whether the parser is currently inside a JSON string.
162    pub fn is_in_string(&self) -> bool {
163        self.in_string
164    }
165
166    /// Current nesting depth of brackets/braces.
167    pub fn depth(&self) -> usize {
168        self.depth
169    }
170
171    /// Strip a markdown code fence and any leading/trailing non-JSON text,
172    /// returning the slice that holds the top-level JSON value.
173    ///
174    /// Streaming-safe: only reads what has been accumulated so far, so a
175    /// partially-delivered object still yields its partial JSON (e.g. while the
176    /// model is still emitting the closing brace).
177    ///
178    /// # Rules
179    ///
180    /// - Leading text up to the first `{` or `[` is dropped (covers a ```json
181    ///   fence line, "结果是:" prose, and whitespace).
182    /// - Trailing text after the top-level structure closes is dropped (covers
183    ///   the closing ``` fence).
184    /// - Returns `""` when no `{`/`[` has been seen yet (e.g. the buffer is
185    ///   still just "```json").
186    pub(crate) fn strip_markdown_fence(buffer: &str) -> &str {
187        let bytes = buffer.as_bytes();
188        // First byte that opens the top-level JSON value. `{`/`[` are ASCII, so
189        // this byte index is always a UTF-8 char boundary.
190        let start = match bytes.iter().position(|b| *b == b'{' || *b == b'[') {
191            Some(i) => i,
192            None => return "",
193        };
194
195        // Walk from `start` tracking string/escape state; the top-level value
196        // ends where depth returns to 0. Everything after it (the closing ```
197        // fence) is dropped. Multi-byte UTF-8 never matches the structural
198        // ASCII bytes below, so byte-wise scanning is safe.
199        let mut depth: i64 = 0;
200        let mut in_string = false;
201        let mut escape_next = false;
202        let mut end = bytes.len();
203        let mut idx = start;
204        while idx < bytes.len() {
205            let b = bytes[idx];
206            if escape_next {
207                escape_next = false;
208            } else if b == b'\\' && in_string {
209                escape_next = true;
210            } else if b == b'"' {
211                in_string = !in_string;
212            } else if !in_string {
213                match b {
214                    b'{' | b'[' => depth += 1,
215                    b'}' | b']' => {
216                        depth -= 1;
217                        if depth == 0 {
218                            end = idx + 1;
219                            break;
220                        }
221                    }
222                    _ => {}
223                }
224            }
225            idx += 1;
226        }
227        &buffer[start..end]
228    }
229
230    /// Repair a partial JSON string by closing unclosed structures and
231    /// truncating incomplete values.
232    pub(crate) fn repair_partial_json(text: &str) -> String {
233        let mut repaired = text.trim().to_string();
234
235        // Scan the text tracking string state to correctly count braces/brackets
236        // and quotes outside of strings (C20 + C21).
237        let mut in_string = false;
238        let mut escape_next = false;
239        let mut open_braces = 0usize;
240        let mut close_braces = 0usize;
241        let mut open_brackets = 0usize;
242        let mut close_brackets = 0usize;
243        let mut unescaped_quote_count = 0usize;
244
245        for ch in repaired.chars() {
246            if escape_next {
247                escape_next = false;
248                continue;
249            }
250            if ch == '\\' && in_string {
251                escape_next = true;
252                continue;
253            }
254            if ch == '"' {
255                unescaped_quote_count += 1;
256                in_string = !in_string;
257                continue;
258            }
259            if !in_string {
260                match ch {
261                    '{' => open_braces += 1,
262                    '}' => close_braces += 1,
263                    '[' => open_brackets += 1,
264                    ']' => close_brackets += 1,
265                    _ => {}
266                }
267            }
268        }
269
270        // If we are in the middle of a string value, close it.
271        // Heuristic: odd number of unescaped quotes means an unclosed string.
272        if unescaped_quote_count % 2 != 0 {
273            repaired.push('"');
274        }
275
276        // Close unclosed braces first (before removing trailing commas,
277        // so that commas before the newly-added braces get removed)
278        for _ in close_braces..open_braces {
279            repaired.push('}');
280        }
281
282        // Close unclosed brackets
283        for _ in close_brackets..open_brackets {
284            repaired.push(']');
285        }
286
287        // Remove trailing commas before closing brackets/braces
288        // (must come after closing braces/brackets so we can detect them)
289        repaired = Self::remove_trailing_commas(&repaired);
290
291        repaired
292    }
293
294    /// Remove trailing commas before closing braces/brackets (invalid in strict JSON).
295    pub(crate) fn remove_trailing_commas(s: &str) -> String {
296        let mut result = String::with_capacity(s.len());
297        let chars: Vec<char> = s.chars().collect();
298        let mut i = 0;
299        while i < chars.len() {
300            if chars[i] == ',' && i + 1 < chars.len() {
301                let next_non_ws = chars[i + 1..].iter().find(|c| !c.is_whitespace());
302                if next_non_ws == Some(&'}') || next_non_ws == Some(&']') {
303                    // Skip the trailing comma
304                    i += 1;
305                    continue;
306                }
307            }
308            result.push(chars[i]);
309            i += 1;
310        }
311        result
312    }
313}
314
315impl Default for PartialJsonParser {
316    fn default() -> Self {
317        Self::new()
318    }
319}