nichlink/registry_core/mir/
jsonl.rs1use std::collections::HashMap;
10
11use super::model::{MirCall, MirGraph, MirLocal, MirParseError};
12
13impl MirGraph {
14 pub fn from_jsonl(input: &str) -> Result<Self, MirParseError> {
23 let mut graph = Self::default();
24 for (index, raw) in input.lines().enumerate() {
25 let line = index + 1;
26 if raw.trim().is_empty() {
27 continue;
28 }
29 let fields = parse_object(raw).map_err(|message| MirParseError { line, message })?;
30 let kind = required(&fields, "kind", line)?;
31 match kind {
32 "function" => {
33 graph
34 .functions
35 .insert(required(&fields, "name", line)?.to_owned());
36 }
37 "call" => graph.calls.push(MirCall {
38 caller: required(&fields, "caller", line)?.to_owned(),
39 callee: required(&fields, "callee", line)?.to_owned(),
40 mir_line: number(&fields, "mir_line", line)?,
41 }),
42 "local" => graph.locals.push(MirLocal {
43 function: required(&fields, "function", line)?.to_owned(),
44 name: required(&fields, "name", line)?.to_owned(),
45 type_name: required(&fields, "type", line)?.to_owned(),
46 mir_line: number(&fields, "mir_line", line)?,
47 }),
48 other => {
49 return Err(MirParseError {
50 line,
51 message: format!("unsupported record kind `{other}`"),
52 });
53 }
54 }
55 }
56 Ok(graph)
57 }
58}
59
60fn required<'a>(
61 fields: &'a HashMap<String, String>,
62 key: &str,
63 line: usize,
64) -> Result<&'a str, MirParseError> {
65 fields
66 .get(key)
67 .map(String::as_str)
68 .ok_or_else(|| MirParseError {
69 line,
70 message: format!("missing `{key}`"),
71 })
72}
73
74fn number(
75 fields: &HashMap<String, String>,
76 key: &str,
77 line: usize,
78) -> Result<usize, MirParseError> {
79 required(fields, key, line)?
80 .parse()
81 .map_err(|_| MirParseError {
82 line,
83 message: format!("`{key}` is not an integer"),
84 })
85}
86
87fn parse_object(input: &str) -> Result<HashMap<String, String>, String> {
88 let bytes = input.as_bytes();
89 let mut cursor = 0;
90 skip_space(bytes, &mut cursor);
91 if bytes.get(cursor) != Some(&b'{') {
92 return Err("record must start with `{`".to_owned());
93 }
94 cursor += 1;
95 let mut fields = HashMap::new();
96 loop {
97 skip_space(bytes, &mut cursor);
98 if bytes.get(cursor) == Some(&b'}') {
99 return Ok(fields);
100 }
101 let key = quoted(bytes, &mut cursor)?;
102 skip_space(bytes, &mut cursor);
103 if bytes.get(cursor) != Some(&b':') {
104 return Err("expected `:` after key".to_owned());
105 }
106 cursor += 1;
107 skip_space(bytes, &mut cursor);
108 let value = if bytes.get(cursor) == Some(&b'"') {
109 quoted(bytes, &mut cursor)?
110 } else {
111 let start = cursor;
112 while cursor < bytes.len()
113 && !matches!(bytes[cursor], b',' | b'}' | b' ' | b'\n' | b'\r' | b'\t')
114 {
115 cursor += 1;
116 }
117 if start == cursor {
118 return Err("expected a value".to_owned());
119 }
120 String::from_utf8(bytes[start..cursor].to_vec())
121 .map_err(|_| "value is not UTF-8".to_owned())?
122 };
123 if fields.insert(key, value).is_some() {
124 return Err("duplicate field".to_owned());
125 }
126 skip_space(bytes, &mut cursor);
127 match bytes.get(cursor) {
128 Some(b',') => cursor += 1,
129 Some(b'}') => return Ok(fields),
130 _ => return Err("expected `,` or `}`".to_owned()),
131 }
132 }
133}
134
135fn quoted(bytes: &[u8], cursor: &mut usize) -> Result<String, String> {
136 if bytes.get(*cursor) != Some(&b'"') {
137 return Err("expected a quoted string".to_owned());
138 }
139 *cursor += 1;
140 let mut value = String::new();
141 while let Some(byte) = bytes.get(*cursor).copied() {
142 *cursor += 1;
143 match byte {
144 b'"' => return Ok(value),
145 b'\\' => {
146 let escaped = bytes.get(*cursor).copied().ok_or("unterminated escape")?;
147 *cursor += 1;
148 match escaped {
149 b'"' => value.push('"'),
150 b'\\' => value.push('\\'),
151 b'/' => value.push('/'),
152 b'b' => value.push('\u{8}'),
153 b'f' => value.push('\u{c}'),
154 b'n' => value.push('\n'),
155 b'r' => value.push('\r'),
156 b't' => value.push('\t'),
157 b'u' => value.push(decode_unicode_escape(bytes, cursor)?),
158 _ => return Err("unsupported string escape".to_owned()),
159 }
160 }
161 byte if byte.is_ascii() => value.push(byte as char),
162 _ => {
163 let start = *cursor - 1;
172 let width = utf8_width(byte);
173 let end = start + width;
174 if width == 0 || end > bytes.len() {
175 return Err("invalid UTF-8 in string".to_owned());
176 }
177 let text = core::str::from_utf8(&bytes[start..end])
178 .map_err(|_| "invalid UTF-8 in string".to_owned())?;
179 value.push_str(text);
180 *cursor = end;
181 }
182 }
183 }
184 Err("unterminated string".to_owned())
185}
186
187fn utf8_width(byte: u8) -> usize {
191 match byte {
192 0xC2..=0xDF => 2,
193 0xE0..=0xEF => 3,
194 0xF0..=0xF4 => 4,
195 _ => 0,
196 }
197}
198
199fn decode_unicode_escape(bytes: &[u8], cursor: &mut usize) -> Result<char, String> {
202 let code = hex4(bytes, cursor)?;
203 if (0xD800..0xDC00).contains(&code) {
204 if bytes.get(*cursor..*cursor + 2) != Some(b"\\u") {
208 return Err("a high surrogate needs a low surrogate".to_owned());
209 }
210 *cursor += 2;
211 let low = hex4(bytes, cursor)?;
212 if !(0xDC00..0xE000).contains(&low) {
213 return Err("a high surrogate needs a low surrogate".to_owned());
214 }
215 let combined = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00);
216 return char::from_u32(combined).ok_or_else(|| "invalid \\u escape".to_owned());
217 }
218 char::from_u32(code).ok_or_else(|| "invalid \\u escape".to_owned())
219}
220
221fn hex4(bytes: &[u8], cursor: &mut usize) -> Result<u32, String> {
224 let digits = bytes
225 .get(*cursor..*cursor + 4)
226 .ok_or_else(|| "truncated \\u escape".to_owned())?;
227 let text = core::str::from_utf8(digits).map_err(|_| "invalid \\u escape".to_owned())?;
228 let code = u32::from_str_radix(text, 16).map_err(|_| "invalid \\u escape".to_owned())?;
229 *cursor += 4;
230 Ok(code)
231}
232
233fn skip_space(bytes: &[u8], cursor: &mut usize) {
236 while bytes
237 .get(*cursor)
238 .is_some_and(|byte| byte.is_ascii_whitespace())
239 {
240 *cursor += 1;
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::MirGraph;
247
248 #[test]
249 fn parses_jsonl_without_json_dependency() {
250 let graph = MirGraph::from_jsonl(
251 "{\"kind\":\"function\",\"name\":\"crate::a\"}\n{\"kind\":\"call\",\"caller\":\"crate::a\",\"callee\":\"crate::b\",\"mir_line\":12}\n{\"kind\":\"local\",\"function\":\"crate::a\",\"name\":\"_1\",\"type\":\"f32\",\"mir_line\":14}\n",
252 )
253 .unwrap();
254 assert!(graph.functions.contains("crate::a"));
255 assert_eq!(graph.calls[0].mir_line, 12);
256 assert_eq!(graph.locals[0].type_name, "f32");
257 }
258 #[test]
267 fn a_non_ascii_symbol_round_trips() {
268 let line = "{\"kind\":\"function\",\"name\":\"crate::héllo\"}\n";
269 let graph = MirGraph::from_jsonl(line).expect("raw UTF-8 is valid JSON");
270 assert!(
271 graph.functions.iter().any(|name| name == "crate::héllo"),
272 "the raw form reads back: {:?}",
273 graph.functions
274 );
275 let again = MirGraph::from_jsonl(&graph.to_jsonl()).expect("the writer's own output");
276 assert!(
277 again.functions.iter().any(|name| name == "crate::héllo"),
278 "the artifact round-trips: {:?}",
279 again.functions
280 );
281
282 let escaped = MirGraph::from_jsonl(
286 "{\"kind\":\"function\",\"name\":\"caf\\u00e9 \\ud83d\\ude00\"}\n",
287 )
288 .expect("escapes are accepted");
289 assert!(
290 escaped.functions.iter().any(|name| name == "café 😀"),
291 "escapes decode: {:?}",
292 escaped.functions
293 );
294 }
295}