1mod bibtex;
2mod forester;
3pub mod latex;
4mod org;
5mod query;
6mod rst;
7mod shared;
8mod sweave;
9mod tinylang;
10mod typst;
11
12use anyhow::{Result, anyhow};
13use std::ops::Range;
14use std::path::Path;
15use tracing::warn;
16use tree_sitter::{Language, Parser};
17
18use crate::checker::Diagnostic;
19use crate::ignore_rules::{DirectiveRegion, IgnoreParser};
20
21use crate::sls::SchemaRegistry;
22
23pub struct ProseExtractor {
24 parser: Parser,
25 language: Language,
26}
27
28impl ProseExtractor {
29 pub fn new(language: Language) -> Result<Self> {
30 let mut parser = Parser::new();
31 parser.set_language(&language)?;
32 Ok(Self { parser, language })
33 }
34
35 pub fn extract(
36 &mut self,
37 text: &str,
38 lang_id: &str,
39 latex_extras: &latex::LatexExtras,
40 ) -> Result<Vec<ProseRange>> {
41 let tree = self
42 .parser
43 .parse(text, None)
44 .ok_or_else(|| anyhow!("Failed to parse text"))?;
45
46 let root = tree.root_node();
47
48 let ranges = match lang_id {
49 "latex" => latex::extract(text, root, latex_extras),
50 "sweave" => sweave::extract(text, root, latex_extras),
51 "forester" => forester::extract(text, root),
52 "tinylang" => tinylang::extract(text, root),
53 "rst" => rst::extract(text, root),
54 "bibtex" => bibtex::extract(text, root),
55 "org" => org::extract(text, root),
56 "typst" => typst::extract(text, root),
57 lang => query::extract(text, root, &self.language, lang)?,
58 };
59
60 let force_regions = crate::ignore_rules::IgnoreParser::block_regions(text);
64 Ok(shared::merge_continuations(ranges, text, &force_regions))
65 }
66}
67
68pub fn extract_with_fallback(
74 text: &str,
75 lang_id: &str,
76 path: Option<&Path>,
77 schema_registry: Option<&SchemaRegistry>,
78 latex_extras: &latex::LatexExtras,
79) -> Result<Vec<ProseRange>> {
80 if let Some(ext) = path
81 .and_then(|value| value.extension())
82 .and_then(|value| value.to_str())
83 && crate::languages::builtin_language_for_extension(ext).is_none()
84 && let Some(schema) = schema_registry.and_then(|registry| registry.find_by_extension(ext))
85 {
86 return Ok(schema.extract(text));
87 }
88
89 let canonical_lang = crate::languages::resolve_language_id(lang_id);
90 let language = crate::languages::resolve_ts_language(canonical_lang);
91 let mut extractor = ProseExtractor::new(language)?;
92 let mut ranges = extractor.extract(text, canonical_lang, latex_extras)?;
93
94 let directives = IgnoreParser::parse_directives(text);
95 let resolved = IgnoreParser::resolve_all(text, &directives);
96 let type_regions: Vec<_> = resolved
97 .regions
98 .iter()
99 .filter(|r| r.options.doc_type.is_some())
100 .collect();
101 if !type_regions.is_empty() {
102 ranges = apply_type_overrides(text, ranges, &type_regions, latex_extras)?;
103 }
104
105 Ok(ranges)
106}
107
108fn apply_type_overrides(
115 text: &str,
116 base_ranges: Vec<ProseRange>,
117 type_regions: &[&DirectiveRegion],
118 latex_extras: &latex::LatexExtras,
119) -> Result<Vec<ProseRange>> {
120 let override_spans: Vec<&Range<usize>> = type_regions.iter().map(|r| &r.byte_range).collect();
121
122 let mut result: Vec<ProseRange> = base_ranges
124 .into_iter()
125 .filter(|r| {
126 !override_spans
127 .iter()
128 .any(|span| span.contains(&r.start_byte))
129 })
130 .collect();
131
132 for region in type_regions {
133 let doc_type = region.options.doc_type.as_deref().unwrap();
134 let canonical = crate::languages::resolve_language_id(doc_type);
135
136 if !crate::languages::SUPPORTED_LANGUAGE_IDS.contains(&canonical) {
137 warn!(
138 doc_type,
139 "`type:` directive names an unsupported language; skipping region"
140 );
141 continue;
142 }
143
144 let slice = &text[region.byte_range.clone()];
145 let ts_lang = crate::languages::resolve_ts_language(canonical);
146 let mut ext = ProseExtractor::new(ts_lang)?;
147 let sub_ranges = ext.extract(slice, canonical, latex_extras)?;
148
149 let offset = region.byte_range.start;
150 for mut r in sub_ranges {
151 r.start_byte += offset;
152 r.end_byte += offset;
153 r.exclusions = r
154 .exclusions
155 .into_iter()
156 .map(|(s, e)| (s + offset, e + offset))
157 .collect();
158 result.push(r);
159 }
160 }
161
162 result.sort_by_key(|r| r.start_byte);
163 Ok(result)
164}
165
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub struct ProseRange {
168 pub start_byte: usize,
169 pub end_byte: usize,
170 pub exclusions: Vec<(usize, usize)>,
174}
175
176impl ProseRange {
177 #[must_use]
180 pub fn extract_text<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
181 let slice = &text[self.start_byte..self.end_byte];
182 if self.exclusions.is_empty() {
183 return std::borrow::Cow::Borrowed(slice);
184 }
185 #[cfg(debug_assertions)]
192 for &(exc_start, exc_end) in &self.exclusions {
193 let s = exc_start.saturating_sub(self.start_byte).min(slice.len());
194 let e = exc_end.saturating_sub(self.start_byte).min(slice.len());
195 debug_assert!(
196 slice.is_char_boundary(s) && slice.is_char_boundary(e),
197 "exclusion ({s}, {e}) is not on a char boundary in {slice:?}"
198 );
199 }
200
201 let mut buf = slice.to_string();
202 let bytes = unsafe { buf.as_bytes_mut() };
205 let mut blanked: Vec<(usize, usize)> = Vec::with_capacity(self.exclusions.len());
206 for &(exc_start, exc_end) in &self.exclusions {
207 let local_start = exc_start.saturating_sub(self.start_byte).min(bytes.len());
210 let local_end = exc_end.saturating_sub(self.start_byte).min(bytes.len());
211 if local_start < local_end {
212 bytes[local_start..local_end].fill(b' ');
213 blanked.push((local_start, local_end));
214 }
215 }
216 strip_unmatched_brackets(bytes);
217 reseat_quotes_across_blanks(bytes, &blanked);
218 std::borrow::Cow::Owned(buf)
219 }
220
221 #[must_use]
224 #[allow(clippy::cast_possible_truncation)]
225 pub fn overlaps_exclusion(&self, local_start: u32, local_end: u32) -> bool {
226 let doc_start = self.start_byte as u32 + local_start;
227 let doc_end = self.start_byte as u32 + local_end;
228 self.exclusions.iter().any(|&(exc_start, exc_end)| {
229 let es = exc_start as u32;
230 let ee = exc_end as u32;
231 doc_start < ee && doc_end > es
232 })
233 }
234
235 #[must_use]
241 pub fn exclusion_adjacency(
242 &self,
243 text: &str,
244 local_start: u32,
245 local_end: u32,
246 ) -> ExclusionAdjacency {
247 if self.overlaps_exclusion(local_start, local_end) {
248 return ExclusionAdjacency::Overlapping;
249 }
250 let doc_start = self.start_byte + local_start as usize;
251 let doc_end = self.start_byte + local_end as usize;
252 let mut best = ExclusionAdjacency::None;
253 for &(es, ee) in &self.exclusions {
254 let rel = if doc_start >= ee {
261 classify_gap(text, ee, doc_start, byte_before_is_whitespace(text, ee))
262 } else {
263 classify_gap(text, doc_end, es, byte_at_is_whitespace(text, es))
264 };
265 best = best.max_severity(rel);
266 if best == ExclusionAdjacency::Glued {
267 break; }
269 }
270 best
271 }
272
273 #[must_use]
283 pub fn suppresses_diagnostic(
284 &self,
285 text: &str,
286 local_start: u32,
287 local_end: u32,
288 unified_id: &str,
289 ) -> bool {
290 match self.exclusion_adjacency(text, local_start, local_end) {
291 ExclusionAdjacency::Overlapping | ExclusionAdjacency::Glued => true,
292 ExclusionAdjacency::WhitespaceAdjacent => !is_spelling_category(unified_id),
293 ExclusionAdjacency::None => false,
294 }
295 }
296
297 #[allow(clippy::cast_possible_truncation)]
301 pub fn adopt_diagnostics(&self, text: &str, diagnostics: &mut Vec<Diagnostic>) {
302 diagnostics
303 .retain(|d| !self.suppresses_diagnostic(text, d.start_byte, d.end_byte, &d.unified_id));
304 for d in diagnostics {
305 d.start_byte += self.start_byte as u32;
306 d.end_byte += self.start_byte as u32;
307 }
308 }
309}
310
311#[must_use]
314pub fn range_texts(ranges: &[ProseRange], text: &str) -> Vec<String> {
315 ranges
316 .iter()
317 .map(|r| r.extract_text(text).into_owned())
318 .collect()
319}
320
321#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub enum ExclusionAdjacency {
324 Overlapping,
326 Glued,
328 WhitespaceAdjacent,
330 None,
332}
333
334impl ExclusionAdjacency {
335 const fn rank(self) -> u8 {
336 match self {
337 Self::None => 0,
338 Self::WhitespaceAdjacent => 1,
339 Self::Glued => 2,
340 Self::Overlapping => 3,
341 }
342 }
343
344 #[must_use]
346 const fn max_severity(self, other: Self) -> Self {
347 if other.rank() > self.rank() {
348 other
349 } else {
350 self
351 }
352 }
353}
354
355fn classify_gap(
364 text: &str,
365 lo: usize,
366 hi: usize,
367 skip_edge_is_whitespace: bool,
368) -> ExclusionAdjacency {
369 if lo == hi {
370 return if skip_edge_is_whitespace {
371 ExclusionAdjacency::WhitespaceAdjacent
372 } else {
373 ExclusionAdjacency::Glued
374 };
375 }
376 match text.get(lo..hi) {
377 Some(gap) if gap.chars().all(char::is_whitespace) => ExclusionAdjacency::WhitespaceAdjacent,
378 _ => ExclusionAdjacency::None,
379 }
380}
381
382fn byte_before_is_whitespace(text: &str, pos: usize) -> bool {
384 text.get(..pos)
385 .and_then(|s| s.chars().next_back())
386 .is_some_and(char::is_whitespace)
387}
388
389fn byte_at_is_whitespace(text: &str, pos: usize) -> bool {
391 text.get(pos..)
392 .and_then(|s| s.chars().next())
393 .is_some_and(char::is_whitespace)
394}
395
396#[must_use]
398pub fn is_spelling_category(unified_id: &str) -> bool {
399 unified_id.starts_with("spelling.")
400}
401
402fn strip_unmatched_brackets(bytes: &mut [u8]) {
408 let mut paren_stack: Vec<usize> = Vec::new();
409 let mut bracket_stack: Vec<usize> = Vec::new();
410 let mut brace_stack: Vec<usize> = Vec::new();
411 let mut unmatched: Vec<usize> = Vec::new();
412
413 for (i, &b) in bytes.iter().enumerate() {
414 match b {
415 b'(' => paren_stack.push(i),
416 b')' if paren_stack.pop().is_none() => {
417 unmatched.push(i);
418 }
419 b'[' => bracket_stack.push(i),
420 b']' if bracket_stack.pop().is_none() => {
421 unmatched.push(i);
422 }
423 b'{' => brace_stack.push(i),
424 b'}' if brace_stack.pop().is_none() => {
425 unmatched.push(i);
426 }
427 _ => {}
428 }
429 }
430
431 unmatched.extend(paren_stack);
432 unmatched.extend(bracket_stack);
433 unmatched.extend(brace_stack);
434
435 for idx in unmatched {
436 bytes[idx] = b' ';
437 }
438}
439
440fn is_word_byte(bytes: &[u8], i: usize) -> bool {
448 if i >= bytes.len() {
449 return false;
450 }
451 let mut start = i;
453 while start > 0 && bytes[start] & 0b1100_0000 == 0b1000_0000 {
454 start -= 1;
455 }
456 (1..=4)
457 .find_map(|len| std::str::from_utf8(bytes.get(start..start + len)?).ok())
458 .and_then(|s| s.chars().next())
459 .is_some_and(char::is_alphanumeric)
460}
461
462fn reseat_quotes_across_blanks(bytes: &mut [u8], blanked: &[(usize, usize)]) {
483 for &(start, end) in blanked {
484 if start >= end {
485 continue;
486 }
487 if start > 0
490 && bytes[start - 1] == b'"'
491 && !start.checked_sub(2).is_some_and(|i| is_word_byte(bytes, i))
492 {
493 let word = (end..bytes.len())
494 .find(|&i| bytes[i] != b' ')
495 .filter(|&i| is_word_byte(bytes, i));
496 if let Some(word) = word {
497 bytes[start - 1] = b' ';
498 bytes[word - 1] = b'"';
499 continue;
500 }
501 }
502 if bytes.get(end) == Some(&b'"') && !is_word_byte(bytes, end + 1) {
504 let after_word = (0..start)
505 .rev()
506 .find(|&i| bytes[i] != b' ')
507 .filter(|&i| is_word_byte(bytes, i))
508 .map(|i| i + 1);
509 if let Some(after_word) = after_word {
510 bytes[end] = b' ';
511 bytes[after_word] = b'"';
512 }
513 }
514 }
515}
516
517#[cfg(test)]
518mod tests {
519 use super::*;
520 use latex::LatexExtras;
521
522 #[test]
525 fn extract_text_no_exclusions_is_borrowed() {
526 let text = "café — touché";
527 let range = ProseRange {
528 start_byte: 0,
529 end_byte: text.len(),
530 exclusions: Vec::new(),
531 };
532 let out = range.extract_text(text);
533 assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
534 assert_eq!(out, text);
535 }
536
537 #[test]
538 fn extract_text_blanks_excluded_ascii_keeping_multibyte() {
539 let text = "café X tea";
541 let x = text.find('X').unwrap();
542 let range = ProseRange {
543 start_byte: 0,
544 end_byte: text.len(),
545 exclusions: vec![(x, x + 1)],
546 };
547 let out = range.extract_text(text);
548 assert_eq!(out, "café tea");
549 assert!(std::str::from_utf8(out.as_bytes()).is_ok());
550 }
551
552 #[test]
553 fn extract_text_blanks_a_whole_multibyte_char() {
554 let text = "a—b";
556 let dash_start = text.find('—').unwrap();
557 let dash_end = dash_start + '—'.len_utf8();
558 let range = ProseRange {
559 start_byte: 0,
560 end_byte: text.len(),
561 exclusions: vec![(dash_start, dash_end)],
562 };
563 let out = range.extract_text(text);
564 assert_eq!(out, "a b");
565 }
566
567 #[test]
568 fn extract_text_handles_document_level_offsets() {
569 let text = "PREFIX café — done";
571 let start = text.find("café").unwrap();
572 let dash = text.find('—').unwrap();
573 let range = ProseRange {
574 start_byte: start,
575 end_byte: text.len(),
576 exclusions: vec![(dash, dash + '—'.len_utf8())],
577 };
578 let out = range.extract_text(text);
580 assert_eq!(out, "café done");
581 }
582
583 fn range_excluding(text: &str, excluded: &str) -> ProseRange {
584 let start = text.find(excluded).unwrap();
585 ProseRange {
586 start_byte: 0,
587 end_byte: text.len(),
588 exclusions: vec![(start, start + excluded.len())],
589 }
590 }
591
592 #[test]
593 fn extract_text_reseats_opening_quote_stranded_by_a_blank() {
594 let text = r##"He said "#{m} is fine"."##;
597 let out = range_excluding(text, "#{m}").extract_text(text);
598 assert_eq!(out, r#"He said "is fine"."#);
599 }
600
601 #[test]
602 fn extract_text_reseats_closing_quote_stranded_by_a_blank() {
603 let text = r#"He said "it is #{m}"."#;
604 let out = range_excluding(text, "#{m}").extract_text(text);
605 assert_eq!(out, r#"He said "it is" ."#);
606 }
607
608 #[test]
609 fn extract_text_leaves_quotes_that_still_hug_their_word() {
610 let text = r#"He said "fine #{m} here"."#;
611 let out = range_excluding(text, "#{m}").extract_text(text);
612 assert_eq!(out, r#"He said "fine here"."#);
613 }
614
615 #[test]
616 fn extract_text_reseat_keeps_utf8_valid_around_multibyte_words() {
617 let text = r##"Il dit "#{m} café"."##;
618 let out = range_excluding(text, "#{m}").extract_text(text);
619 assert_eq!(out, r#"Il dit "café"."#);
620 assert!(std::str::from_utf8(out.as_bytes()).is_ok());
621 }
622
623 fn diagnostic(start: u32, end: u32, unified_id: &str) -> Diagnostic {
624 Diagnostic {
625 start_byte: start,
626 end_byte: end,
627 message: String::new(),
628 suggestions: Vec::new(),
629 rule_id: String::new(),
630 severity: 2,
631 unified_id: unified_id.to_string(),
632 confidence: 1.0,
633 }
634 }
635
636 #[test]
637 fn adopt_diagnostics_rebases_survivors_onto_document_offsets() {
638 let text = "PREFIX one two";
639 let start = text.find("one").unwrap();
640 let range = ProseRange {
641 start_byte: start,
642 end_byte: text.len(),
643 exclusions: Vec::new(),
644 };
645 let mut diagnostics = vec![diagnostic(4, 7, "spelling.typo")];
647 range.adopt_diagnostics(text, &mut diagnostics);
648
649 assert_eq!(diagnostics.len(), 1);
650 let d = &diagnostics[0];
651 assert_eq!(
652 &text[d.start_byte as usize..d.end_byte as usize],
653 "two",
654 "rebased span must slice the same word out of the document"
655 );
656 }
657
658 #[test]
659 fn adopt_diagnostics_drops_skip_induced_false_positives() {
660 let text = "one XXX two";
661 let range = ProseRange {
662 start_byte: 0,
663 end_byte: text.len(),
664 exclusions: vec![(4, 7)],
665 };
666 let mut diagnostics = vec![
668 diagnostic(4, 7, "spelling.typo"),
669 diagnostic(8, 11, "typography.capitalization"),
670 ];
671 range.adopt_diagnostics(text, &mut diagnostics);
672
673 assert!(diagnostics.is_empty(), "got: {diagnostics:?}");
674 }
675
676 #[test]
677 fn range_texts_matches_per_range_extraction() {
678 let text = "alpha SKIP beta";
679 let ranges = vec![
680 ProseRange {
681 start_byte: 0,
682 end_byte: 5,
683 exclusions: Vec::new(),
684 },
685 ProseRange {
686 start_byte: 6,
687 end_byte: text.len(),
688 exclusions: vec![(6, 10)],
689 },
690 ];
691 let texts = range_texts(&ranges, text);
692
693 assert_eq!(texts.len(), ranges.len());
694 for (range, extracted) in ranges.iter().zip(&texts) {
695 assert_eq!(*extracted, range.extract_text(text));
696 }
697 }
698
699 #[test]
700 fn extract_text_reseat_does_not_cross_a_line_break() {
701 let text = "He said \"#{m}\nis fine\".";
703 let out = range_excluding(text, "#{m}").extract_text(text);
704 assert_eq!(out, "He said \" \nis fine\".");
705 }
706
707 #[test]
708 fn test_markdown_extraction() -> Result<()> {
709 let language: tree_sitter::Language = tree_sitter_md::LANGUAGE.into();
710 let mut extractor = ProseExtractor::new(language)?;
711
712 let text =
713 "# Header\n\nThis is a paragraph.\n\n```rust\nfn main() {}\n```\n\nAnother paragraph.";
714 let ranges = extractor.extract(text, "markdown", &LatexExtras::default())?;
715
716 assert!(ranges.len() >= 3);
717
718 let extracted_texts: Vec<&str> = ranges
719 .iter()
720 .map(|r| &text[r.start_byte..r.end_byte])
721 .collect();
722 assert!(extracted_texts.iter().any(|t| t.contains("Header")));
723 assert!(
724 extracted_texts
725 .iter()
726 .any(|t| t.contains("This is a paragraph"))
727 );
728 assert!(
729 extracted_texts
730 .iter()
731 .any(|t| t.contains("Another paragraph"))
732 );
733
734 Ok(())
735 }
736
737 #[test]
738 fn test_overlaps_exclusion() {
739 let range = ProseRange {
740 start_byte: 100,
741 end_byte: 300,
742 exclusions: vec![(150, 200)],
743 };
744
745 assert!(range.overlaps_exclusion(50, 100)); assert!(range.overlaps_exclusion(40, 60)); assert!(range.overlaps_exclusion(90, 110)); assert!(!range.overlaps_exclusion(0, 40)); assert!(!range.overlaps_exclusion(110, 130)); }
754
755 #[test]
756 fn test_exclusion_adjacency_classifies_position() {
757 let text = "a #{i} is b";
759 let range = ProseRange {
760 start_byte: 0,
761 end_byte: text.len(),
762 exclusions: vec![(2, 6)],
763 };
764 assert_eq!(
766 range.exclusion_adjacency(text, 7, 9),
767 ExclusionAdjacency::WhitespaceAdjacent
768 );
769 assert_eq!(
771 range.exclusion_adjacency(text, 3, 5),
772 ExclusionAdjacency::Overlapping
773 );
774 assert_eq!(
776 range.exclusion_adjacency(text, 10, 11),
777 ExclusionAdjacency::None
778 );
779 }
780
781 #[test]
782 fn test_exclusion_adjacency_detects_glued_fragment() {
783 let text = "#{n}th word";
785 let range = ProseRange {
786 start_byte: 0,
787 end_byte: text.len(),
788 exclusions: vec![(0, 4)],
789 };
790 assert_eq!(
791 range.exclusion_adjacency(text, 4, 6),
792 ExclusionAdjacency::Glued
793 );
794 }
795
796 #[test]
797 fn test_exclusion_swallowing_flanking_space_is_not_glued() {
798 let text = "teh #{G} ok"; let range = ProseRange {
804 start_byte: 0,
805 end_byte: text.len(),
806 exclusions: vec![(3, 6)],
807 };
808 assert_eq!(
809 range.exclusion_adjacency(text, 0, 3),
810 ExclusionAdjacency::WhitespaceAdjacent
811 );
812 assert!(!range.suppresses_diagnostic(text, 0, 3, "spelling.typo"));
814 assert!(range.suppresses_diagnostic(text, 0, 3, "typography.capitalization"));
815 }
816
817 #[test]
818 fn test_suppresses_diagnostic_keeps_spelling_near_skip() {
819 let text = "a #{i} wrd b";
822 let range = ProseRange {
823 start_byte: 0,
824 end_byte: text.len(),
825 exclusions: vec![(2, 6)],
826 };
827 assert!(range.suppresses_diagnostic(text, 7, 10, "typography.capitalization"));
829 assert!(!range.suppresses_diagnostic(text, 7, 10, "spelling.typo"));
831 }
832
833 #[test]
834 fn test_suppresses_diagnostic_drops_glued_fragment_spelling() {
835 let text = "#{n}th word";
838 let range = ProseRange {
839 start_byte: 0,
840 end_byte: text.len(),
841 exclusions: vec![(0, 4)],
842 };
843 assert!(range.suppresses_diagnostic(text, 4, 6, "spelling.typo"));
844 assert!(!range.suppresses_diagnostic(text, 7, 11, "spelling.typo"));
846 }
847
848 #[test]
849 fn type_override_latex_in_markdown() -> Result<()> {
850 let text = "\
851# Title
852
853Some intro text.
854
855<!-- lang-check-begin type:latex -->
856\\emph{Hello} world and \\textbf{bold} text.
857<!-- lang-check-end -->
858
859Final paragraph.";
860
861 let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
862
863 let texts: Vec<&str> = ranges
864 .iter()
865 .map(|r| &text[r.start_byte..r.end_byte])
866 .collect();
867
868 assert!(texts.iter().any(|t| t.contains("Title")));
870 assert!(texts.iter().any(|t| t.contains("intro text")));
871 assert!(texts.iter().any(|t| t.contains("Final paragraph")));
872
873 assert!(
876 texts.iter().any(|t| t.contains("Hello")),
877 "expected LaTeX extractor to produce range containing 'Hello', got: {texts:?}"
878 );
879
880 Ok(())
881 }
882
883 #[test]
884 fn type_override_unknown_skipped() -> Result<()> {
885 let text = "\
886# Title
887
888<!-- lang-check-begin type:foobar -->
889Some content here.
890<!-- lang-check-end -->
891
892Trailing text.";
893
894 let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
895
896 let texts: Vec<&str> = ranges
897 .iter()
898 .map(|r| &text[r.start_byte..r.end_byte])
899 .collect();
900
901 assert!(texts.iter().any(|t| t.contains("Title")));
903 assert!(texts.iter().any(|t| t.contains("Trailing text")));
904
905 assert!(
908 !texts.iter().any(|t| t.contains("Some content")),
909 "expected unknown type region to be skipped, got: {texts:?}"
910 );
911
912 Ok(())
913 }
914
915 #[test]
916 fn type_override_preserves_surrounding() -> Result<()> {
917 let text = "\
918First paragraph before.
919
920<!-- lang-check-begin type:latex -->
921\\section{Test}
922Some LaTeX prose.
923<!-- lang-check-end -->
924
925Last paragraph after.";
926
927 let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
928
929 let texts: Vec<&str> = ranges
930 .iter()
931 .map(|r| &text[r.start_byte..r.end_byte])
932 .collect();
933
934 assert!(
936 texts.iter().any(|t| t.contains("First paragraph before")),
937 "pre-region range missing: {texts:?}"
938 );
939 assert!(
940 texts.iter().any(|t| t.contains("Last paragraph after")),
941 "post-region range missing: {texts:?}"
942 );
943
944 Ok(())
945 }
946
947 #[test]
948 fn strip_unmatched_orphan_close() {
949 let mut bytes = b"hello } world".to_vec();
950 strip_unmatched_brackets(&mut bytes);
951 assert_eq!(&bytes, b"hello world");
952 }
953
954 #[test]
955 fn strip_unmatched_orphan_open() {
956 let mut bytes = b"hello ( world".to_vec();
957 strip_unmatched_brackets(&mut bytes);
958 assert_eq!(&bytes, b"hello world");
959 }
960
961 #[test]
962 fn strip_unmatched_preserves_matched() {
963 let mut bytes = b"f(x) and [y]".to_vec();
964 strip_unmatched_brackets(&mut bytes);
965 assert_eq!(&bytes, b"f(x) and [y]");
966 }
967
968 #[test]
969 fn strip_unmatched_mixed() {
970 let mut bytes = b"value } is f(x)".to_vec();
972 strip_unmatched_brackets(&mut bytes);
973 assert_eq!(&bytes, b"value is f(x)");
974 }
975
976 #[test]
977 fn strip_unmatched_via_extract_text() {
978 let range = ProseRange {
979 start_byte: 0,
980 end_byte: 20,
981 exclusions: vec![(5, 10)],
982 };
983 let text = "text #{x+y} rest____";
987 let clean = range.extract_text(text);
988 assert!(!clean.contains('#'));
990 assert!(!clean.contains('{'));
991 assert!(!clean.contains('}'));
992 }
993}