use rowan::TextRange;
use crate::parser::{LexConfig, parse_with_flavor};
use crate::syntax::{SyntaxElement, SyntaxKind, SyntaxNode};
#[derive(Debug, Clone)]
pub struct PerturbedVariant {
pub label: String,
pub text: String,
}
#[derive(Debug, Clone)]
pub struct TriviaPerturbations {
pub variants: Vec<PerturbedVariant>,
pub eligible_gaps: usize,
pub dropped_unsafe: usize,
}
#[derive(Debug, Clone, Copy)]
pub struct TriviaReport {
pub variants_checked: usize,
pub dropped_unsafe: usize,
}
#[derive(Debug, Clone)]
pub struct TriviaFailure {
pub label: String,
pub perturbed_input: String,
pub formatted_original: String,
pub formatted_perturbed: String,
}
#[derive(Debug, Clone)]
pub struct ConvergenceFailure {
pub label: String,
pub perturbed_input: String,
pub reason: String,
pub once: String,
pub twice: String,
}
#[derive(Debug, Clone)]
pub enum TriviaError {
Original(String),
Violation(Box<TriviaFailure>),
}
#[derive(Debug, Clone)]
pub enum ConvergenceError {
Original(String),
Violation(Box<ConvergenceFailure>),
}
pub fn nontrivia_content(text: &str, config: impl Into<LexConfig>) -> String {
node_nontrivia_content(&parse_with_flavor(text, config).syntax())
}
pub const DEFAULT_SINGLE_FLIP_SAMPLES: usize = 8;
pub fn trivia_perturbations(
input: &str,
config: impl Into<LexConfig>,
single_flip_samples: usize,
) -> TriviaPerturbations {
let config = config.into();
let parsed = parse_with_flavor(input, config);
if !parsed.errors.is_empty() {
return TriviaPerturbations {
variants: Vec::new(),
eligible_gaps: 0,
dropped_unsafe: 0,
};
}
let root = parsed.syntax();
let margined = margined_line_ranges(&root);
let gaps = collect_gaps(&root, &margined);
let original_content = node_nontrivia_content(&root);
let original_skeleton = skeleton(&root);
let mut out = TriviaPerturbations {
variants: Vec::new(),
eligible_gaps: gaps.len(),
dropped_unsafe: 0,
};
let push = |label: String, text: String, out: &mut TriviaPerturbations| {
let parsed = parse_with_flavor(&text, config);
if !parsed.errors.is_empty() {
out.dropped_unsafe += 1;
return;
}
let root = parsed.syntax();
if node_nontrivia_content(&root) != original_content || skeleton(&root) != original_skeleton
{
out.dropped_unsafe += 1;
return;
}
out.variants.push(PerturbedVariant { label, text });
};
for (direction, label) in [
(Direction::NewlineToSpace, "all-newlines-to-spaces"),
(Direction::SpaceToNewline, "all-spaces-to-newlines"),
] {
let bulk: Vec<&Gap> = gaps.iter().filter(|g| g.direction == direction).collect();
if !bulk.is_empty() {
push(label.to_string(), splice(input, &bulk), &mut out);
}
}
let mut rng = Lcg(fnv1a(input));
let mut picked: Vec<usize> = Vec::new();
if gaps.len() <= single_flip_samples {
picked.extend(0..gaps.len());
} else {
while picked.len() < single_flip_samples {
let i = rng.below(gaps.len());
if !picked.contains(&i) {
picked.push(i);
}
}
picked.sort_unstable();
}
for i in picked {
let gap = &gaps[i];
let dir = match gap.direction {
Direction::NewlineToSpace => "nl-to-space",
Direction::SpaceToNewline => "space-to-nl",
};
let label = format!("flip@{}-{dir}", u32::from(gap.range.start()));
push(label, splice(input, &[gap]), &mut out);
}
out
}
pub fn check_trivia_convergence(
input: &str,
config: impl Into<LexConfig>,
single_flip_samples: usize,
fmt: impl Fn(&str) -> Result<String, String>,
) -> Result<TriviaReport, ConvergenceError> {
let config = config.into();
let perturbations = trivia_perturbations(input, config, single_flip_samples);
fmt(input).map_err(ConvergenceError::Original)?;
let violation = |variant: &PerturbedVariant, reason: String, once: String, twice: String| {
ConvergenceError::Violation(Box::new(ConvergenceFailure {
label: variant.label.clone(),
perturbed_input: variant.text.clone(),
reason,
once,
twice,
}))
};
let mut variants_checked = 0;
for variant in &perturbations.variants {
let once = fmt(&variant.text).map_err(|e| {
violation(
variant,
format!("perturbed input failed to format: {e}"),
String::new(),
String::new(),
)
})?;
let once_parsed = parse_with_flavor(&once, config);
let once_root = once_parsed.syntax();
if node_nontrivia_content(&once_root)
!= node_nontrivia_content(
&parse_with_flavor(&format!("{}\n", variant.text), config).syntax(),
)
{
return Err(violation(
variant,
"format changed non-trivia content".to_string(),
once,
String::new(),
));
}
if !once_parsed.errors.is_empty() {
return Err(violation(
variant,
"formatted output does not parse without diagnostics".to_string(),
once,
String::new(),
));
}
if once_root.to_string() != once {
return Err(violation(
variant,
"formatted output does not round-trip losslessly".to_string(),
once,
String::new(),
));
}
match fmt(&once) {
Err(e) => {
return Err(violation(
variant,
format!("formatted output failed to re-format: {e}"),
once,
String::new(),
));
}
Ok(twice) if twice != once => {
return Err(violation(
variant,
"did not reach a fixed point".to_string(),
once,
twice,
));
}
Ok(_) => {}
}
variants_checked += 1;
}
Ok(TriviaReport {
variants_checked,
dropped_unsafe: perturbations.dropped_unsafe,
})
}
pub fn check_trivia_invariance(
input: &str,
config: impl Into<LexConfig>,
single_flip_samples: usize,
fmt: impl Fn(&str) -> Result<String, String>,
) -> Result<TriviaReport, TriviaError> {
let perturbations = trivia_perturbations(input, config, single_flip_samples);
let formatted_original = fmt(input).map_err(TriviaError::Original)?;
let mut variants_checked = 0;
for variant in &perturbations.variants {
let formatted_perturbed = match fmt(&variant.text) {
Ok(text) => text,
Err(err) => format!("<format error: {err}>"),
};
if formatted_perturbed != formatted_original {
return Err(TriviaError::Violation(Box::new(TriviaFailure {
label: variant.label.clone(),
perturbed_input: variant.text.clone(),
formatted_original,
formatted_perturbed,
})));
}
variants_checked += 1;
}
Ok(TriviaReport {
variants_checked,
dropped_unsafe: perturbations.dropped_unsafe,
})
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Direction {
NewlineToSpace,
SpaceToNewline,
}
#[derive(Debug, Clone)]
struct Gap {
range: TextRange,
direction: Direction,
}
fn is_ignored_trivia(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::WHITESPACE
| SyntaxKind::NEWLINE
| SyntaxKind::COMMENT
| SyntaxKind::DOC_MARGIN
| SyntaxKind::GUARD
)
}
fn is_collapsible(kind: SyntaxKind) -> bool {
matches!(kind, SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE)
}
fn is_excluded_neighbor(kind: SyntaxKind) -> bool {
matches!(
kind,
SyntaxKind::COMMENT
| SyntaxKind::DOC_MARGIN
| SyntaxKind::GUARD
| SyntaxKind::VERB
| SyntaxKind::VERBATIM_BODY
)
}
fn node_nontrivia_content(root: &SyntaxNode) -> String {
root.descendants_with_tokens()
.filter_map(|el| el.into_token())
.filter(|t| !is_ignored_trivia(t.kind()))
.map(|t| t.text().to_string())
.collect()
}
fn skeleton(root: &SyntaxNode) -> Vec<SyntaxKind> {
root.descendants_with_tokens()
.filter(|el| match el {
SyntaxElement::Node(_) => true,
SyntaxElement::Token(t) => !is_ignored_trivia(t.kind()),
})
.map(|el| el.kind())
.collect()
}
fn margined_line_ranges(root: &SyntaxNode) -> Vec<TextRange> {
let mut ranges = Vec::new();
let mut line_start = rowan::TextSize::from(0);
let mut line_has_margin = false;
let mut cursor = root.first_token();
while let Some(token) = cursor {
match token.kind() {
SyntaxKind::DOC_MARGIN | SyntaxKind::GUARD => line_has_margin = true,
SyntaxKind::NEWLINE => {
let end = token.text_range().end();
if line_has_margin {
ranges.push(TextRange::new(line_start, end));
}
line_start = end;
line_has_margin = false;
}
_ => {}
}
cursor = token.next_token();
}
if line_has_margin {
ranges.push(TextRange::new(line_start, root.text_range().end()));
}
ranges
}
fn collect_gaps(root: &SyntaxNode, margined: &[TextRange]) -> Vec<Gap> {
let mut gaps = Vec::new();
let mut cursor = root.first_token();
while let Some(token) = cursor {
if !is_collapsible(token.kind()) {
cursor = token.next_token();
continue;
}
let prev = token.prev_token();
let start = token.text_range().start();
let mut end = token.text_range().end();
let mut newlines = usize::from(token.kind() == SyntaxKind::NEWLINE);
let mut run_len = 1;
let single_space = token.kind() == SyntaxKind::WHITESPACE && token.text() == " ";
let mut last = token.clone();
while let Some(next) = last.next_token().filter(|t| is_collapsible(t.kind())) {
newlines += usize::from(next.kind() == SyntaxKind::NEWLINE);
end = next.text_range().end();
run_len += 1;
last = next;
}
let next = last.next_token();
let eligible = match (&prev, &next) {
(Some(p), Some(n)) => {
!is_excluded_neighbor(p.kind())
&& !is_excluded_neighbor(n.kind())
&& !margined
.iter()
.any(|r| r.contains(start) || r.contains(end))
}
_ => false,
};
if eligible {
let range = TextRange::new(start, end);
if newlines == 1 {
gaps.push(Gap {
range,
direction: Direction::NewlineToSpace,
});
} else if newlines == 0 && run_len == 1 && single_space {
gaps.push(Gap {
range,
direction: Direction::SpaceToNewline,
});
}
}
cursor = next;
}
gaps
}
fn splice(input: &str, gaps: &[&Gap]) -> String {
let mut out = String::with_capacity(input.len());
let mut pos = 0usize;
for gap in gaps {
let (start, end) = (
u32::from(gap.range.start()) as usize,
u32::from(gap.range.end()) as usize,
);
out.push_str(&input[pos..start]);
out.push(match gap.direction {
Direction::NewlineToSpace => ' ',
Direction::SpaceToNewline => '\n',
});
pos = end;
}
out.push_str(&input[pos..]);
out
}
struct Lcg(u64);
impl Lcg {
fn next(&mut self) -> u64 {
self.0 = self
.0
.wrapping_mul(6_364_136_223_846_793_005)
.wrapping_add(1_442_695_040_888_963_407);
self.0 >> 16
}
fn below(&mut self, bound: usize) -> usize {
(self.next() as usize) % bound.max(1)
}
}
fn fnv1a(text: &str) -> u64 {
let mut hash = 0xcbf2_9ce4_8422_2325u64;
for byte in text.bytes() {
hash ^= u64::from(byte);
hash = hash.wrapping_mul(0x0000_0100_0000_01b3);
}
hash
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formatter::{FormatStyle, format_with_style};
use crate::parser::LatexFlavor;
fn perturb(input: &str) -> TriviaPerturbations {
trivia_perturbations(input, LatexFlavor::Document, 8)
}
#[test]
fn top_level_space_gap_is_eligible() {
let p = perturb("alpha beta\n");
assert_eq!(p.eligible_gaps, 1);
assert_eq!(p.dropped_unsafe, 0);
assert!(
p.variants
.iter()
.any(|v| v.text == "alpha\nbeta\n" && v.label == "all-spaces-to-newlines"),
"expected the space -> newline bulk variant, got {:?}",
p.variants
);
}
#[test]
fn lone_newline_run_folds_to_one_space() {
let p = perturb("a \n b\n");
assert!(
p.variants
.iter()
.any(|v| v.text == "a b\n" && v.label == "all-newlines-to-spaces"),
"expected the whole run spliced to one space, got {:?}",
p.variants
);
}
#[test]
fn blank_line_is_never_touched() {
let p = perturb("a\n\nb\n");
assert_eq!(p.eligible_gaps, 0);
assert!(p.variants.is_empty());
}
#[test]
fn multi_space_gap_is_ineligible() {
let p = perturb("a b\n");
assert_eq!(p.eligible_gaps, 0);
}
#[test]
fn comment_adjacent_gaps_are_excluded() {
let p = perturb("a\n% note\nb\n");
assert_eq!(p.eligible_gaps, 0);
}
#[test]
fn group_interior_gaps_are_eligible() {
let p = perturb("x {a b} y\n");
assert_eq!(p.eligible_gaps, 3);
}
#[test]
fn dtx_margin_lines_are_excluded() {
let p = trivia_perturbations(
"% \\DescribeMacro{\\foo}\n% doc prose here\n",
LexConfig {
flavor: LatexFlavor::Package,
dtx: true,
},
8,
);
assert_eq!(p.eligible_gaps, 0);
}
#[test]
fn generation_is_deterministic() {
let input = "alpha beta\ngamma delta epsilon\nzeta {eta theta} iota\n";
let a = perturb(input);
let b = perturb(input);
let key = |p: &TriviaPerturbations| {
p.variants
.iter()
.map(|v| (v.label.clone(), v.text.clone()))
.collect::<Vec<_>>()
};
assert_eq!(key(&a), key(&b));
}
#[test]
fn unparseable_input_yields_no_variants() {
let p = perturb("\\begin{itemize}\n");
assert!(p.variants.is_empty());
assert_eq!(p.eligible_gaps, 0);
}
#[test]
fn strict_oracle_passes_on_reflowed_prose() {
let report =
check_trivia_invariance("alpha\nbeta gamma\n", LatexFlavor::Document, 8, |s| {
format_with_style(s, FormatStyle::default()).map_err(|e| e.to_string())
})
.expect("prose reflow is trivia-invariant");
assert!(report.variants_checked > 0);
}
#[test]
fn strict_oracle_accepts_structural_expl3_statements() {
let input =
"\\ExplSyntaxOn\n\\tl_new:N \\l_tmpa_tl\n\\tl_new:N \\l_tmpb_tl\n\\ExplSyntaxOff\n";
let report = check_trivia_invariance(input, LatexFlavor::Document, 8, |s| {
format_with_style(s, FormatStyle::default()).map_err(|e| e.to_string())
})
.expect("structural expl3 statements are strictly trivia-invariant");
assert!(report.variants_checked > 0);
}
#[test]
fn strict_oracle_accepts_structural_expl3_definition() {
let input = "\\ExplSyntaxOn\n\\cs_new:Npn \\demo_foo:n #1\n { \\demo_use:n {#1} }\n\\cs_new:Nn \\demo_bar:n { \\demo_use:n { x } }\n\\ExplSyntaxOff\n";
let report = check_trivia_invariance(input, LatexFlavor::Document, 8, |s| {
format_with_style(s, FormatStyle::default()).map_err(|e| e.to_string())
})
.expect("a structural expl3 definition is strictly trivia-invariant");
assert!(report.variants_checked > 0);
}
#[test]
fn convergence_oracle_accepts_authored_break_preservation() {
let input = "\\usepackage{a}\n\\usepackage{b}\nalpha\nbeta gamma\n";
let report = check_trivia_convergence(input, LatexFlavor::Document, 8, |s| {
format_with_style(s, FormatStyle::default()).map_err(|e| e.to_string())
})
.expect("authored-break preservation converges");
assert!(report.variants_checked > 0);
}
}