use std::collections::HashMap;
use super::model::{MirCall, MirGraph, MirLocal, MirParseError};
impl MirGraph {
pub fn from_jsonl(input: &str) -> Result<Self, MirParseError> {
let mut graph = Self::default();
for (index, raw) in input.lines().enumerate() {
let line = index + 1;
if raw.trim().is_empty() {
continue;
}
let fields = parse_object(raw).map_err(|message| MirParseError { line, message })?;
let kind = required(&fields, "kind", line)?;
match kind {
"function" => {
graph
.functions
.insert(required(&fields, "name", line)?.to_owned());
}
"call" => graph.calls.push(MirCall {
caller: required(&fields, "caller", line)?.to_owned(),
callee: required(&fields, "callee", line)?.to_owned(),
mir_line: number(&fields, "mir_line", line)?,
}),
"local" => graph.locals.push(MirLocal {
function: required(&fields, "function", line)?.to_owned(),
name: required(&fields, "name", line)?.to_owned(),
type_name: required(&fields, "type", line)?.to_owned(),
mir_line: number(&fields, "mir_line", line)?,
}),
other => {
return Err(MirParseError {
line,
message: format!("unsupported record kind `{other}`"),
});
}
}
}
Ok(graph)
}
}
fn required<'a>(
fields: &'a HashMap<String, String>,
key: &str,
line: usize,
) -> Result<&'a str, MirParseError> {
fields
.get(key)
.map(String::as_str)
.ok_or_else(|| MirParseError {
line,
message: format!("missing `{key}`"),
})
}
fn number(
fields: &HashMap<String, String>,
key: &str,
line: usize,
) -> Result<usize, MirParseError> {
required(fields, key, line)?
.parse()
.map_err(|_| MirParseError {
line,
message: format!("`{key}` is not an integer"),
})
}
fn parse_object(input: &str) -> Result<HashMap<String, String>, String> {
let bytes = input.as_bytes();
let mut cursor = 0;
skip_space(bytes, &mut cursor);
if bytes.get(cursor) != Some(&b'{') {
return Err("record must start with `{`".to_owned());
}
cursor += 1;
let mut fields = HashMap::new();
loop {
skip_space(bytes, &mut cursor);
if bytes.get(cursor) == Some(&b'}') {
return Ok(fields);
}
let key = quoted(bytes, &mut cursor)?;
skip_space(bytes, &mut cursor);
if bytes.get(cursor) != Some(&b':') {
return Err("expected `:` after key".to_owned());
}
cursor += 1;
skip_space(bytes, &mut cursor);
let value = if bytes.get(cursor) == Some(&b'"') {
quoted(bytes, &mut cursor)?
} else {
let start = cursor;
while cursor < bytes.len()
&& !matches!(bytes[cursor], b',' | b'}' | b' ' | b'\n' | b'\r' | b'\t')
{
cursor += 1;
}
if start == cursor {
return Err("expected a value".to_owned());
}
String::from_utf8(bytes[start..cursor].to_vec())
.map_err(|_| "value is not UTF-8".to_owned())?
};
if fields.insert(key, value).is_some() {
return Err("duplicate field".to_owned());
}
skip_space(bytes, &mut cursor);
match bytes.get(cursor) {
Some(b',') => cursor += 1,
Some(b'}') => return Ok(fields),
_ => return Err("expected `,` or `}`".to_owned()),
}
}
}
fn quoted(bytes: &[u8], cursor: &mut usize) -> Result<String, String> {
if bytes.get(*cursor) != Some(&b'"') {
return Err("expected a quoted string".to_owned());
}
*cursor += 1;
let mut value = String::new();
while let Some(byte) = bytes.get(*cursor).copied() {
*cursor += 1;
match byte {
b'"' => return Ok(value),
b'\\' => {
let escaped = bytes.get(*cursor).copied().ok_or("unterminated escape")?;
*cursor += 1;
match escaped {
b'"' => value.push('"'),
b'\\' => value.push('\\'),
b'/' => value.push('/'),
b'b' => value.push('\u{8}'),
b'f' => value.push('\u{c}'),
b'n' => value.push('\n'),
b'r' => value.push('\r'),
b't' => value.push('\t'),
b'u' => value.push(decode_unicode_escape(bytes, cursor)?),
_ => return Err("unsupported string escape".to_owned()),
}
}
byte if byte.is_ascii() => value.push(byte as char),
_ => {
let start = *cursor - 1;
let width = utf8_width(byte);
let end = start + width;
if width == 0 || end > bytes.len() {
return Err("invalid UTF-8 in string".to_owned());
}
let text = core::str::from_utf8(&bytes[start..end])
.map_err(|_| "invalid UTF-8 in string".to_owned())?;
value.push_str(text);
*cursor = end;
}
}
}
Err("unterminated string".to_owned())
}
fn utf8_width(byte: u8) -> usize {
match byte {
0xC2..=0xDF => 2,
0xE0..=0xEF => 3,
0xF0..=0xF4 => 4,
_ => 0,
}
}
fn decode_unicode_escape(bytes: &[u8], cursor: &mut usize) -> Result<char, String> {
let code = hex4(bytes, cursor)?;
if (0xD800..0xDC00).contains(&code) {
if bytes.get(*cursor..*cursor + 2) != Some(b"\\u") {
return Err("a high surrogate needs a low surrogate".to_owned());
}
*cursor += 2;
let low = hex4(bytes, cursor)?;
if !(0xDC00..0xE000).contains(&low) {
return Err("a high surrogate needs a low surrogate".to_owned());
}
let combined = 0x10000 + ((code - 0xD800) << 10) + (low - 0xDC00);
return char::from_u32(combined).ok_or_else(|| "invalid \\u escape".to_owned());
}
char::from_u32(code).ok_or_else(|| "invalid \\u escape".to_owned())
}
fn hex4(bytes: &[u8], cursor: &mut usize) -> Result<u32, String> {
let digits = bytes
.get(*cursor..*cursor + 4)
.ok_or_else(|| "truncated \\u escape".to_owned())?;
let text = core::str::from_utf8(digits).map_err(|_| "invalid \\u escape".to_owned())?;
let code = u32::from_str_radix(text, 16).map_err(|_| "invalid \\u escape".to_owned())?;
*cursor += 4;
Ok(code)
}
fn skip_space(bytes: &[u8], cursor: &mut usize) {
while bytes
.get(*cursor)
.is_some_and(|byte| byte.is_ascii_whitespace())
{
*cursor += 1;
}
}
#[cfg(test)]
mod tests {
use super::MirGraph;
#[test]
fn parses_jsonl_without_json_dependency() {
let graph = MirGraph::from_jsonl(
"{\"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",
)
.unwrap();
assert!(graph.functions.contains("crate::a"));
assert_eq!(graph.calls[0].mir_line, 12);
assert_eq!(graph.locals[0].type_name, "f32");
}
#[test]
fn a_non_ascii_symbol_round_trips() {
let line = "{\"kind\":\"function\",\"name\":\"crate::héllo\"}\n";
let graph = MirGraph::from_jsonl(line).expect("raw UTF-8 is valid JSON");
assert!(
graph.functions.iter().any(|name| name == "crate::héllo"),
"the raw form reads back: {:?}",
graph.functions
);
let again = MirGraph::from_jsonl(&graph.to_jsonl()).expect("the writer's own output");
assert!(
again.functions.iter().any(|name| name == "crate::héllo"),
"the artifact round-trips: {:?}",
again.functions
);
let escaped = MirGraph::from_jsonl(
"{\"kind\":\"function\",\"name\":\"caf\\u00e9 \\ud83d\\ude00\"}\n",
)
.expect("escapes are accepted");
assert!(
escaped.functions.iter().any(|name| name == "café 😀"),
"escapes decode: {:?}",
escaped.functions
);
}
}