use std::fmt::Write;
use typst_syntax::SyntaxNode;
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "camelCase"))]
pub struct SpanMapping {
pub src_start: usize,
pub src_end: usize,
pub out_start: usize,
pub out_end: usize,
}
pub fn format_ast_with_mapping(root: &SyntaxNode) -> (String, Vec<SpanMapping>) {
let mut buf = String::new();
let mut mappings = Vec::new();
format_ast_debug(root, &mut buf, &mut mappings, 0, 0);
(buf, mappings)
}
fn format_ast_debug(
node: &SyntaxNode,
buf: &mut String,
mappings: &mut Vec<SpanMapping>,
indent: usize,
src_offset: usize,
) {
let children: Vec<&SyntaxNode> = node.children().collect();
if children.is_empty() {
let out_start = buf.len();
write!(buf, "{node:?}").unwrap();
let out_end = buf.len();
let node_len = node.len();
if node_len > 0 {
mappings.push(SpanMapping {
src_start: src_offset,
src_end: src_offset + node_len,
out_start,
out_end,
});
}
} else {
write!(buf, "{:?}: {}", node.kind(), node.len()).unwrap();
buf.push_str(" [\n");
let child_indent = indent + 2;
let mut child_offset = src_offset;
for child in &children {
for _ in 0..child_indent {
buf.push(' ');
}
format_ast_debug(child, buf, mappings, child_indent, child_offset);
buf.push_str(",\n");
child_offset += child.len();
}
for _ in 0..indent {
buf.push(' ');
}
buf.push(']');
}
}
#[cfg(test)]
mod tests {
use typst_syntax::Source;
use super::*;
use crate::utils::indent_4_to_2;
fn assert_format_matches(src: &str) {
let source = Source::detached(src.to_string());
let root = source.root();
let expected = indent_4_to_2(&format!("{root:#?}"));
let (actual, mappings) = format_ast_with_mapping(root);
assert_eq!(
actual, expected,
"Custom formatter output differs from reference"
);
assert!(!mappings.is_empty(), "Expected non-empty mappings");
for w in mappings.windows(2) {
assert!(
w[0].out_end <= w[1].out_start,
"Mapping out ranges overlap: {:?} and {:?}",
w[0],
w[1]
);
assert!(
w[0].src_start <= w[1].src_start,
"Mapping src ranges not ordered: {:?} and {:?}",
w[0],
w[1]
);
}
}
#[test]
fn test_simple_text() {
assert_format_matches("Hello *world*");
}
#[test]
fn test_let_binding() {
assert_format_matches("#let x = 1\nHello");
}
#[test]
fn test_nested_function_call() {
assert_format_matches(
r#"#figure(table(
columns: 2,
[Benchmark A], [100],
[Benchmark B], [200],
))"#,
);
}
#[test]
fn test_empty_document() {
let source = Source::detached("".to_string());
let root = source.root();
let expected = indent_4_to_2(&format!("{root:#?}"));
let (actual, _) = format_ast_with_mapping(root);
assert_eq!(actual, expected);
}
#[test]
fn test_multiline_with_special_chars() {
assert_format_matches("Hello\n\"world\"\ttab\\slash");
}
#[test]
fn test_math_mode() {
assert_format_matches("$x + y = z$");
}
#[test]
fn test_code_block() {
assert_format_matches("#{\n let a = 1\n let b = 2\n}");
}
#[test]
fn test_mapping_coverage() {
let src = "#let x = 1\nHello";
let source = Source::detached(src.to_string());
let root = source.root();
let (output, mappings) = format_ast_with_mapping(root);
fn count_leaves(node: &SyntaxNode) -> usize {
let children: Vec<_> = node.children().collect();
if children.is_empty() {
if !node.is_empty() { 1 } else { 0 }
} else {
children.iter().map(|c| count_leaves(c)).sum()
}
}
let leaf_count = count_leaves(root);
assert_eq!(
mappings.len(),
leaf_count,
"Expected one mapping per non-empty leaf, got {} mappings for {} leaves",
mappings.len(),
leaf_count,
);
for m in &mappings {
assert!(
m.out_end <= output.len(),
"Mapping out_end exceeds output length"
);
assert!(
m.src_end <= src.len(),
"Mapping src_end exceeds source length"
);
}
}
}