1use serde::Serialize;
7
8use crate::format::Format;
9use crate::parser::source_line_payloads;
10use crate::sentence::SentenceSplitter;
11use crate::{FormatConfig, format_text};
12
13pub const DEFAULT_LONG_THRESHOLD: usize = 120;
16
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
19#[serde(rename_all = "lowercase")]
20pub enum DiagnosticKind {
21 Fused,
23 Wrap,
26 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#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
43pub struct LineDiagnostic {
44 pub line: usize,
45 pub kind: DiagnosticKind,
46 pub excerpt: String,
47}
48
49pub 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
59pub fn would_reformat(input: &str, config: &FormatConfig) -> anyhow::Result<bool> {
61 let output = format_text(input, config)?;
62 Ok(output != input)
63}
64
65const 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
71pub 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}