use std::fs;
use std::path::{Path, PathBuf};
use badness::formatter::{FormatStyle, WrapMode, format, format_with_style};
use badness::parser::{parse, reconstruct};
use badness::syntax::{SyntaxKind, SyntaxNode};
use rowan::NodeOrToken;
fn structure(input: &str) -> String {
let mut out = String::new();
render_kinds(&parse(input).syntax(), 0, &mut out);
out
}
fn render_kinds(node: &SyntaxNode, depth: usize, out: &mut String) {
out.push_str(&format!(
"{:indent$}{:?}\n",
"",
node.kind(),
indent = depth * 2
));
for child in node.children_with_tokens() {
match child {
NodeOrToken::Node(n) => render_kinds(&n, depth + 1, out),
NodeOrToken::Token(t) => {
if matches!(t.kind(), SyntaxKind::WHITESPACE | SyntaxKind::NEWLINE) {
continue;
}
out.push_str(&format!(
"{:indent$}{:?}\n",
"",
t.kind(),
indent = (depth + 1) * 2
));
}
}
}
}
fn assert_format_invariants(input: &str) {
let formatted = format(input).expect("clean input should format");
let twice = format(&formatted).expect("formatted output should re-format");
assert_eq!(twice, formatted, "format is not idempotent for {input:?}");
assert_eq!(
structure(&formatted),
structure(input),
"format is not parse-stable for {input:?}"
);
assert!(
parse(&formatted).errors.is_empty(),
"formatted output should parse without diagnostics for {input:?}"
);
assert_eq!(
reconstruct(&formatted),
formatted,
"formatted output should round-trip losslessly for {input:?}"
);
}
const CLEAN_CASES: &[&str] = &[
"",
"hello world",
r"\section{Introduction}",
r"$x^2 + y_i = \frac{1}{2}$",
"a % comment\nb",
r"\begin{itemize}\item one\end{itemize}",
"unicode: café — naïve ∑∫ 𝕏",
r"\\ \{ \} \% \, \;",
"trailing backslash \\",
"[opt] {req} & # ~ ^_",
"no final newline",
"para one\n\npara two\n",
"\\begin{tabular}\n{cc}\nx & y\n\\end{tabular}\n",
"\\begin{tabular}{cc}\nx & y\n\\end{tabular}\n",
"\\begin{minipage}[t]{4cm}\ntext\n\\end{minipage}\n",
"\\begin{myenv}\n{cc}\nbody\n\\end{myenv}\n",
];
#[test]
fn format_invariants_units() {
for case in CLEAN_CASES {
assert!(
parse(case).errors.is_empty(),
"CLEAN_CASES must parse without diagnostics: {case:?}"
);
assert_format_invariants(case);
}
}
#[test]
fn format_invariants_corpus() {
let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/corpus");
let mut count = 0;
for entry in fs::read_dir(&dir).expect("read corpus dir") {
let path = entry.expect("dir entry").path();
if path.extension().and_then(|e| e.to_str()) != Some("tex") {
continue;
}
let text = fs::read_to_string(&path).expect("read corpus file");
if parse(&text).errors.is_empty() {
assert_format_invariants(&text);
count += 1;
}
}
assert!(count > 0, "no clean .tex corpus files found in {dir:?}");
}
const FIXTURES: &[(&str, WrapMode, usize)] = &[
(
"whitespace_trailing_and_blank_lines",
WrapMode::Preserve,
80,
),
("trailing_whitespace_only", WrapMode::Preserve, 80),
("collapse_blank_lines", WrapMode::Preserve, 80),
("protected_comment_trailing_space", WrapMode::Preserve, 80),
("protected_verbatim", WrapMode::Preserve, 80),
("final_newline_added", WrapMode::Preserve, 80),
("environment_indents_body", WrapMode::Preserve, 80),
("nested_environments", WrapMode::Preserve, 80),
("environment_reindents", WrapMode::Preserve, 80),
("environment_blank_lines_in_body", WrapMode::Preserve, 80),
("environment_begin_arguments", WrapMode::Preserve, 80),
("environment_argument_glued", WrapMode::Preserve, 80),
("environment_user_defined_glued", WrapMode::Preserve, 80),
("environment_xparse_glued", WrapMode::Preserve, 80),
("verbatim_in_environment", WrapMode::Preserve, 80),
("group_indents_body", WrapMode::Preserve, 80),
("optional_indents_body", WrapMode::Preserve, 80),
("nested_groups", WrapMode::Preserve, 80),
("group_single_line_stays_inline", WrapMode::Preserve, 80),
("group_reindents", WrapMode::Preserve, 80),
("reflow_join_short", WrapMode::Reflow, 80),
("reflow_wrap_to_width", WrapMode::Reflow, 40),
("reflow_tie_no_break", WrapMode::Reflow, 12),
("reflow_forced_break", WrapMode::Reflow, 80),
("reflow_forced_break_with_optarg", WrapMode::Reflow, 80),
("reflow_comment_ends_line", WrapMode::Reflow, 80),
("reflow_in_environment", WrapMode::Reflow, 20),
("reflow_prose_arg_wraps", WrapMode::Reflow, 40),
("reflow_prose_arg_joins_short", WrapMode::Reflow, 80),
("reflow_prose_arg_optional_omitted", WrapMode::Reflow, 30),
("reflow_non_prose_preserved", WrapMode::Reflow, 40),
("reflow_prose_arg_blank_line", WrapMode::Reflow, 40),
("reflow_prose_arg_nested_in_paragraph", WrapMode::Reflow, 50),
];
fn fixture_path(name: &str, file: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/formatter")
.join(name)
.join(file)
}
#[test]
fn formatter_fixtures_match_expected() {
for &(name, wrap, line_width) in FIXTURES {
let style = FormatStyle {
wrap,
line_width,
..FormatStyle::default()
};
let input = fs::read_to_string(fixture_path(name, "input.tex"))
.unwrap_or_else(|e| panic!("read {name}/input.tex: {e}"));
let expected = fs::read_to_string(fixture_path(name, "expected.tex"))
.unwrap_or_else(|e| panic!("read {name}/expected.tex: {e}"));
assert!(
parse(&input).errors.is_empty(),
"fixture {name} input must parse without diagnostics"
);
let formatted =
format_with_style(&input, style).unwrap_or_else(|e| panic!("format {name}: {e}"));
assert_eq!(formatted, expected, "fixture {name} output mismatch");
assert_eq!(
format_with_style(&formatted, style).expect("reformat"),
formatted,
"fixture {name} is not idempotent"
);
assert!(
parse(&formatted).errors.is_empty(),
"fixture {name} formatted output must parse cleanly"
);
assert_eq!(
reconstruct(&formatted),
formatted,
"fixture {name} formatted output must round-trip"
);
}
}
#[test]
fn preserve_keeps_author_breaks_while_reflow_joins() {
let input = "one two\nthree four\n";
let preserve = FormatStyle {
wrap: WrapMode::Preserve,
..FormatStyle::default()
};
assert_eq!(
format_with_style(input, preserve).expect("preserve formats"),
"one two\nthree four\n",
"preserve must keep authored line breaks"
);
assert_eq!(
format(input).expect("reflow formats"),
"one two three four\n",
"default reflow must join the lines"
);
}
#[test]
fn user_definition_drives_begin_argument_glue() {
let style = FormatStyle {
wrap: WrapMode::Preserve,
..FormatStyle::default()
};
let undefined = "\\begin{thm}\n{x}\nbody\n\\end{thm}\n";
assert_eq!(
format_with_style(undefined, style).expect("formats"),
"\\begin{thm}\n{x}\n body\n\\end{thm}\n",
"an undefined environment must not glue its argument"
);
let defined = format!("\\newenvironment{{thm}}[1]{{a}}{{b}}\n{undefined}");
assert_eq!(
format_with_style(&defined, style).expect("formats"),
"\\newenvironment{thm}[1]{a}{b}\n\\begin{thm}{x}\n body\n\\end{thm}\n",
"defining thm's arity must glue the argument onto \\begin"
);
}
#[test]
fn format_rejects_unparseable_input() {
let input = "}";
assert!(!parse(input).errors.is_empty(), "expected a parse error");
assert!(
format(input).is_err(),
"formatter should refuse error input"
);
}
#[test]
fn format_output_snapshot() {
let input = "\\section{Intro} \n\n\n\nSome text with trailing space \nmore text.";
insta::assert_snapshot!(format(input).expect("formats"));
}