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 // Fast path: try full parse first
106 if let Ok(value) = serde_json::from_str::<Value>(&self.buffer) {
107 return Ok(value);
108 }
109
110 // Only attempt repair if we have at least opened a structure
111 if self.depth > 0
112 || self.buffer.trim().starts_with('{')
113 || self.buffer.trim().starts_with('[')
114 {
115 let repaired = Self::repair_partial_json(&self.buffer);
116 if let Ok(value) = serde_json::from_str::<Value>(&repaired) {
117 return Ok(value);
118 }
119 }
120
121 Err(PartialJsonError::Incomplete(format!(
122 "Buffer has {} chars, depth={}",
123 self.buffer.len(),
124 self.depth
125 )))
126 }
127
128 /// Get the final complete value.
129 ///
130 /// Call this when the stream has ended. It first tries to parse the full
131 /// buffer, then falls back to the repaired version.
132 pub fn finalize(self) -> Result<Value, PartialJsonError> {
133 // Try full parse
134 if let Ok(value) = serde_json::from_str::<Value>(&self.buffer) {
135 return Ok(value);
136 }
137
138 // Try repaired
139 let repaired = Self::repair_partial_json(&self.buffer);
140 serde_json::from_str::<Value>(&repaired).map_err(|e| {
141 PartialJsonError::Invalid(format!(
142 "Failed to parse final buffer ({} chars): {}. Buffer: {}",
143 self.buffer.len(),
144 e,
145 &self.buffer[..std::cmp::min(200, self.buffer.len())]
146 ))
147 })
148 }
149
150 /// Return a reference to the current buffer contents.
151 pub fn buffer(&self) -> &str {
152 &self.buffer
153 }
154
155 /// Whether the parser is currently inside a JSON string.
156 pub fn is_in_string(&self) -> bool {
157 self.in_string
158 }
159
160 /// Current nesting depth of brackets/braces.
161 pub fn depth(&self) -> usize {
162 self.depth
163 }
164
165 /// Repair a partial JSON string by closing unclosed structures and
166 /// truncating incomplete values.
167 pub(crate) fn repair_partial_json(text: &str) -> String {
168 let mut repaired = text.trim().to_string();
169
170 // Scan the text tracking string state to correctly count braces/brackets
171 // and quotes outside of strings (C20 + C21).
172 let mut in_string = false;
173 let mut escape_next = false;
174 let mut open_braces = 0usize;
175 let mut close_braces = 0usize;
176 let mut open_brackets = 0usize;
177 let mut close_brackets = 0usize;
178 let mut unescaped_quote_count = 0usize;
179
180 for ch in repaired.chars() {
181 if escape_next {
182 escape_next = false;
183 continue;
184 }
185 if ch == '\\' && in_string {
186 escape_next = true;
187 continue;
188 }
189 if ch == '"' {
190 unescaped_quote_count += 1;
191 in_string = !in_string;
192 continue;
193 }
194 if !in_string {
195 match ch {
196 '{' => open_braces += 1,
197 '}' => close_braces += 1,
198 '[' => open_brackets += 1,
199 ']' => close_brackets += 1,
200 _ => {}
201 }
202 }
203 }
204
205 // If we are in the middle of a string value, close it.
206 // Heuristic: odd number of unescaped quotes means an unclosed string.
207 if unescaped_quote_count % 2 != 0 {
208 repaired.push('"');
209 }
210
211 // Close unclosed braces first (before removing trailing commas,
212 // so that commas before the newly-added braces get removed)
213 for _ in close_braces..open_braces {
214 repaired.push('}');
215 }
216
217 // Close unclosed brackets
218 for _ in close_brackets..open_brackets {
219 repaired.push(']');
220 }
221
222 // Remove trailing commas before closing brackets/braces
223 // (must come after closing braces/brackets so we can detect them)
224 repaired = Self::remove_trailing_commas(&repaired);
225
226 repaired
227 }
228
229 /// Remove trailing commas before closing braces/brackets (invalid in strict JSON).
230 pub(crate) fn remove_trailing_commas(s: &str) -> String {
231 let mut result = String::with_capacity(s.len());
232 let chars: Vec<char> = s.chars().collect();
233 let mut i = 0;
234 while i < chars.len() {
235 if chars[i] == ',' && i + 1 < chars.len() {
236 let next_non_ws = chars[i + 1..].iter().find(|c| !c.is_whitespace());
237 if next_non_ws == Some(&'}') || next_non_ws == Some(&']') {
238 // Skip the trailing comma
239 i += 1;
240 continue;
241 }
242 }
243 result.push(chars[i]);
244 i += 1;
245 }
246 result
247 }
248}
249
250impl Default for PartialJsonParser {
251 fn default() -> Self {
252 Self::new()
253 }
254}