Skip to main content

snapper_fmt/
check.rs

1//! Line-level `--check` diagnostics: fused, wrap, and long.
2//!
3//! These kinds describe the source as written. They share the same sentence
4//! splitter as format so abbreviations do not produce false fused hits.
5
6use serde::Serialize;
7
8use crate::format::Format;
9use crate::parser::source_line_payloads;
10use crate::sentence::SentenceSplitter;
11use crate::{FormatConfig, format_text};
12
13/// Default character threshold for the advisory `long` kind when `max_width`
14/// is unset (0).
15pub const DEFAULT_LONG_THRESHOLD: usize = 120;
16
17/// Kind of a line-level check diagnostic.
18#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "lowercase")]
20pub enum DiagnosticKind {
21    /// Splitter finds more than one sentence on a prose line.
22    Fused,
23    /// Mid-clause continuation: the previous prose line does not end a clause
24    /// and this line starts with a lowercase non-connector word.
25    Wrap,
26    /// Advisory: prose line exceeds the width threshold and has a clause
27    /// boundary where a break could go.
28    Long,
29}
30
31impl DiagnosticKind {
32    pub fn as_str(self) -> &'static str {
33        match self {
34            DiagnosticKind::Fused => "fused",
35            DiagnosticKind::Wrap => "wrap",
36            DiagnosticKind::Long => "long",
37        }
38    }
39}
40
41/// One 1-indexed diagnostic on a source line.
42#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct LineDiagnostic {
44    pub line: usize,
45    pub kind: DiagnosticKind,
46    pub excerpt: String,
47}
48
49/// Width used for `long`: `max_width` when set, otherwise the configured
50/// default (120 if unset).
51pub fn resolve_long_threshold(max_width: usize, configured: Option<usize>) -> usize {
52    if max_width > 0 {
53        max_width
54    } else {
55        configured.unwrap_or(DEFAULT_LONG_THRESHOLD)
56    }
57}
58
59/// Identity check used by CLI `--check` and MCP `would_reformat`.
60pub fn would_reformat(input: &str, config: &FormatConfig) -> anyhow::Result<bool> {
61    let output = format_text(input, config)?;
62    Ok(output != input)
63}
64
65/// Connector words that start an intentional semantic break, not a wrap.
66const WRAP_CONNECTORS: &[&str] = &[
67    "and", "but", "so", "or", "nor", "yet", "which", "that", "where", "who", "whose", "whom",
68    "when", "while", "because", "although", "though", "unless", "until", "if", "as",
69];
70
71/// Collect fused / wrap / long diagnostics for `input`.
72///
73/// `long_threshold` is character count, already resolved by
74/// [`resolve_long_threshold`]. `config` supplies `[latex]` extras so
75/// `--check` uses the same region kinds as `format_text`. `None` keeps
76/// the built-in lists.
77pub fn collect_diagnostics(
78    input: &str,
79    format: Format,
80    splitter: &dyn SentenceSplitter,
81    long_threshold: usize,
82    config: Option<&FormatConfig>,
83) -> Vec<LineDiagnostic> {
84    let lines: Vec<&str> = input.lines().collect();
85    let payloads = source_line_payloads(input, format, config);
86    debug_assert_eq!(
87        payloads.len(),
88        lines.len(),
89        "parser line map must cover every source line (got {} payloads for {} lines)",
90        payloads.len(),
91        lines.len()
92    );
93    let mut diagnostics = Vec::new();
94    let mut prev_prose: Option<String> = None;
95
96    for (idx, line) in lines.iter().enumerate() {
97        if line.trim().is_empty() {
98            prev_prose = None;
99            continue;
100        }
101        let Some(payload) = payloads.get(idx).and_then(|p| p.as_deref()) else {
102            prev_prose = None;
103            continue;
104        };
105
106        let line_no = idx + 1;
107        let excerpt = excerpt_of(line);
108
109        if splitter.split(payload.trim()).len() > 1 {
110            diagnostics.push(LineDiagnostic {
111                line: line_no,
112                kind: DiagnosticKind::Fused,
113                excerpt: excerpt.clone(),
114            });
115        }
116
117        if let Some(prev) = prev_prose.as_deref() {
118            if !ends_clause_or_quote(prev) {
119                if let Some(word) = leading_lowercase_word(payload) {
120                    if !WRAP_CONNECTORS.contains(&word.as_str()) {
121                        diagnostics.push(LineDiagnostic {
122                            line: line_no,
123                            kind: DiagnosticKind::Wrap,
124                            excerpt: excerpt.clone(),
125                        });
126                    }
127                }
128            }
129        }
130
131        let width = payload.chars().count();
132        if width > long_threshold && has_clause_boundary_hint(payload) {
133            diagnostics.push(LineDiagnostic {
134                line: line_no,
135                kind: DiagnosticKind::Long,
136                excerpt,
137            });
138        }
139
140        prev_prose = Some(payload.to_string());
141    }
142
143    diagnostics
144}
145
146fn excerpt_of(line: &str) -> String {
147    const MAX: usize = 200;
148    let trimmed = line.trim();
149    if trimmed.chars().count() <= MAX {
150        return trimmed.to_string();
151    }
152    let mut out: String = trimmed.chars().take(MAX).collect();
153    out.push_str("...");
154    out
155}
156
157fn ends_clause_or_quote(line: &str) -> bool {
158    let trimmed = line.trim_end();
159    if trimmed.is_empty() {
160        return false;
161    }
162    if trimmed.ends_with('\u{2014}') || trimmed.ends_with("--") {
163        return true;
164    }
165    matches!(
166        trimmed.chars().last(),
167        Some(
168            '.' | '!'
169                | '?'
170                | ';'
171                | ':'
172                | ','
173                | '"'
174                | '\''
175                | '\u{201d}'
176                | '\u{2019}'
177                | ')'
178                | ']'
179                | '}'
180        )
181    )
182}
183
184fn leading_lowercase_word(line: &str) -> Option<String> {
185    let trimmed = line.trim_start();
186    let mut chars = trimmed.chars();
187    let first = chars.next()?;
188    if !first.is_lowercase() {
189        return None;
190    }
191    let mut word = String::new();
192    word.push(first);
193    for c in chars {
194        if c.is_alphabetic() || c == '\'' {
195            word.push(c);
196        } else {
197            break;
198        }
199    }
200    Some(word)
201}
202
203fn has_clause_boundary_hint(line: &str) -> bool {
204    if line.contains('\u{2014}') || line.contains("--") {
205        return true;
206    }
207    let mut i = 0;
208    while i < line.len() {
209        let c = line[i..].chars().next().unwrap();
210        let len = c.len_utf8();
211        if matches!(c, ',' | ';' | ':' | '.' | '!' | '?') {
212            let rest = &line[i + len..];
213            if rest.starts_with(|n: char| n.is_whitespace()) {
214                return true;
215            }
216        }
217        i += len;
218    }
219    false
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225    use crate::sentence::unicode::UnicodeSentenceSplitter;
226
227    fn diags(input: &str) -> Vec<LineDiagnostic> {
228        let splitter = UnicodeSentenceSplitter::new();
229        collect_diagnostics(
230            input,
231            Format::Plaintext,
232            &splitter,
233            DEFAULT_LONG_THRESHOLD,
234            None,
235        )
236    }
237
238    fn kinds_on(diags: &[LineDiagnostic], line: usize) -> Vec<DiagnosticKind> {
239        diags
240            .iter()
241            .filter(|d| d.line == line)
242            .map(|d| d.kind)
243            .collect()
244    }
245
246    #[test]
247    fn fused_two_sentences_on_one_line() {
248        let found = diags("Hello world. This is a test.\n");
249        assert!(
250            found
251                .iter()
252                .any(|d| d.line == 1 && d.kind == DiagnosticKind::Fused),
253            "expected fused on line 1, got {found:?}"
254        );
255        assert!(
256            found
257                .iter()
258                .any(|d| d.kind == DiagnosticKind::Fused && d.excerpt.contains("Hello world")),
259            "fused excerpt should carry the source line, got {found:?}"
260        );
261    }
262
263    #[test]
264    fn fused_abbreviation_is_not_a_sentence_break() {
265        let found = diags("See Fig. 3 for details.\n");
266        assert!(
267            found.iter().all(|d| d.kind != DiagnosticKind::Fused),
268            "Fig. must not produce fused, got {found:?}"
269        );
270    }
271
272    #[test]
273    fn wrap_mid_clause_continuation() {
274        let found = diags("The experiment ran for several\nweeks using the usual protocol.\n");
275        assert!(
276            found
277                .iter()
278                .any(|d| d.line == 2 && d.kind == DiagnosticKind::Wrap),
279            "expected wrap on the continuation line, got {found:?}"
280        );
281        assert!(
282            found
283                .iter()
284                .any(|d| d.kind == DiagnosticKind::Wrap && d.excerpt.contains("weeks")),
285            "wrap excerpt should be the continuation line, got {found:?}"
286        );
287    }
288
289    #[test]
290    fn wrap_skips_connector_and() {
291        let found = diags("The experiment ran for several weeks\nand used the usual protocol.\n");
292        assert!(
293            found.iter().all(|d| d.kind != DiagnosticKind::Wrap),
294            "connector-led and is not wrap, got {found:?}"
295        );
296    }
297
298    #[test]
299    fn wrap_skips_connector_which() {
300        let found = diags("The results were significant\nwhich surprised the team.\n");
301        assert!(
302            found.iter().all(|d| d.kind != DiagnosticKind::Wrap),
303            "connector-led which is not wrap, got {found:?}"
304        );
305    }
306
307    #[test]
308    fn wrap_skips_after_comma() {
309        let found = diags("The experiment ran for several weeks,\nusing the usual protocol.\n");
310        assert!(
311            found.iter().all(|d| d.kind != DiagnosticKind::Wrap),
312            "a comma-ended previous line is a clause break, not wrap, got {found:?}"
313        );
314    }
315
316    #[test]
317    fn wrap_skips_after_em_dash() {
318        let found =
319            diags("The experiment ran for several weeks \u{2014}\nusing the usual protocol.\n");
320        assert!(
321            found.iter().all(|d| d.kind != DiagnosticKind::Wrap),
322            "an em-dash-ended previous line is not wrap, got {found:?}"
323        );
324    }
325
326    #[test]
327    fn wrap_skips_after_closing_quote() {
328        let found = diags("He said \"yes\"\nwithout any pause.\n");
329        assert!(
330            found.iter().all(|d| d.kind != DiagnosticKind::Wrap),
331            "a closing-quote-ended previous line is not wrap, got {found:?}"
332        );
333    }
334
335    #[test]
336    fn wrap_skips_uppercase_start() {
337        let found = diags(
338            "The experiment ran for several weeks\nUsing a different protocol is possible.\n",
339        );
340        assert!(
341            found.iter().all(|d| d.kind != DiagnosticKind::Wrap),
342            "uppercase start is not a mid-clause wrap, got {found:?}"
343        );
344    }
345
346    #[test]
347    fn long_advisory_needs_clause_boundary() {
348        let long_with_comma = format!(
349            "The quick brown fox jumps over the lazy dog, then continues running across a very long meadow without pausing for breath at all today.\n"
350        );
351        assert!(
352            long_with_comma.trim_end().chars().count() > DEFAULT_LONG_THRESHOLD,
353            "fixture must exceed the default long threshold"
354        );
355        let found = diags(&long_with_comma);
356        assert!(
357            found
358                .iter()
359                .any(|d| d.line == 1 && d.kind == DiagnosticKind::Long),
360            "long line with a comma should be long, got {found:?}"
361        );
362
363        let no_hint = format!("{}\n", "A".repeat(DEFAULT_LONG_THRESHOLD + 10));
364        let found = diags(&no_hint);
365        assert!(
366            found.iter().all(|d| d.kind != DiagnosticKind::Long),
367            "a long line with no clause-boundary hint is not long, got {found:?}"
368        );
369    }
370
371    #[test]
372    fn long_uses_resolved_threshold() {
373        let splitter = UnicodeSentenceSplitter::new();
374        let line = "Short clause, still short.\n";
375        let found = collect_diagnostics(line, Format::Plaintext, &splitter, 5, None);
376        assert!(
377            found.iter().any(|d| d.kind == DiagnosticKind::Long),
378            "threshold 5 must flag a comma-bearing line, got {found:?}"
379        );
380    }
381
382    #[test]
383    fn fused_and_long_can_share_a_line() {
384        let line = "Hello world. This is a test that goes on and on, with extra words to exceed the default long threshold of one hundred twenty characters easily.\n";
385        assert!(line.trim_end().chars().count() > DEFAULT_LONG_THRESHOLD);
386        let found = diags(line);
387        let kinds = kinds_on(&found, 1);
388        assert!(
389            kinds.contains(&DiagnosticKind::Fused),
390            "expected fused, got {found:?}"
391        );
392        assert!(
393            kinds.contains(&DiagnosticKind::Long),
394            "expected long, got {found:?}"
395        );
396    }
397
398    #[test]
399    fn structure_and_code_are_not_prose() {
400        let md = "# Title. Still a heading.\n\n```\nHello. World.\n```\n\nBody sentence.\n";
401        let splitter = UnicodeSentenceSplitter::new();
402        let found = collect_diagnostics(
403            md,
404            Format::Markdown,
405            &splitter,
406            DEFAULT_LONG_THRESHOLD,
407            None,
408        );
409        assert!(
410            found.iter().all(|d| d.kind != DiagnosticKind::Fused),
411            "headings and fenced code must not produce fused, got {found:?}"
412        );
413    }
414
415    fn assert_no_kind_on(
416        found: &[LineDiagnostic],
417        kind: DiagnosticKind,
418        lines: &[usize],
419        msg: &str,
420    ) {
421        for line in lines {
422            assert!(
423                found.iter().all(|d| !(d.line == *line && d.kind == kind)),
424                "{msg}: line {line} has {kind:?} in {found:?}"
425            );
426        }
427    }
428
429    #[test]
430    fn org_quote_comment_drawer_are_not_prose() {
431        let input = concat!(
432            "#+BEGIN_QUOTE\n",
433            "Quoted hello. Quoted world.\n",
434            "#+END_QUOTE\n",
435            "# Comment hello. Comment world.\n",
436            ":PROPERTIES:\n",
437            ":ID: drawer-value-hello. Drawer world with extra padding so a comma, stays structure.\n",
438            ":END:\n",
439            "\n",
440            "Real prose. Second sentence.\n",
441        );
442        let splitter = UnicodeSentenceSplitter::new();
443        let found =
444            collect_diagnostics(input, Format::Org, &splitter, DEFAULT_LONG_THRESHOLD, None);
445        assert_no_kind_on(
446            &found,
447            DiagnosticKind::Fused,
448            &[2, 4, 6],
449            "org quote/comment/drawer must not be fused",
450        );
451        assert!(
452            found
453                .iter()
454                .any(|d| d.line == 9 && d.kind == DiagnosticKind::Fused),
455            "real org prose should still be fused, got {found:?}"
456        );
457    }
458
459    #[test]
460    fn markdown_front_matter_and_setext_are_not_prose() {
461        let input = concat!(
462            "---\n",
463            "title: Hello. World in front matter.\n",
464            "---\n",
465            "\n",
466            "Setext Title. Still Title\n",
467            "=========================\n",
468            "\n",
469            "Body one. Body two.\n",
470        );
471        let splitter = UnicodeSentenceSplitter::new();
472        let found = collect_diagnostics(
473            input,
474            Format::Markdown,
475            &splitter,
476            DEFAULT_LONG_THRESHOLD,
477            None,
478        );
479        assert_no_kind_on(
480            &found,
481            DiagnosticKind::Fused,
482            &[2, 5],
483            "front matter and setext title must not be fused",
484        );
485        assert!(
486            found
487                .iter()
488                .any(|d| d.line == 8 && d.kind == DiagnosticKind::Fused),
489            "markdown body should still be fused, got {found:?}"
490        );
491    }
492
493    #[test]
494    fn latex_preamble_and_equation_are_not_prose() {
495        let input = concat!(
496            "\\documentclass{article}\n",
497            "\\usepackage{amsmath}\n",
498            "\\begin{document}\n",
499            "\\begin{equation}\n",
500            "E = mc^2 + a very long expression, with commas, that exceeds one hundred twenty characters easily xxxxxxxxxxxxxxxxx\n",
501            "\\end{equation}\n",
502            "Body one. Body two.\n",
503            "\\end{document}\n",
504        );
505        let splitter = UnicodeSentenceSplitter::new();
506        let found = collect_diagnostics(
507            input,
508            Format::Latex,
509            &splitter,
510            DEFAULT_LONG_THRESHOLD,
511            None,
512        );
513        assert_no_kind_on(
514            &found,
515            DiagnosticKind::Fused,
516            &[1, 2, 3, 4, 5, 6, 8],
517            "latex preamble and equation must not be fused",
518        );
519        assert!(
520            found
521                .iter()
522                .all(|d| !(d.line == 5 && d.kind == DiagnosticKind::Long)),
523            "equation body must not be long, got {found:?}"
524        );
525        assert!(
526            found
527                .iter()
528                .any(|d| d.line == 7 && d.kind == DiagnosticKind::Fused),
529            "latex body should still be fused, got {found:?}"
530        );
531    }
532
533    #[test]
534    fn rst_title_and_note_body_are_not_prose() {
535        let input = concat!(
536            "Title Here. With Period.\n",
537            "========================\n",
538            "\n",
539            ".. note::\n",
540            "\n",
541            "   This is a note. With two sentences.\n",
542            "\n",
543            "Body one. Body two.\n",
544        );
545        let splitter = UnicodeSentenceSplitter::new();
546        let found =
547            collect_diagnostics(input, Format::Rst, &splitter, DEFAULT_LONG_THRESHOLD, None);
548        assert_no_kind_on(
549            &found,
550            DiagnosticKind::Fused,
551            &[1, 4, 6],
552            "rst title and note body must not be fused",
553        );
554        assert!(
555            found
556                .iter()
557                .any(|d| d.line == 8 && d.kind == DiagnosticKind::Fused),
558            "rst body should still be fused, got {found:?}"
559        );
560    }
561
562    #[test]
563    fn snapper_off_region_is_not_prose() {
564        let input = concat!(
565            "Hello world. This is a test.\n",
566            "snapper:off\n",
567            "Do not. Touch this.\n",
568            "snapper:on\n",
569            "After one. After two.\n",
570        );
571        let splitter = UnicodeSentenceSplitter::new();
572        let found = collect_diagnostics(
573            input,
574            Format::Plaintext,
575            &splitter,
576            DEFAULT_LONG_THRESHOLD,
577            None,
578        );
579        assert_no_kind_on(
580            &found,
581            DiagnosticKind::Fused,
582            &[2, 3, 4],
583            "snapper:off body must not be fused",
584        );
585        assert!(
586            found
587                .iter()
588                .any(|d| d.line == 1 && d.kind == DiagnosticKind::Fused),
589            "prose before snapper:off should be fused, got {found:?}"
590        );
591        assert!(
592            found
593                .iter()
594                .any(|d| d.line == 5 && d.kind == DiagnosticKind::Fused),
595            "prose after snapper:on should be fused, got {found:?}"
596        );
597    }
598
599    #[test]
600    fn would_reformat_matches_format_identity() {
601        let config = FormatConfig {
602            format: Format::Plaintext,
603            ..Default::default()
604        };
605        assert!(would_reformat("Hello world. This is a test.\n", &config).unwrap());
606        assert!(!would_reformat("Hello world.\nThis is a test.\n", &config).unwrap());
607    }
608
609    #[test]
610    fn would_reformat_uses_unlimited_clause_breaks_when_on() {
611        let on = FormatConfig {
612            format: Format::Plaintext,
613            clause_breaks: true,
614            ..Default::default()
615        };
616        let off = FormatConfig {
617            format: Format::Plaintext,
618            clause_breaks: false,
619            ..Default::default()
620        };
621        let fused = "Hello, world.\n";
622        let broken = "Hello,\nworld.\n";
623        assert!(
624            would_reformat(fused, &on).unwrap(),
625            "--check with clause_breaks must see a fused clause as dirty"
626        );
627        assert!(
628            !would_reformat(broken, &on).unwrap(),
629            "--check identity must use the same unlimited clause-break mode"
630        );
631        assert!(
632            !would_reformat(fused, &off).unwrap(),
633            "default --check must not require clause breaks"
634        );
635        assert_eq!(format_text(fused, &on).unwrap(), broken);
636    }
637
638    fn no_fused(input: &str, format: Format) -> Vec<LineDiagnostic> {
639        let splitter = UnicodeSentenceSplitter::new();
640        collect_diagnostics(input, format, &splitter, DEFAULT_LONG_THRESHOLD, None)
641    }
642
643    #[test]
644    fn numbered_list_payload_is_item_text() {
645        use crate::parser::source_line_payloads;
646        let md = source_line_payloads("1. Hello world.\n", Format::Markdown, None);
647        assert_eq!(md[0].as_deref(), Some("Hello world."));
648        let org = source_line_payloads("1. Hello world.\n", Format::Org, None);
649        assert_eq!(org[0].as_deref(), Some("Hello world."));
650        let tex = source_line_payloads(
651            "\\begin{document}\nSee Fig. 1. % TODO cite\n\\end{document}\n",
652            Format::Latex,
653            None,
654        );
655        assert_eq!(
656            tex[1].as_deref().map(str::trim),
657            Some("See Fig. 1."),
658            "mid-line % prefix is the prose payload; trailing space is splice gap"
659        );
660    }
661
662    #[test]
663    fn numbered_list_item_is_not_fused_markdown() {
664        let input = "1. Hello world.\n";
665        let found = no_fused(input, Format::Markdown);
666        assert!(
667            found.iter().all(|d| d.kind != DiagnosticKind::Fused),
668            "numbered list body is one sentence; marker is not fused, got {found:?}"
669        );
670        let config = FormatConfig {
671            format: Format::Markdown,
672            ..Default::default()
673        };
674        assert!(
675            !would_reformat(input, &config).unwrap(),
676            "1. Hello world. must be identity under markdown"
677        );
678    }
679
680    #[test]
681    fn numbered_list_item_is_not_fused_org() {
682        let input = "1. Hello world.\n";
683        let found = no_fused(input, Format::Org);
684        assert!(
685            found.iter().all(|d| d.kind != DiagnosticKind::Fused),
686            "org numbered list body is one sentence; marker is not fused, got {found:?}"
687        );
688        let config = FormatConfig {
689            format: Format::Org,
690            ..Default::default()
691        };
692        assert!(
693            !would_reformat(input, &config).unwrap(),
694            "1. Hello world. must be identity under org"
695        );
696    }
697
698    #[test]
699    fn latex_mid_line_comment_is_not_fused() {
700        let input = "See Fig. 1. % TODO cite\n";
701        let found = no_fused(input, Format::Latex);
702        assert!(
703            found.iter().all(|d| d.kind != DiagnosticKind::Fused),
704            "latex comment is structure; Fig. 1. is one sentence, got {found:?}"
705        );
706        let config = FormatConfig {
707            format: Format::Latex,
708            ..Default::default()
709        };
710        assert!(
711            !would_reformat(input, &config).unwrap(),
712            "See Fig. 1. % TODO cite must be identity under latex"
713        );
714    }
715
716    #[test]
717    fn latex_body_mid_line_comment_is_not_fused() {
718        let input = "\\begin{document}\nSee Fig. 1. % TODO cite\n\\end{document}\n";
719        let found = no_fused(input, Format::Latex);
720        assert!(
721            found.iter().all(|d| d.kind != DiagnosticKind::Fused),
722            "body-line comment must not fuse Fig. 1. with TODO, got {found:?}"
723        );
724        let config = FormatConfig {
725            format: Format::Latex,
726            ..Default::default()
727        };
728        assert!(
729            !would_reformat(input, &config).unwrap(),
730            "document with See Fig. 1. % TODO cite must be identity, got {}",
731            crate::format_text(input, &config).unwrap()
732        );
733    }
734
735    #[test]
736    fn parser_line_map_covers_every_source_line() {
737        use crate::parser::source_line_payloads;
738        let cases = [
739            (
740                Format::Org,
741                "#+BEGIN_QUOTE\nQuoted hello. Quoted world.\n#+END_QUOTE\n# Comment.\n:PROPERTIES:\n:ID: x\n:END:\n\nReal. Two.\n",
742            ),
743            (
744                Format::Markdown,
745                "---\ntitle: Hello. World.\n---\n\nSetext Title. Still\n===================\n\nBody. Two.\n",
746            ),
747            (
748                Format::Latex,
749                "\\documentclass{article}\n\\begin{document}\n\\begin{equation}\nE=mc^2\n\\end{equation}\nBody. Two.\n\\end{document}\n",
750            ),
751            (
752                Format::Rst,
753                "Title Here. With Period.\n========================\n\n.. note::\n\n   Note. Two.\n\nBody. Two.\n",
754            ),
755            (
756                Format::Plaintext,
757                "Hello. World.\nsnapper:off\nDo not. Touch.\nsnapper:on\nAfter. Two.\n",
758            ),
759        ];
760        for (fmt, input) in cases {
761            let kinds = source_line_payloads(input, fmt, None);
762            assert_eq!(
763                kinds.len(),
764                input.lines().count(),
765                "line map length mismatch for {fmt:?}"
766            );
767        }
768    }
769
770    #[test]
771    fn configured_verb_inner_percent_is_fused_not_comment() {
772        let input = "\\begin{document}\nCode \\Verb!%! here. Next sentence.\n\\end{document}\n";
773        let config = FormatConfig {
774            format: Format::Latex,
775            latex_verbatim_commands: vec!["Verb".into()],
776            ..Default::default()
777        };
778        let splitter = UnicodeSentenceSplitter::new().with_verbatim_commands(vec!["Verb".into()]);
779        let found = collect_diagnostics(
780            input,
781            Format::Latex,
782            &splitter,
783            DEFAULT_LONG_THRESHOLD,
784            Some(&config),
785        );
786        assert!(
787            found
788                .iter()
789                .any(|d| d.line == 2 && d.kind == DiagnosticKind::Fused),
790            "configured Verb inner % is content; the line is fused, got {found:?}"
791        );
792        let payloads = source_line_payloads(input, Format::Latex, Some(&config));
793        assert_eq!(
794            payloads[1].as_deref().map(str::trim),
795            Some("Code \\Verb!%! here. Next sentence."),
796            "extras must keep % inside Verb as prose, got {payloads:?}"
797        );
798        let builtin = source_line_payloads(input, Format::Latex, None);
799        assert_ne!(
800            builtin[1].as_deref().map(str::trim),
801            Some("Code \\Verb!%! here. Next sentence."),
802            "built-in lists treat % as a comment, got {builtin:?}"
803        );
804    }
805
806    #[test]
807    fn resolve_long_threshold_prefers_max_width() {
808        assert_eq!(resolve_long_threshold(80, Some(200)), 80);
809        assert_eq!(resolve_long_threshold(0, Some(200)), 200);
810        assert_eq!(resolve_long_threshold(0, None), DEFAULT_LONG_THRESHOLD);
811    }
812}