diff --git a/src/parser/latex.rs b/src/parser/latex.rs index d1c88469..6b6f82b6 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. // mathtools `multlined` / `lgathered` / `rgathered` and the starred // matrix family, plus breqn `dmath` / `dmath*`, are the same class. // There is no `multlined*` / `lgathered*` / `rgathered*`. breqn @@ -57,6 +59,7 @@ static NON_PROSE_ENVS: &[&str] = &[ "eqnarray*", "IEEEeqnarray", "IEEEeqnarray*", + "IEEEeqnarraybox", "subeqnarray", "subeqnarray*", "split", @@ -100,6 +103,7 @@ static NON_PROSE_ENVS: &[&str] = &[ "verbatim", "minted", "tikzpicture", + "tikzpicture*", "tikzcd", "tikzcd*", "quantikz", @@ -108,6 +112,14 @@ static NON_PROSE_ENVS: &[&str] = &[ "pgfpicture*", "axis", "axis*", + "loglogaxis", + "semilogxaxis", + "semilogyaxis", + "groupplot", + "smithchart", + "smithchartaxis", + "polaraxis", + "ternaryaxis", "array", "array*", "subarray", @@ -115,6 +127,10 @@ static NON_PROSE_ENVS: &[&str] = &[ "prooftree", "empheq", "empheq*", + "dgroup", + "dgroup*", + "darray", + "darray*", "matrix", "matrix*", "pmatrix", @@ -320,6 +336,12 @@ fn is_builtin_code_env(name: &str) -> bool { | "VerbEnv" | "alltt" | "boxedverbatim" + | "semiverbatim" + | "verbbox" + | "myverbbox" + | "verbnobox" + | "markdown" + | "markdown*" | "verbatimtab" | "verbatimwrite" | "verbwrite" diff --git a/src/parser/pandoc/shield.rs b/src/parser/pandoc/shield.rs index 74a702b7..9f075a19 100644 --- a/src/parser/pandoc/shield.rs +++ b/src/parser/pandoc/shield.rs @@ -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()); @@ -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; @@ -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\ncode\n\n```\n"; diff --git a/src/sentence/unicode.rs b/src/sentence/unicode.rs index ad6836d3..33c54e41 100644 --- a/src/sentence/unicode.rs +++ b/src/sentence/unicode.rs @@ -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, 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(); @@ -3532,10 +3548,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; } @@ -3544,6 +3560,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/latex_more_envs.rs b/tests/latex_more_envs.rs new file mode 100644 index 00000000..16922f93 --- /dev/null +++ b/tests/latex_more_envs.rs @@ -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*", ""); +} 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.