use std::fs;
use std::path::{Path, PathBuf};
use badness::formatter::{
FormatStyle, WrapMode, format, format_node_range_with_signatures, format_with_style,
format_with_style_flavored,
};
use badness::parser::{LatexFlavor, LexConfig, parse, parse_with_flavor, reconstruct};
use badness::semantic::SignatureDb;
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!(
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}$",
r"$x^{2} + a_i^{n+1} + {a+b}^2$",
r"\[ x ^ 2 \quad y_\alpha \]",
r"$\left[ \left( a \right) \right]^2 + \left\langle x \right\rangle$",
"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",
"\\begin{minted}[frame=single]{python}\nprint(\"$x$\") # raw\n\\end{minted}\n",
r"see \url{http://x.com/a_b} and \code{$x_y$} inline",
r"\lstinline|a_$b$_c| then \mintinline{python}{x = $1}",
"given by \\code{\nmulti-line $verbatim$ body with a_b} and more text here\n",
"\\begin{aligned}\n & a & & b \\\\\n % & long commented-out row & & y \\\\\n & c & & d \\\\\n\\end{aligned}\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_begin_trailing_comment", WrapMode::Reflow, 80),
("comment_binds_leading_to_construct", WrapMode::Reflow, 80),
("verbatim_jss_code_environment", WrapMode::Preserve, 80),
("environment_user_defined_glued", WrapMode::Preserve, 80),
("environment_xparse_glued", WrapMode::Preserve, 80),
("verbatim_in_environment", WrapMode::Preserve, 80),
("verbatim_argument_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),
("group_comment_rides_open_brace", 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_comment_own_line", WrapMode::Reflow, 80),
("reflow_in_environment", WrapMode::Reflow, 20),
("reflow_command_lines_preserved", WrapMode::Reflow, 80),
("reflow_list_hanging_indent", WrapMode::Reflow, 72),
("reflow_list_item_label", WrapMode::Reflow, 60),
("reflow_list_nested", WrapMode::Reflow, 50),
("reflow_list_blank_between_items", WrapMode::Reflow, 80),
("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_brace_body_wraps", WrapMode::Reflow, 80),
(
"reflow_brace_body_statements_preserved",
WrapMode::Reflow,
80,
),
("reflow_prose_arg_blank_line", WrapMode::Reflow, 40),
("reflow_prose_arg_nested_in_paragraph", WrapMode::Reflow, 50),
("reflow_inline_prose_in_paragraph", WrapMode::Reflow, 50),
("reflow_caption_block", WrapMode::Reflow, 40),
("reflow_cite_collapses_and_flows", WrapMode::Reflow, 80),
("reflow_cite_comment_keeps_block", WrapMode::Reflow, 80),
("reflow_ref_flows", WrapMode::Reflow, 80),
("math_collapse_spaces", WrapMode::Preserve, 80),
("math_trim_delims", WrapMode::Preserve, 80),
("math_tight_scripts", WrapMode::Preserve, 80),
("math_strip_single_token_braces", WrapMode::Preserve, 80),
("math_keep_multichar_braces", WrapMode::Preserve, 80),
("math_comment_breaks", WrapMode::Preserve, 80),
("math_display_block", WrapMode::Preserve, 80),
("math_display_dollars", WrapMode::Preserve, 80),
("math_display_break_operators", WrapMode::Preserve, 80),
("math_left_right", WrapMode::Preserve, 80),
("math_left_right_control_word_delim", WrapMode::Preserve, 80),
("math_left_right_nested_scripted", WrapMode::Preserve, 80),
("align_columns_basic", WrapMode::Preserve, 80),
("align_columns_uneven_rows", WrapMode::Preserve, 80),
("align_columns_linebreak_optional", WrapMode::Preserve, 80),
("pmatrix_columns", WrapMode::Preserve, 80),
("align_nested_block_fallback", WrapMode::Preserve, 80),
("align_comment_only_line", WrapMode::Preserve, 80),
("align_trailing_comment", WrapMode::Preserve, 80),
("align_comment_mid_row_fallback", WrapMode::Preserve, 80),
("tabular_hline", WrapMode::Preserve, 80),
("tabular_booktabs", WrapMode::Preserve, 80),
("array_columns", WrapMode::Preserve, 80),
("reflow_expl_tilde_breaks", WrapMode::Reflow, 40),
("reflow_expl_straddle", WrapMode::Reflow, 80),
];
fn fixture_path(name: &str, file: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests/fixtures/formatter")
.join(name)
.join(file)
}
const PACKAGE_FIXTURES: &[(&str, &str)] = &[
("package_at_letter_command", "sty"),
("class_provides_preserve", "cls"),
("expl_function_def", "sty"),
("expl_inline_vs_block_groups", "sty"),
];
#[test]
fn package_fixtures_match_expected() {
for &(name, ext) in PACKAGE_FIXTURES {
let style = FormatStyle {
wrap: WrapMode::Preserve,
..FormatStyle::default()
};
let input = fs::read_to_string(fixture_path(name, &format!("input.{ext}")))
.unwrap_or_else(|e| panic!("read {name}/input.{ext}: {e}"));
let expected = fs::read_to_string(fixture_path(name, &format!("expected.{ext}")))
.unwrap_or_else(|e| panic!("read {name}/expected.{ext}: {e}"));
assert!(
parse_with_flavor(&input, LatexFlavor::Package)
.errors
.is_empty(),
"fixture {name} input must parse cleanly under the package flavor"
);
let formatted = format_with_style_flavored(&input, style, LatexFlavor::Package)
.unwrap_or_else(|e| panic!("format {name}: {e}"));
assert_eq!(formatted, expected, "fixture {name} output mismatch");
assert_eq!(
format_with_style_flavored(&formatted, style, LatexFlavor::Package).expect("reformat"),
formatted,
"fixture {name} is not idempotent"
);
let reparsed = parse_with_flavor(&formatted, LatexFlavor::Package);
assert!(
reparsed.errors.is_empty(),
"fixture {name} formatted output must parse cleanly"
);
assert_eq!(
reparsed.syntax().to_string(),
formatted,
"fixture {name} formatted output must round-trip losslessly"
);
}
}
const DTX_FIXTURES: &[&str] = &[
"dtx_macrocode_basic",
"dtx_macrocode_nested_groups",
"dtx_prose_itemize",
"dtx_guards",
"dtx_driver",
"dtx_margin_blank_line",
];
fn dtx_config() -> LexConfig {
LexConfig {
flavor: LatexFlavor::Document,
dtx: true,
}
}
#[test]
fn dtx_fixtures_match_expected() {
for &name in DTX_FIXTURES {
let style = FormatStyle {
wrap: WrapMode::Preserve,
..FormatStyle::default()
};
let input = fs::read_to_string(fixture_path(name, "input.dtx"))
.unwrap_or_else(|e| panic!("read {name}/input.dtx: {e}"));
let expected = fs::read_to_string(fixture_path(name, "expected.dtx"))
.unwrap_or_else(|e| panic!("read {name}/expected.dtx: {e}"));
assert!(
parse_with_flavor(&input, dtx_config()).errors.is_empty(),
"fixture {name} input must parse cleanly under the dtx config"
);
let formatted = format_with_style_flavored(&input, style, dtx_config())
.unwrap_or_else(|e| panic!("format {name}: {e}"));
assert_eq!(formatted, expected, "fixture {name} output mismatch");
assert_eq!(
format_with_style_flavored(&formatted, style, dtx_config()).expect("reformat"),
formatted,
"fixture {name} is not idempotent"
);
let reparsed = parse_with_flavor(&formatted, dtx_config());
assert!(
reparsed.errors.is_empty(),
"fixture {name} formatted output must parse cleanly"
);
assert_eq!(
reparsed.syntax().to_string(),
formatted,
"fixture {name} formatted output must round-trip losslessly"
);
}
}
const DTX_REFLOW_FIXTURES: &[(&str, usize)] = &[
("dtx_reflow_prose_wrap", 50),
("dtx_reflow_prose_joins", 80),
("dtx_reflow_margin_blank_line", 80),
("dtx_reflow_itemize", 50),
];
#[test]
fn dtx_reflow_fixtures_match_expected() {
for &(name, line_width) in DTX_REFLOW_FIXTURES {
let style = FormatStyle {
wrap: WrapMode::Reflow,
line_width,
..FormatStyle::default()
};
let input = fs::read_to_string(fixture_path(name, "input.dtx"))
.unwrap_or_else(|e| panic!("read {name}/input.dtx: {e}"));
let expected = fs::read_to_string(fixture_path(name, "expected.dtx"))
.unwrap_or_else(|e| panic!("read {name}/expected.dtx: {e}"));
assert!(
parse_with_flavor(&input, dtx_config()).errors.is_empty(),
"fixture {name} input must parse cleanly under the dtx config"
);
let formatted = format_with_style_flavored(&input, style, dtx_config())
.unwrap_or_else(|e| panic!("format {name}: {e}"));
assert_eq!(formatted, expected, "fixture {name} output mismatch");
for line in formatted.lines() {
assert!(
line.chars().count() <= line_width,
"fixture {name} line exceeds width {line_width}: {line:?}"
);
}
assert_eq!(
format_with_style_flavored(&formatted, style, dtx_config()).expect("reformat"),
formatted,
"fixture {name} is not idempotent"
);
let reparsed = parse_with_flavor(&formatted, dtx_config());
assert!(
reparsed.errors.is_empty(),
"fixture {name} formatted output must parse cleanly"
);
assert_eq!(
reparsed.syntax().to_string(),
formatted,
"fixture {name} formatted output must round-trip losslessly"
);
}
}
const INS_FIXTURES: &[&str] = &["ins_driver"];
fn ins_config() -> LexConfig {
LexConfig::from(LatexFlavor::Document)
}
#[test]
fn ins_fixtures_match_expected() {
for &name in INS_FIXTURES {
let style = FormatStyle {
wrap: WrapMode::Preserve,
..FormatStyle::default()
};
let input = fs::read_to_string(fixture_path(name, "input.ins"))
.unwrap_or_else(|e| panic!("read {name}/input.ins: {e}"));
let expected = fs::read_to_string(fixture_path(name, "expected.ins"))
.unwrap_or_else(|e| panic!("read {name}/expected.ins: {e}"));
assert!(
parse_with_flavor(&input, ins_config()).errors.is_empty(),
"fixture {name} input must parse cleanly under the ins config"
);
let formatted = format_with_style_flavored(&input, style, ins_config())
.unwrap_or_else(|e| panic!("format {name}: {e}"));
assert_eq!(formatted, expected, "fixture {name} output mismatch");
assert_eq!(
format_with_style_flavored(&formatted, style, ins_config()).expect("reformat"),
formatted,
"fixture {name} is not idempotent"
);
let reparsed = parse_with_flavor(&formatted, ins_config());
assert!(
reparsed.errors.is_empty(),
"fixture {name} formatted output must parse cleanly"
);
assert_eq!(
reparsed.syntax().to_string(),
formatted,
"fixture {name} formatted output must round-trip losslessly"
);
}
}
#[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 cite_key_list_layout_is_deterministic() {
let one_line =
"Something \\citep{koslinski2023comparative, srivastava2025amino} were selected.\n";
let multi_line = "Something\n\\citep{\n koslinski2023comparative,\n srivastava2025amino\n}\nwere selected.\n";
let from_one = format(one_line).expect("one-line formats");
let from_multi = format(multi_line).expect("multi-line formats");
assert_eq!(
from_one, from_multi,
"cite key-list layout must not depend on the authored source line breaks"
);
}
#[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 user_verbatim_command_body_is_protected() {
let input = "\\newcommand\\shellcmd[1]{\\@makeother\\$#1}\n\\shellcmd{a_$b$ c}\n";
let formatted = format(input).expect("formats");
assert!(
formatted.contains("\\shellcmd{a_$b$ c}"),
"verbatim body must pass through unaltered: {formatted:?}"
);
assert_format_invariants(input);
}
#[test]
fn user_verbatim_environment_body_is_protected() {
let input = "\\newenvironment{shellenv}{\\@makeother\\$}{}\n\\begin{shellenv}\na_$b$ c % literal\n\\end{shellenv}\n";
let formatted = format(input).expect("formats");
assert!(
formatted.contains("a_$b$ c % literal"),
"verbatim body must pass through unaltered: {formatted:?}"
);
assert_format_invariants(input);
}
#[test]
fn no_indent_environment_keeps_body_flush() {
let input = "\\begin{document}\nHello.\n\n\\begin{itemize}\n\\item one\n\\end{itemize}\n\\end{document}\n";
assert_eq!(
format(input).expect("formats"),
"\\begin{document}\nHello.\n\n\\begin{itemize}\n \\item one\n\\end{itemize}\n\\end{document}\n",
"document body must stay flush while nested itemize indents"
);
}
#[test]
fn appendix_environment_keeps_body_flush() {
let input = "\\begin{appendix}\n\\section{Proofs}\nText.\n\\end{appendix}\n";
assert_eq!(
format(input).expect("formats"),
"\\begin{appendix}\n\\section{Proofs}\nText.\n\\end{appendix}\n",
"appendix body must stay flush like document"
);
assert_format_invariants(input);
}
#[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"));
}
#[test]
fn range_format_block_equals_standalone_without_trailing_newline() {
let style = FormatStyle::default();
let input = "first paragraph.\n\nsecond paragraph.\n";
let root = parse(input).syntax();
let first = root.children().next().expect("a first top-level block");
let r = first.text_range();
let fragment =
format_node_range_with_signatures(&root, style, &SignatureDb::default(), r).unwrap();
let slice = &input[usize::from(r.start())..usize::from(r.end())];
let standalone = format_with_style(slice, style).unwrap();
assert_eq!(fragment, standalone.trim_end_matches('\n'));
assert!(
!fragment.ends_with('\n'),
"fragment must not force a newline"
);
assert_eq!(fragment, "first paragraph.");
}
#[test]
fn range_format_multiline_environment_block() {
let style = FormatStyle::default();
let input =
"\\begin{itemize}\n\\item one\n\\item two\n\\end{itemize}\n\nsecond paragraph.\n";
let root = parse(input).syntax();
let env = root.children().next().expect("the environment block");
let r = env.text_range();
let fragment =
format_node_range_with_signatures(&root, style, &SignatureDb::default(), r).unwrap();
let slice = &input[usize::from(r.start())..usize::from(r.end())];
let standalone = format_with_style(slice, style).unwrap();
assert_eq!(fragment, standalone.trim_end_matches('\n'));
assert!(fragment.starts_with("\\begin{itemize}"));
assert!(fragment.ends_with("\\end{itemize}"));
assert!(
fragment.contains('\n'),
"a multi-line block stays multi-line"
);
}