use std::fs;
use std::path::{Path, PathBuf};
use std::collections::BTreeMap;
use badness::formatter::{
FormatStyle, MathWrap, SentenceOptions, WrapMode, format, format_node_range_with_signatures,
format_with_style, format_with_style_flavored, format_with_style_flavored_sentence,
};
use badness::parser::{LatexFlavor, LexConfig, parse, parse_with_flavor, reconstruct};
use badness::semantic::SignatureDb;
use badness::syntax::{SyntaxKind, SyntaxNode};
fn nontrivia_content(text: &str) -> String {
let root = SyntaxNode::new_root(parse(text).green);
root.descendants_with_tokens()
.filter_map(|el| el.into_token())
.filter(|t| {
!matches!(
t.kind(),
SyntaxKind::WHITESPACE
| SyntaxKind::NEWLINE
| SyntaxKind::COMMENT
| SyntaxKind::DOC_MARGIN
| SyntaxKind::GUARD
)
})
.map(|t| t.text().to_string())
.collect()
}
fn assert_format_invariants(input: &str) {
let formatted = format(input).expect("clean input should format");
assert_eq!(
nontrivia_content(&formatted),
nontrivia_content(&format!("{input}\n")),
"format changed non-trivia content for {input:?}"
);
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"$a+2*1^5$ and $x=-b$ and $-x+1$ and $2*-1$ and $a<=b$",
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}",
"\\begin{itemize}\n\\item a\n% one\n% two\n\\item b\n\\end{itemize}\n",
"\\begin{itemize}\n\\item a\n\n% c\n\n\\item b\n\\end{itemize}\n",
"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),
(
"begin_trailing_comment_special_layouts",
WrapMode::Reflow,
80,
),
("comment_binds_leading_to_construct", WrapMode::Reflow, 80),
("trailing_comment_rides_block", WrapMode::Reflow, 80),
("trailing_comment_after_block_space", WrapMode::Reflow, 80),
("reflow_trailing_comment_never_wraps", WrapMode::Reflow, 80),
(
"trailing_comment_rides_before_command",
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_list_comment_own_line", WrapMode::Reflow, 80),
("reflow_list_doc_comment_item", 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_secdef_redef_keeps_prose", WrapMode::Reflow, 40),
("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),
("optional_collapse_fits", WrapMode::Reflow, 30),
("math_collapse_spaces", WrapMode::Preserve, 80),
("math_trim_delims", WrapMode::Preserve, 80),
("math_tight_scripts", WrapMode::Preserve, 80),
("math_op_spacing", WrapMode::Preserve, 80),
("math_keep_single_token_braces", WrapMode::Preserve, 80),
("math_keep_braces_before_operator", 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_display_break_relations", WrapMode::Preserve, 80),
("math_display_break_paren_tight", WrapMode::Preserve, 80),
("math_display_break_coloneq", WrapMode::Preserve, 80),
("math_display_brace_delims_tight", WrapMode::Preserve, 80),
("math_display_multiline_lhs", WrapMode::Preserve, 80),
("math_display_leading_label", 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),
("math_bracket_not_optional", WrapMode::Preserve, 80),
("align_columns_basic", WrapMode::Preserve, 80),
("align_columns_uneven_rows", WrapMode::Preserve, 80),
("align_columns_linebreak_optional", WrapMode::Preserve, 80),
("align_continuation_join", WrapMode::Preserve, 80),
("pmatrix_columns", WrapMode::Preserve, 80),
("align_nested_block_cell", WrapMode::Preserve, 80),
("align_nested_aligned_cell", WrapMode::Preserve, 80),
("align_nested_recursive", WrapMode::Preserve, 80),
("align_nested_left_right_cell", WrapMode::Preserve, 80),
(
"align_nested_block_mid_row_fallback",
WrapMode::Preserve,
80,
),
("align_blank_line_in_cell_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),
("align_trailing_comment_lines", WrapMode::Reflow, 80),
("tabular_hline", WrapMode::Preserve, 80),
("tabular_booktabs", WrapMode::Preserve, 80),
("tabular_rule_doc_comment", WrapMode::Preserve, 80),
("align_rule_overattached_cell", WrapMode::Preserve, 80),
("array_columns", WrapMode::Preserve, 80),
("tabular_align_lcr", WrapMode::Preserve, 80),
("tabular_align_right_numeric", WrapMode::Preserve, 80),
("tabular_multicolumn", WrapMode::Preserve, 80),
("tabular_cmidrule_trim", WrapMode::Preserve, 80),
("tabular_rule_same_line", WrapMode::Preserve, 80),
("tabular_pmb_left", WrapMode::Preserve, 80),
("tabular_unknown_spec_fallback", WrapMode::Preserve, 80),
("math_env_equation", WrapMode::Preserve, 80),
("math_env_gather", WrapMode::Preserve, 80),
("math_env_align_spacing", WrapMode::Preserve, 80),
("reflow_expl_tilde_breaks", WrapMode::Reflow, 40),
("reflow_expl_straddle", WrapMode::Reflow, 80),
("sentence_basic", WrapMode::Sentence, 80),
("sentence_abbreviations", WrapMode::Sentence, 80),
("sentence_ellipsis", WrapMode::Sentence, 80),
("sentence_contextual_abbrev", WrapMode::Sentence, 80),
("sentence_inline_math", WrapMode::Sentence, 80),
("sentence_list_items", WrapMode::Sentence, 80),
("sentence_caption", WrapMode::Sentence, 80),
("sentence_long_no_width_break", WrapMode::Sentence, 20),
("semantic_preserve_authored_break", WrapMode::Semantic, 80),
("semantic_adds_sentence_break", WrapMode::Semantic, 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"),
("expl_region_midline_open", "sty"),
("expl_region_false_positive", "sty"),
("expl_doc_comment_statement", "sty"),
("expl_trailing_block_hug", "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",
"dtx_expl3_chunks",
"dtx_expl3_unmargined_doc_line",
"dtx_doc_margin_math",
"dtx_expl3_guarded_group",
"dtx_expl3_guarded_arguments",
"dtx_expl3_guarded_release_block",
"dtx_indented_macrocode_frame",
];
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,
math_wrap: MathWrap::Break,
..FormatStyle::default()
};
assert_fixture(name, style);
}
}
const MATH_WRAP_FIXTURES: &[(&str, WrapMode, MathWrap, usize)] = &[
(
"math_wrap_preserve_authored",
WrapMode::Preserve,
MathWrap::Auto,
80,
),
(
"math_wrap_preserve_equation_env",
WrapMode::Preserve,
MathWrap::Auto,
80,
),
(
"math_wrap_single_line",
WrapMode::Preserve,
MathWrap::SingleLine,
80,
),
(
"math_wrap_break_under_preserve",
WrapMode::Preserve,
MathWrap::Break,
80,
),
(
"math_wrap_label_splits_under_preserve",
WrapMode::Preserve,
MathWrap::Auto,
80,
),
];
#[test]
fn math_wrap_fixtures_match_expected() {
for &(name, wrap, math_wrap, line_width) in MATH_WRAP_FIXTURES {
let style = FormatStyle {
wrap,
math_wrap,
line_width,
..FormatStyle::default()
};
assert_fixture(name, style);
}
}
fn assert_fixture(name: &str, style: FormatStyle) {
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 sentence_wrap_language_and_user_abbreviations() {
let style = FormatStyle {
wrap: WrapMode::Sentence,
line_width: 80,
..FormatStyle::default()
};
let input = "Das ist eins bzw. zwei. Und drei.\n";
let english = format_with_style_flavored_sentence(
input,
style,
LatexFlavor::Document,
SentenceOptions::default(),
)
.expect("format");
assert_eq!(english, "Das ist eins bzw.\nzwei.\nUnd drei.\n");
let de = SentenceOptions::from_lang(Some("de"));
let german = format_with_style_flavored_sentence(input, style, LatexFlavor::Document, de)
.expect("format");
assert_eq!(german, "Das ist eins bzw. zwei.\nUnd drei.\n");
assert_eq!(
format_with_style_flavored_sentence(&german, style, LatexFlavor::Document, de)
.expect("reformat"),
german,
);
let mut map: BTreeMap<String, Vec<String>> = BTreeMap::new();
map.insert("default".to_string(), vec!["foo.".to_string()]);
let mut scratch = Vec::new();
let opts = SentenceOptions::resolve(None, &map, &mut scratch);
let user = format_with_style_flavored_sentence(
"See foo. Then more here. Done.\n",
style,
LatexFlavor::Document,
opts,
)
.expect("format");
assert_eq!(user, "See foo. Then more here.\nDone.\n");
}
#[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 stable_preserves_an_equilibrium_break_that_reflow_removes() {
let input = "Alpha beta gamma delta epsilon.\nZeta eta theta iota kappa lambda.\n";
let stable = FormatStyle {
line_width: 40,
wrap: WrapMode::Stable,
..FormatStyle::default()
};
assert_eq!(
format_with_style(input, stable).expect("stable formats"),
input,
"an authored break at the soft target must remain stable"
);
let reflow = FormatStyle {
line_width: 40,
wrap: WrapMode::Reflow,
..FormatStyle::default()
};
assert_ne!(
format_with_style(input, reflow).expect("reflow formats"),
input,
"canonical reflow should not prefer the authored boundary"
);
}
#[test]
fn stable_repairs_overflow_locally_and_is_idempotent() {
let input = "This stable opening line reaches the target today.\n\
This edited middle line is now much too long for the configured hard width here.\n\
This following boundary reaches the target safely today.\n";
let style = FormatStyle {
line_width: 60,
wrap: WrapMode::Stable,
..FormatStyle::default()
};
let formatted = format_with_style(input, style).expect("stable formats");
assert!(
formatted.starts_with("This stable opening line reaches the target today.\n"),
"the preceding equilibrium boundary should remain fixed: {formatted:?}"
);
assert!(
formatted.lines().all(|line| line.chars().count() <= 60),
"stable wrapping must honor the hard width: {formatted:?}"
);
assert_eq!(
format_with_style(&formatted, style).expect("reformat"),
formatted,
"stable wrapping must be idempotent"
);
}
#[test]
fn stable_rebalances_only_unequilibrated_regions() {
let input = "The opening line is already safely within the accepted range today.\n\
This edited middle line has become too long for the configured width here.\n\
while this following line can donate some nearby space today.\n\
Finally this boundary should remain exactly where it is.\n\n\
This second opening line is also an acceptable stable anchor today.\n\
A shortened line now needs a few more nearby words.\n\
from this following line which has enough content to share with it today.\n\
The final short line remains a valid paragraph ending.\n";
let expected = "The opening line is already safely within the accepted range today.\n\
This edited middle line has become too long for the configured width\n\
here. while this following line can donate some nearby space today.\n\
Finally this boundary should remain exactly where it is.\n\n\
This second opening line is also an acceptable stable anchor today.\n\
A shortened line now needs a few more nearby words. from\n\
this following line which has enough content to share with it today.\n\
The final short line remains a valid paragraph ending.\n";
let style = FormatStyle {
line_width: 70,
wrap: WrapMode::Stable,
..FormatStyle::default()
};
let formatted = format_with_style(input, style).expect("stable formats");
assert_eq!(formatted, expected);
assert_eq!(
format_with_style(&formatted, style).expect("reformat"),
formatted
);
}
#[test]
fn stable_wrapping_is_idempotent_over_random_prose() {
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 random_prose(rng: &mut Lcg) -> String {
let mut out = String::new();
let paragraphs = 1 + rng.below(3);
for p in 0..paragraphs {
if p > 0 {
out.push_str("\n\n");
}
let words = 4 + rng.below(40);
for w in 0..words {
if w > 0 {
out.push(if rng.below(3) == 0 { '\n' } else { ' ' });
}
let len = if rng.below(16) == 0 {
25 + rng.below(20)
} else {
1 + rng.below(12)
};
for _ in 0..len {
out.push((b'a' + rng.below(26) as u8) as char);
}
}
}
out.push('\n');
out
}
let mut rng = Lcg(0x1234_5678_9abc_def0);
for case in 0..400 {
let input = random_prose(&mut rng);
for &line_width in &[24usize, 40, 60, 72, 90] {
let style = FormatStyle {
line_width,
wrap: WrapMode::Stable,
..FormatStyle::default()
};
let once = format_with_style(&input, style)
.unwrap_or_else(|e| panic!("case {case} @ {line_width}: format failed: {e:?}"));
let twice = format_with_style(&once, style)
.unwrap_or_else(|e| panic!("case {case} @ {line_width}: reformat failed: {e:?}"));
assert_eq!(
twice, once,
"stable wrap not idempotent (case {case} @ width {line_width})\ninput: {input:?}\nonce: {once:?}\ntwice: {twice:?}"
);
for line in once.lines() {
let cols = line.chars().count();
let widest_word = line
.split_whitespace()
.map(|w| w.chars().count())
.max()
.unwrap_or(0);
assert!(
cols <= line_width || widest_word > line_width,
"line over hard width with a breakable layout (case {case} @ {line_width}): {line:?}"
);
}
assert!(
parse(&once).errors.is_empty(),
"stable output should parse cleanly (case {case} @ {line_width}): {once:?}"
);
assert_eq!(
reconstruct(&once),
once,
"stable output should round-trip losslessly (case {case} @ {line_width})"
);
}
}
}
#[test]
fn stable_wraps_dtx_doc_prose_within_the_margin() {
let input = "% This documentation paragraph is authored on one long line that clearly overflows.\n\
% A second authored line that also runs well past the configured hard width here.\n";
let style = FormatStyle {
line_width: 50,
wrap: WrapMode::Stable,
..FormatStyle::default()
};
let formatted = format_with_style_flavored(input, style, dtx_config()).expect("stable dtx");
assert!(
formatted.lines().all(|line| line.chars().count() <= 50),
"stable dtx wrapping must honor the hard width including the margin: {formatted:?}"
);
assert!(
formatted.lines().all(|line| line.starts_with('%')),
"stable dtx wrapping must preserve the `%` margin on every line: {formatted:?}"
);
let reparsed = parse_with_flavor(&formatted, dtx_config());
assert!(
reparsed.errors.is_empty(),
"stable dtx output must parse cleanly: {formatted:?}"
);
assert_eq!(
reparsed.syntax().to_string(),
formatted,
"stable dtx output must round-trip losslessly"
);
assert_eq!(
format_with_style_flavored(&formatted, style, dtx_config()).expect("reformat"),
formatted,
"stable dtx wrapping must be idempotent"
);
}
#[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 glued_brace_opener_keeps_first_body_token_on_its_line() {
let style = FormatStyle {
wrap: WrapMode::Preserve,
..FormatStyle::default()
};
let glued = "\\def\\x{\\aaa\\bbb\n\\ccc}\n";
assert_eq!(
format_with_style_flavored(glued, style, LatexFlavor::Package).expect("formats"),
"\\def\\x{\\aaa\\bbb\n \\ccc\n}\n",
"a glued opener must not gain a break (and space token) after `{{`"
);
let spaced = "\\def\\x{ \\aaa\\bbb\n\\ccc}\n";
assert_eq!(
format_with_style_flavored(spaced, style, LatexFlavor::Package).expect("formats"),
"\\def\\x{\n \\aaa\\bbb\n \\ccc\n}\n",
"whitespace after the opener keeps the Allman break"
);
}
#[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"
);
}