From 1e41fb9b2f4c20755e44e32349118997afd27a10 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 04:34:05 -0500 Subject: [PATCH 1/7] fix(latex): keep more math and verbatim environments whole IEEEeqnarraybox, tikzpicture*, and the pgfplots log and semilog axes stay structure. semiverbatim, the verbatimbox environments, and the markdown.sty markdown environments stay code. --- src/parser/latex.rs | 11 +++++ tests/latex_more_envs.rs | 93 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 tests/latex_more_envs.rs diff --git a/src/parser/latex.rs b/src/parser/latex.rs index 84ffa2b5..c06cf4c5 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -50,6 +50,7 @@ static NON_PROSE_ENVS: &[&str] = &[ "eqnarray*", "IEEEeqnarray", "IEEEeqnarray*", + "IEEEeqnarraybox", "subeqnarray", "subeqnarray*", "split", @@ -93,6 +94,7 @@ static NON_PROSE_ENVS: &[&str] = &[ "verbatim", "minted", "tikzpicture", + "tikzpicture*", "tikzcd", "tikzcd*", "quantikz", @@ -101,6 +103,9 @@ static NON_PROSE_ENVS: &[&str] = &[ "pgfpicture*", "axis", "axis*", + "loglogaxis", + "semilogxaxis", + "semilogyaxis", "array", "array*", "subarray", @@ -296,6 +301,12 @@ fn is_builtin_code_env(name: &str) -> bool { | "VerbEnv" | "alltt" | "boxedverbatim" + | "semiverbatim" + | "verbbox" + | "myverbbox" + | "verbnobox" + | "markdown" + | "markdown*" | "verbatimtab" | "verbatimwrite" | "verbwrite" diff --git a/tests/latex_more_envs.rs b/tests/latex_more_envs.rs new file mode 100644 index 00000000..3504f221 --- /dev/null +++ b/tests/latex_more_envs.rs @@ -0,0 +1,93 @@ +//! 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", ""); +} + +#[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*", ""); +} From ceb11e5646d5c216c9e08e603112becc63c34df1 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 04:36:37 -0500 Subject: [PATCH 2/7] fix(pandoc): shield starred verbatim, lstlisting, and minted The code-region check required the closing brace immediately after the name, so a star kept the body out of the region. A snapper:off line inside that body is no longer a pragma. --- src/parser/pandoc/shield.rs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/src/parser/pandoc/shield.rs b/src/parser/pandoc/shield.rs index 74a702b7..01c1de4a 100644 --- a/src/parser/pandoc/shield.rs +++ b/src/parser/pandoc/shield.rs @@ -234,8 +234,11 @@ 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{lstlisting}") + || lower.starts_with("\\begin{lstlisting*}") || lower.starts_with("\\begin{minted}") + || lower.starts_with("\\begin{minted*}") { *in_code = true; *fence_mark = Some(lower.chars().take(24).collect()); @@ -256,8 +259,11 @@ 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{lstlisting}") + || lower.starts_with("\\end{lstlisting*}") || lower.starts_with("\\end{minted}") + || lower.starts_with("\\end{minted*}") { *in_code = false; *fence_mark = None; @@ -316,6 +322,17 @@ 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_markdown_fence_is_ignored() { let input = "```\n\ncode\n\n```\n"; From 4ca94ae17c638c27383428f0f6715fb5370e6575 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 04:36:37 -0500 Subject: [PATCH 3/7] fix(latex): keep breqn dgroup and darray whole dgroup, dgroup*, darray, and darray* are display math. Their bodies stay structure, and a sentence after the environment still splits. --- src/parser/latex.rs | 4 ++++ tests/latex_more_envs.rs | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/src/parser/latex.rs b/src/parser/latex.rs index c06cf4c5..c9585ecc 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -113,6 +113,10 @@ static NON_PROSE_ENVS: &[&str] = &[ "prooftree", "empheq", "empheq*", + "dgroup", + "dgroup*", + "darray", + "darray*", "matrix", "pmatrix", "bmatrix", diff --git a/tests/latex_more_envs.rs b/tests/latex_more_envs.rs index 3504f221..e40198c0 100644 --- a/tests/latex_more_envs.rs +++ b/tests/latex_more_envs.rs @@ -80,6 +80,10 @@ fn more_math_envs_stay_structure() { structure_env_does_not_reflow("loglogaxis", ""); structure_env_does_not_reflow("semilogxaxis", ""); structure_env_does_not_reflow("semilogyaxis", ""); + 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] From 1aefe7d044a5ce4e74350368055287ad9fb0d00a Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 04:57:52 -0500 Subject: [PATCH 4/7] style(pandoc): wrap the starred-verbatim test input rustfmt wants the format string on its own line. --- src/parser/pandoc/shield.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/parser/pandoc/shield.rs b/src/parser/pandoc/shield.rs index 01c1de4a..0f451e96 100644 --- a/src/parser/pandoc/shield.rs +++ b/src/parser/pandoc/shield.rs @@ -325,7 +325,8 @@ mod tests { #[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"); + 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:?}" From 3e207aad6cfb6224b2afe6f91d01e8c050d5354a Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 05:08:35 -0500 Subject: [PATCH 5/7] fix(pandoc): shield alltt, BVerbatim, and luacode Case folding turns BVerbatim into bverbatim, so the verbatim arm never matches. alltt, luacode, and luacode* were also outside the code region, so a snapper:off inside them was a pragma. --- src/parser/pandoc/shield.rs | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/src/parser/pandoc/shield.rs b/src/parser/pandoc/shield.rs index 0f451e96..9f075a19 100644 --- a/src/parser/pandoc/shield.rs +++ b/src/parser/pandoc/shield.rs @@ -235,10 +235,14 @@ fn toggle_code( || 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()); @@ -260,10 +264,14 @@ fn toggle_code( || 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; @@ -334,6 +342,18 @@ mod tests { } } + #[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\ncode\n\n```\n"; From c8fb922846902e865c2dc0a1434bc97a3e7e7337 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 05:10:26 -0500 Subject: [PATCH 6/7] fix(latex): keep pgfplots group and special axes whole groupplot, smithchart, smithchartaxis, polaraxis, and ternaryaxis stay structure, same as axis and loglogaxis. Prose after each environment still splits. --- src/parser/latex.rs | 9 ++++++++- tests/latex_more_envs.rs | 5 +++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/parser/latex.rs b/src/parser/latex.rs index c9585ecc..15dabdd3 100644 --- a/src/parser/latex.rs +++ b/src/parser/latex.rs @@ -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. // // `figure` / `table` (and stars) are not here: Overleaf FigureEnvironment // is Content, tree-sitter caption curly_group is text. Float chrome @@ -106,6 +108,11 @@ static NON_PROSE_ENVS: &[&str] = &[ "loglogaxis", "semilogxaxis", "semilogyaxis", + "groupplot", + "smithchart", + "smithchartaxis", + "polaraxis", + "ternaryaxis", "array", "array*", "subarray", diff --git a/tests/latex_more_envs.rs b/tests/latex_more_envs.rs index e40198c0..16922f93 100644 --- a/tests/latex_more_envs.rs +++ b/tests/latex_more_envs.rs @@ -80,6 +80,11 @@ fn more_math_envs_stay_structure() { 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", ""); From 7a874305dd8bb29835c4bcbaa882c635dd11dbf0 Mon Sep 17 00:00:00 2001 From: Rohit Goswami Date: Sat, 26 Sep 2026 05:25:08 -0500 Subject: [PATCH 7/7] fix(plaintext): do not split a balanced quote on a period A period before a backtick and a capital stays inside the open span. The same glue holds when an org equals span hides a paren. --- src/sentence/unicode.rs | 29 +++++++++++++++++++++++++---- tests/sentence_delim_props.rs | 25 +++++++++++++++++++++++++ 2 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/sentence/unicode.rs b/src/sentence/unicode.rs index 35823ceb..f6507778 100644 --- a/src/sentence/unicode.rs +++ b/src/sentence/unicode.rs @@ -3452,6 +3452,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, 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) -> Vec { let mut result: Vec = Vec::with_capacity(segments.len()); let mut state = DelimState::default(); @@ -3471,10 +3487,10 @@ fn merge_splits_inside_delimiters(segments: Vec) -> Vec { } 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; } @@ -3483,6 +3499,11 @@ fn merge_splits_inside_delimiters(segments: Vec) -> Vec { } 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()); } diff --git a/tests/sentence_delim_props.rs b/tests/sentence_delim_props.rs index e93b0ad4..059f0c9d 100644 --- a/tests/sentence_delim_props.rs +++ b/tests/sentence_delim_props.rs @@ -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.