Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion src/parser/latex.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,9 @@ use crate::sentence::unicode::{
// Overleaf leftover names (tokens.mjs) are `IEEEeqnarray` /
// `IEEEeqnarray*` / `subeqnarray` / `subeqnarray*` / `xltabular` /
// `math*`. `tikzcd` / pgfplots `axis` / `pgfpicture` are the same
// class (and starred variants). Not every pgfplots name.
// class (and starred variants). `groupplot`, `smithchart`
// (alias of `smithchartaxis`), `polaraxis`, and `ternaryaxis`
// are that class too. Not every pgfplots name.
// mathtools `multlined` / `lgathered` / `rgathered` and the starred
// matrix family, plus breqn `dmath` / `dmath*`, are the same class.
// There is no `multlined*` / `lgathered*` / `rgathered*`. breqn
Expand Down Expand Up @@ -57,6 +59,7 @@ static NON_PROSE_ENVS: &[&str] = &[
"eqnarray*",
"IEEEeqnarray",
"IEEEeqnarray*",
"IEEEeqnarraybox",
"subeqnarray",
"subeqnarray*",
"split",
Expand Down Expand Up @@ -100,6 +103,7 @@ static NON_PROSE_ENVS: &[&str] = &[
"verbatim",
"minted",
"tikzpicture",
"tikzpicture*",
"tikzcd",
"tikzcd*",
"quantikz",
Expand All @@ -108,13 +112,25 @@ static NON_PROSE_ENVS: &[&str] = &[
"pgfpicture*",
"axis",
"axis*",
"loglogaxis",
"semilogxaxis",
"semilogyaxis",
"groupplot",
"smithchart",
"smithchartaxis",
"polaraxis",
"ternaryaxis",
"array",
"array*",
"subarray",
"CD",
"prooftree",
"empheq",
"empheq*",
"dgroup",
"dgroup*",
"darray",
"darray*",
"matrix",
"matrix*",
"pmatrix",
Expand Down Expand Up @@ -320,6 +336,12 @@ fn is_builtin_code_env(name: &str) -> bool {
| "VerbEnv"
| "alltt"
| "boxedverbatim"
| "semiverbatim"
| "verbbox"
| "myverbbox"
| "verbnobox"
| "markdown"
| "markdown*"
| "verbatimtab"
| "verbatimwrite"
| "verbwrite"
Expand Down
38 changes: 38 additions & 0 deletions src/parser/pandoc/shield.rs
Original file line number Diff line number Diff line change
Expand Up @@ -234,8 +234,15 @@ fn toggle_code(
|| lower.starts_with("#+begin_example")
|| lower.starts_with("#+begin_export")
|| lower.starts_with("\\begin{verbatim}")
|| lower.starts_with("\\begin{verbatim*}")
|| lower.starts_with("\\begin{bverbatim}")
|| lower.starts_with("\\begin{lstlisting}")
|| lower.starts_with("\\begin{lstlisting*}")
|| lower.starts_with("\\begin{minted}")
|| lower.starts_with("\\begin{minted*}")
|| lower.starts_with("\\begin{alltt}")
|| lower.starts_with("\\begin{luacode}")
|| lower.starts_with("\\begin{luacode*}")
{
*in_code = true;
*fence_mark = Some(lower.chars().take(24).collect());
Expand All @@ -256,8 +263,15 @@ fn toggle_code(
|| lower.starts_with("#+end_example")
|| lower.starts_with("#+end_export")
|| lower.starts_with("\\end{verbatim}")
|| lower.starts_with("\\end{verbatim*}")
|| lower.starts_with("\\end{bverbatim}")
|| lower.starts_with("\\end{lstlisting}")
|| lower.starts_with("\\end{lstlisting*}")
|| lower.starts_with("\\end{minted}")
|| lower.starts_with("\\end{minted*}")
|| lower.starts_with("\\end{alltt}")
|| lower.starts_with("\\end{luacode}")
|| lower.starts_with("\\end{luacode*}")
{
*in_code = false;
*fence_mark = None;
Expand Down Expand Up @@ -316,6 +330,30 @@ mod tests {
assert!(!chunk.contains("After."));
}

#[test]
fn pragma_inside_starred_verbatim_is_ignored() {
for env in ["verbatim*", "lstlisting*", "minted*"] {
let input =
format!("\\begin{{{env}}}\nsnapper:off\nSee Dr. Smith. He left.\n\\end{{{env}}}\n");
assert!(
pragma_spans(&input).is_empty(),
"{env} must hide an interior pragma, got spans in {input:?}"
);
}
}

#[test]
fn pragma_inside_alltt_bverbatim_and_luacode_is_ignored() {
for env in ["alltt", "BVerbatim", "luacode", "luacode*"] {
let input =
format!("\\begin{{{env}}}\nsnapper:off\nSee Dr. Smith. He left.\n\\end{{{env}}}\n");
assert!(
pragma_spans(&input).is_empty(),
"{env} must hide an interior pragma, got spans in {input:?}"
);
}
}

#[test]
fn pragma_inside_markdown_fence_is_ignored() {
let input = "```\n<!-- snapper:off -->\ncode\n<!-- snapper:on -->\n```\n";
Expand Down
29 changes: 25 additions & 4 deletions src/sentence/unicode.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3513,6 +3513,22 @@ fn wrap_closes_after_bang(piece: &str, bang_at: usize) -> bool {
!dq && !sq && latex <= 0 && paren <= 0 && bracket <= 0 && brace <= 0
}

/// Append `` `Capital `` onto a segment that already ends in `.!?`.
/// No space: a space is a new UAX boundary.
fn glue_tick_capital_after_punct(result: &mut Vec<String>, segment: &str) -> bool {
if !result.last().is_some_and(|last| {
last.ends_with(['.', '!', '?']) && piece_starts_sentence_after_ticks(segment)
}) {
return false;
}
if let Some(last) = result.last_mut() {
last.push_str(segment);
} else {
result.push(segment.to_string());
}
true
}

fn merge_splits_inside_delimiters(segments: Vec<String>) -> Vec<String> {
let mut result: Vec<String> = Vec::with_capacity(segments.len());
let mut state = DelimState::default();
Expand All @@ -3532,10 +3548,10 @@ fn merge_splits_inside_delimiters(segments: Vec<String>) -> Vec<String> {
}
continue;
}
if result.last().is_some_and(|last| {
last.ends_with(['.', '!', '?']) && piece_starts_sentence_after_ticks(&segment)
}) {
result.push(segment.clone());
// `.` + leftover `` `Capital `` is a UAX break. A newline here
// sits inside the open span (`""`A`"`a`.`A"`). Glue with no
// invented space.
if glue_tick_capital_after_punct(&mut result, &segment) {
state.feed(&segment);
continue;
}
Expand All @@ -3544,6 +3560,11 @@ fn merge_splits_inside_delimiters(segments: Vec<String>) -> Vec<String> {
} else {
result.push(segment.clone());
}
} else if glue_tick_capital_after_punct(&mut result, &segment) {
// `=(a=` is an org span once a newline precedes `=`, so the
// paren is hidden and this arm must glue the same ticks.
state.feed(&segment);
continue;
} else {
result.push(segment.clone());
}
Expand Down
102 changes: 102 additions & 0 deletions tests/latex_more_envs.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
//! Math and verbatim environments whose bodies must not reflow as prose.
//! Surrounding prose still splits.

use snapper_fmt::format::Format;
use snapper_fmt::parser::latex::LatexParser;
use snapper_fmt::parser::{FormatParser, Region};
use snapper_fmt::{FormatConfig, format_text};

fn latex_cfg() -> FormatConfig {
FormatConfig {
format: Format::Latex,
..Default::default()
}
.without_safety_backstops()
}

fn structure_env_does_not_reflow(name: &str, begin_suffix: &str) {
let input = format!(
"\\begin{{{name}}}{begin_suffix}\nThis is a long sentence that must stay inside {name} and must not reflow as prose. Another sentence stays put.\n\\end{{{name}}}\nAfter the env. Second sentence.\n"
);
let fused = format!(
"This is a long sentence that must stay inside {name} and must not reflow as prose. Another sentence stays put."
);
let regions = LatexParser::default().parse(&input);
assert!(
regions
.iter()
.any(|r| matches!(r, Region::Structure(s) if s.contains(&fused))),
"{name} body must be Structure, got: {regions:?}"
);
assert!(
!regions
.iter()
.any(|r| matches!(r, Region::Prose(p) if p.contains("must not reflow"))),
"{name} body must not be Prose, got: {regions:?}"
);
let out = format_text(&input, &latex_cfg()).unwrap();
assert!(
out.contains(&fused),
"{name} body must stay one source line, got:\n{out}"
);
assert!(
out.contains("After the env.\nSecond sentence."),
"prose after {name} must still reflow, got:\n{out}"
);
assert_eq!(format_text(&out, &latex_cfg()).unwrap(), out);
}

fn code_env_does_not_reflow(name: &str, begin_suffix: &str) {
let input =
format!("\\begin{{{name}}}{begin_suffix}\nFirst. Second.\n\\end{{{name}}}\nAfter. Next.\n");
let regions = LatexParser::default().parse(&input);
assert!(
regions.iter().any(|r| matches!(
r,
Region::Code { body, .. } if body.contains("First. Second.")
)),
"{name} body must be Code, got: {regions:?}"
);
let out = format_text(&input, &latex_cfg()).unwrap();
assert!(
out.contains("First. Second."),
"{name} body must stay one source line, got:\n{out}"
);
assert!(
!out.contains("First.\nSecond."),
"{name} must not reflow as prose, got:\n{out}"
);
assert!(
out.contains("After.\nNext."),
"prose after {name} must still split, got:\n{out}"
);
assert_eq!(format_text(&out, &latex_cfg()).unwrap(), out);
}

#[test]
fn more_math_envs_stay_structure() {
structure_env_does_not_reflow("IEEEeqnarraybox", "{rCl}");
structure_env_does_not_reflow("tikzpicture*", "");
structure_env_does_not_reflow("loglogaxis", "");
structure_env_does_not_reflow("semilogxaxis", "");
structure_env_does_not_reflow("semilogyaxis", "");
structure_env_does_not_reflow("groupplot", "");
structure_env_does_not_reflow("smithchart", "");
structure_env_does_not_reflow("smithchartaxis", "");
structure_env_does_not_reflow("polaraxis", "");
structure_env_does_not_reflow("ternaryaxis", "");
structure_env_does_not_reflow("dgroup", "");
structure_env_does_not_reflow("dgroup*", "");
structure_env_does_not_reflow("darray", "");
structure_env_does_not_reflow("darray*", "");
}

#[test]
fn more_verbatim_envs_stay_code() {
code_env_does_not_reflow("semiverbatim", "");
code_env_does_not_reflow("verbbox", "");
code_env_does_not_reflow("myverbbox", "{\\mybox}");
code_env_does_not_reflow("verbnobox", "");
code_env_does_not_reflow("markdown", "");
code_env_does_not_reflow("markdown*", "");
}
25 changes: 25 additions & 0 deletions tests/sentence_delim_props.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,31 @@ fn plaintext_period_before_backticks_quote_is_span_safe() {
);
}

/// macOS proptest seed. `""`A`"`a`.`A"` is delimiters_balanced.
/// The period before `` `A" `` must not become a line break inside the quote.
#[test]
fn plaintext_balanced_quote_period_backtick_is_span_safe() {
let input = "\"\"`A`\"`a`.`A\"";
assert!(
delimiters_balanced(&input),
"seed must be balanced so the property would fire\n in={input:?}"
);
let out = format_plain(&input);
let again = format_plain(&out);
assert_eq!(
out, "\"\"`A`\"`a`.`A\"\n",
"must not split after the period\n in={input:?}\n out={out:?}"
);
assert_eq!(
again, out,
"idempotence\n in={input:?}\n out={out:?}\n again={again:?}"
);
assert!(
newlines_respect_delimiter_spans(&out),
"span newline\n in={input:?}\n out={out:?}"
);
}

/// GitHub #374. `".` A`'``aaa0`"0` is delimiters_balanced; format_text
/// used to insert a newline after `.` so newlines_respect_delimiter_spans
/// failed. Markup closer-split must not break the still-open quote.
Expand Down
Loading