Skip to main content

lang_check/prose/
mod.rs

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 tree_sitter::{Language, Parser};
16
17use crate::ignore_rules::{DirectiveRegion, IgnoreParser};
18
19use crate::sls::SchemaRegistry;
20
21pub struct ProseExtractor {
22    parser: Parser,
23    language: Language,
24}
25
26impl ProseExtractor {
27    pub fn new(language: Language) -> Result<Self> {
28        let mut parser = Parser::new();
29        parser.set_language(&language)?;
30        Ok(Self { parser, language })
31    }
32
33    pub fn extract(
34        &mut self,
35        text: &str,
36        lang_id: &str,
37        latex_extras: &latex::LatexExtras,
38    ) -> Result<Vec<ProseRange>> {
39        let tree = self
40            .parser
41            .parse(text, None)
42            .ok_or_else(|| anyhow!("Failed to parse text"))?;
43
44        let root = tree.root_node();
45
46        let ranges = match lang_id {
47            "latex" => latex::extract(text, root, latex_extras),
48            "sweave" => sweave::extract(text, root, latex_extras),
49            "forester" => forester::extract(text, root),
50            "tinylang" => tinylang::extract(text, root),
51            "rst" => rst::extract(text, root),
52            "bibtex" => bibtex::extract(text, root),
53            "org" => org::extract(text, root),
54            "typst" => typst::extract(text, root),
55            lang => query::extract(text, root, &self.language, lang)?,
56        };
57
58        // Merge prose blocks split across markup boundaries (e.g. \p{…} math
59        // \p{…}) so a continuation isn't flagged as a new, uncapitalized
60        // sentence. Honors explicit `lang-check-begin block` overrides.
61        let force_regions = crate::ignore_rules::IgnoreParser::block_regions(text);
62        Ok(shared::merge_continuations(ranges, text, &force_regions))
63    }
64}
65
66/// Extract prose using a built-in tree-sitter extractor or an SLS fallback.
67///
68/// When the file extension matches a loaded SLS schema and that extension has
69/// no built-in tree-sitter extractor, the schema takes over. Built-in
70/// extensions always keep precedence.
71pub fn extract_with_fallback(
72    text: &str,
73    lang_id: &str,
74    path: Option<&Path>,
75    schema_registry: Option<&SchemaRegistry>,
76    latex_extras: &latex::LatexExtras,
77) -> Result<Vec<ProseRange>> {
78    if let Some(ext) = path
79        .and_then(|value| value.extension())
80        .and_then(|value| value.to_str())
81        && crate::languages::builtin_language_for_extension(ext).is_none()
82        && let Some(schema) = schema_registry.and_then(|registry| registry.find_by_extension(ext))
83    {
84        return Ok(schema.extract(text));
85    }
86
87    let canonical_lang = crate::languages::resolve_language_id(lang_id);
88    let language = crate::languages::resolve_ts_language(canonical_lang);
89    let mut extractor = ProseExtractor::new(language)?;
90    let mut ranges = extractor.extract(text, canonical_lang, latex_extras)?;
91
92    let directives = IgnoreParser::parse_directives(text);
93    let resolved = IgnoreParser::resolve_all(text, &directives);
94    let type_regions: Vec<_> = resolved
95        .regions
96        .iter()
97        .filter(|r| r.options.doc_type.is_some())
98        .collect();
99    if !type_regions.is_empty() {
100        ranges = apply_type_overrides(text, ranges, &type_regions, latex_extras)?;
101    }
102
103    Ok(ranges)
104}
105
106/// Re-extract prose for regions tagged with `type:FORMAT`.
107///
108/// For each type-override region, slices the document text, runs the specified
109/// format's extractor, and rebases the resulting ranges to document-level
110/// offsets. Base ranges whose `start_byte` falls inside a type-override region
111/// are removed and replaced with the re-extracted ranges.
112fn apply_type_overrides(
113    text: &str,
114    base_ranges: Vec<ProseRange>,
115    type_regions: &[&DirectiveRegion],
116    latex_extras: &latex::LatexExtras,
117) -> Result<Vec<ProseRange>> {
118    let override_spans: Vec<&Range<usize>> = type_regions.iter().map(|r| &r.byte_range).collect();
119
120    // Keep base ranges that don't start inside any type-override region.
121    let mut result: Vec<ProseRange> = base_ranges
122        .into_iter()
123        .filter(|r| {
124            !override_spans
125                .iter()
126                .any(|span| span.contains(&r.start_byte))
127        })
128        .collect();
129
130    for region in type_regions {
131        let doc_type = region.options.doc_type.as_deref().unwrap();
132        let canonical = crate::languages::resolve_language_id(doc_type);
133
134        if !crate::languages::SUPPORTED_LANGUAGE_IDS.contains(&canonical) {
135            eprintln!("lang-check: `type:{doc_type}` is not a supported language; skipping region");
136            continue;
137        }
138
139        let slice = &text[region.byte_range.clone()];
140        let ts_lang = crate::languages::resolve_ts_language(canonical);
141        let mut ext = ProseExtractor::new(ts_lang)?;
142        let sub_ranges = ext.extract(slice, canonical, latex_extras)?;
143
144        let offset = region.byte_range.start;
145        for mut r in sub_ranges {
146            r.start_byte += offset;
147            r.end_byte += offset;
148            r.exclusions = r
149                .exclusions
150                .into_iter()
151                .map(|(s, e)| (s + offset, e + offset))
152                .collect();
153            result.push(r);
154        }
155    }
156
157    result.sort_by_key(|r| r.start_byte);
158    Ok(result)
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct ProseRange {
163    pub start_byte: usize,
164    pub end_byte: usize,
165    /// Byte ranges (document-level) within this prose range that should be
166    /// excluded from grammar checking (e.g. display math). These regions are
167    /// replaced with spaces when extracting text, preserving byte offsets.
168    pub exclusions: Vec<(usize, usize)>,
169}
170
171impl ProseRange {
172    /// Extract the prose text from the full document, replacing any excluded
173    /// regions with spaces so that byte offsets remain stable.
174    #[must_use]
175    pub fn extract_text<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
176        let slice = &text[self.start_byte..self.end_byte];
177        if self.exclusions.is_empty() {
178            return std::borrow::Cow::Borrowed(slice);
179        }
180        // Each exclusion must be a char-aligned byte range: we blank it with
181        // ASCII spaces, and overwriting only part of a multibyte character
182        // would corrupt the UTF-8 buffer (UB via the `as_bytes_mut` write).
183        // Exclusion boundaries originate from tree-sitter node offsets and
184        // prose-range boundaries, which are always char-aligned — assert it in
185        // debug builds so a regression fails loudly instead of silently.
186        #[cfg(debug_assertions)]
187        for &(exc_start, exc_end) in &self.exclusions {
188            let s = exc_start.saturating_sub(self.start_byte).min(slice.len());
189            let e = exc_end.saturating_sub(self.start_byte).min(slice.len());
190            debug_assert!(
191                slice.is_char_boundary(s) && slice.is_char_boundary(e),
192                "exclusion ({s}, {e}) is not on a char boundary in {slice:?}"
193            );
194        }
195
196        let mut buf = slice.to_string();
197        // SAFETY: every write below blanks a whole, char-aligned byte range
198        // with ASCII spaces (0x20), which preserves the UTF-8 validity of `buf`.
199        let bytes = unsafe { buf.as_bytes_mut() };
200        for &(exc_start, exc_end) in &self.exclusions {
201            // Convert document-level offsets to slice-local offsets, clamping
202            // both ends into range so a stray exclusion can never index OOB.
203            let local_start = exc_start.saturating_sub(self.start_byte).min(bytes.len());
204            let local_end = exc_end.saturating_sub(self.start_byte).min(bytes.len());
205            if local_start < local_end {
206                bytes[local_start..local_end].fill(b' ');
207            }
208        }
209        strip_unmatched_brackets(bytes);
210        std::borrow::Cow::Owned(buf)
211    }
212
213    /// Check whether a local byte range (relative to this prose range)
214    /// overlaps with any exclusion zone.
215    #[must_use]
216    #[allow(clippy::cast_possible_truncation)]
217    pub fn overlaps_exclusion(&self, local_start: u32, local_end: u32) -> bool {
218        let doc_start = self.start_byte as u32 + local_start;
219        let doc_end = self.start_byte as u32 + local_end;
220        self.exclusions.iter().any(|&(exc_start, exc_end)| {
221            let es = exc_start as u32;
222            let ee = exc_end as u32;
223            doc_start < ee && doc_end > es
224        })
225    }
226
227    /// Classify how a diagnostic (range-local byte span) sits relative to the
228    /// skipped (excluded) segments in this range. Excluded segments are blanked
229    /// to spaces before checking, which breaks the surrounding sentence and
230    /// provokes false positives on the flanking text — this drives which of
231    /// those to suppress (see [`Self::suppresses_diagnostic`]).
232    #[must_use]
233    pub fn exclusion_adjacency(
234        &self,
235        text: &str,
236        local_start: u32,
237        local_end: u32,
238    ) -> ExclusionAdjacency {
239        if self.overlaps_exclusion(local_start, local_end) {
240            return ExclusionAdjacency::Overlapping;
241        }
242        let doc_start = self.start_byte + local_start as usize;
243        let doc_end = self.start_byte + local_end as usize;
244        let mut best = ExclusionAdjacency::None;
245        for &(es, ee) in &self.exclusions {
246            // No overlap, so the diagnostic lies entirely before or after this
247            // skip; the gap is the text between the two. When that gap is empty,
248            // the skip edge char decides glued-vs-adjacent: exclusion ranges can
249            // swallow a flanking space (e.g. inline-math delimiters), so a skip
250            // edge that is itself whitespace still means a real word separated by
251            // space, not a word-fragment fused to skip content.
252            let rel = if doc_start >= ee {
253                classify_gap(text, ee, doc_start, byte_before_is_whitespace(text, ee))
254            } else {
255                classify_gap(text, doc_end, es, byte_at_is_whitespace(text, es))
256            };
257            best = best.max_severity(rel);
258            if best == ExclusionAdjacency::Glued {
259                break; // strongest reachable here (overlap already handled)
260            }
261        }
262        best
263    }
264
265    /// Whether a diagnostic should be dropped as a skip-induced false positive.
266    ///
267    /// - Overlapping a skip, or glued to one with no character between them
268    ///   (blanking split a real word into a fragment): always suppressed.
269    /// - Separated from a skip by whitespace only (a real word flanking the
270    ///   cut): suppressed unless it is a spelling diagnostic. Removing a
271    ///   neighbour cannot misspell a real word, so genuine typos beside formulas
272    ///   are kept; the structural grammar/typography/style noise is dropped.
273    /// - Otherwise: kept.
274    #[must_use]
275    pub fn suppresses_diagnostic(
276        &self,
277        text: &str,
278        local_start: u32,
279        local_end: u32,
280        unified_id: &str,
281    ) -> bool {
282        match self.exclusion_adjacency(text, local_start, local_end) {
283            ExclusionAdjacency::Overlapping | ExclusionAdjacency::Glued => true,
284            ExclusionAdjacency::WhitespaceAdjacent => !is_spelling_category(unified_id),
285            ExclusionAdjacency::None => false,
286        }
287    }
288}
289
290/// How a diagnostic span sits relative to a range's skipped segments.
291#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum ExclusionAdjacency {
293    /// The diagnostic span intersects a skip.
294    Overlapping,
295    /// The diagnostic directly abuts a skip with no character between them.
296    Glued,
297    /// The diagnostic is separated from a skip by whitespace only.
298    WhitespaceAdjacent,
299    /// The diagnostic is not near any skip.
300    None,
301}
302
303impl ExclusionAdjacency {
304    const fn rank(self) -> u8 {
305        match self {
306            Self::None => 0,
307            Self::WhitespaceAdjacent => 1,
308            Self::Glued => 2,
309            Self::Overlapping => 3,
310        }
311    }
312
313    /// The stronger (higher-priority) of two classifications.
314    #[must_use]
315    const fn max_severity(self, other: Self) -> Self {
316        if other.rank() > self.rank() {
317            other
318        } else {
319            self
320        }
321    }
322}
323
324/// Classify the document text in `[lo, hi)` as the gap between a diagnostic and
325/// a skip: an all-whitespace (non-empty) gap is
326/// [`ExclusionAdjacency::WhitespaceAdjacent`], anything else (a real word lies
327/// between) is [`ExclusionAdjacency::None`]. When the gap is empty the two touch
328/// directly, and `skip_edge_is_whitespace` (the skip's boundary char) decides:
329/// whitespace there means a real word separated by a swallowed space
330/// ([`ExclusionAdjacency::WhitespaceAdjacent`]); otherwise the diagnostic is a
331/// word-fragment fused to skip content ([`ExclusionAdjacency::Glued`]).
332fn classify_gap(
333    text: &str,
334    lo: usize,
335    hi: usize,
336    skip_edge_is_whitespace: bool,
337) -> ExclusionAdjacency {
338    if lo == hi {
339        return if skip_edge_is_whitespace {
340            ExclusionAdjacency::WhitespaceAdjacent
341        } else {
342            ExclusionAdjacency::Glued
343        };
344    }
345    match text.get(lo..hi) {
346        Some(gap) if gap.chars().all(char::is_whitespace) => ExclusionAdjacency::WhitespaceAdjacent,
347        _ => ExclusionAdjacency::None,
348    }
349}
350
351/// Whether the character ending at byte `pos` (i.e. just before it) is whitespace.
352fn byte_before_is_whitespace(text: &str, pos: usize) -> bool {
353    text.get(..pos)
354        .and_then(|s| s.chars().next_back())
355        .is_some_and(char::is_whitespace)
356}
357
358/// Whether the character starting at byte `pos` is whitespace.
359fn byte_at_is_whitespace(text: &str, pos: usize) -> bool {
360    text.get(pos..)
361        .and_then(|s| s.chars().next())
362        .is_some_and(char::is_whitespace)
363}
364
365/// Whether a unified rule id denotes a spelling diagnostic (e.g. `spelling.typo`).
366#[must_use]
367pub fn is_spelling_category(unified_id: &str) -> bool {
368    unified_id.starts_with("spelling.")
369}
370
371/// Replace provably-unmatched brackets `()[]{}` with spaces.
372///
373/// Uses a single O(n) pass with per-type stacks. Only brackets that have no
374/// matching partner anywhere in the text are replaced — correctly paired
375/// brackets (even across exclusion gaps) are left untouched.
376fn strip_unmatched_brackets(bytes: &mut [u8]) {
377    let mut paren_stack: Vec<usize> = Vec::new();
378    let mut bracket_stack: Vec<usize> = Vec::new();
379    let mut brace_stack: Vec<usize> = Vec::new();
380    let mut unmatched: Vec<usize> = Vec::new();
381
382    for (i, &b) in bytes.iter().enumerate() {
383        match b {
384            b'(' => paren_stack.push(i),
385            b')' if paren_stack.pop().is_none() => {
386                unmatched.push(i);
387            }
388            b'[' => bracket_stack.push(i),
389            b']' if bracket_stack.pop().is_none() => {
390                unmatched.push(i);
391            }
392            b'{' => brace_stack.push(i),
393            b'}' if brace_stack.pop().is_none() => {
394                unmatched.push(i);
395            }
396            _ => {}
397        }
398    }
399
400    unmatched.extend(paren_stack);
401    unmatched.extend(bracket_stack);
402    unmatched.extend(brace_stack);
403
404    for idx in unmatched {
405        bytes[idx] = b' ';
406    }
407}
408
409#[cfg(test)]
410mod tests {
411    use super::*;
412    use latex::LatexExtras;
413
414    // ---- extract_text byte-blanking (FFI-free; also exercised under Miri) ----
415
416    #[test]
417    fn extract_text_no_exclusions_is_borrowed() {
418        let text = "café — touché";
419        let range = ProseRange {
420            start_byte: 0,
421            end_byte: text.len(),
422            exclusions: Vec::new(),
423        };
424        let out = range.extract_text(text);
425        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
426        assert_eq!(out, text);
427    }
428
429    #[test]
430    fn extract_text_blanks_excluded_ascii_keeping_multibyte() {
431        // "café" keeps its multibyte 'é'; the ascii 'X' region is blanked.
432        let text = "café X tea";
433        let x = text.find('X').unwrap();
434        let range = ProseRange {
435            start_byte: 0,
436            end_byte: text.len(),
437            exclusions: vec![(x, x + 1)],
438        };
439        let out = range.extract_text(text);
440        assert_eq!(out, "café   tea");
441        assert!(std::str::from_utf8(out.as_bytes()).is_ok());
442    }
443
444    #[test]
445    fn extract_text_blanks_a_whole_multibyte_char() {
446        // Excluding the em-dash (3 UTF-8 bytes) must blank all 3 and stay valid.
447        let text = "a—b";
448        let dash_start = text.find('—').unwrap();
449        let dash_end = dash_start + '—'.len_utf8();
450        let range = ProseRange {
451            start_byte: 0,
452            end_byte: text.len(),
453            exclusions: vec![(dash_start, dash_end)],
454        };
455        let out = range.extract_text(text);
456        assert_eq!(out, "a   b");
457    }
458
459    #[test]
460    fn extract_text_handles_document_level_offsets() {
461        // Range starts partway into the document; exclusions are document-level.
462        let text = "PREFIX café — done";
463        let start = text.find("café").unwrap();
464        let dash = text.find('—').unwrap();
465        let range = ProseRange {
466            start_byte: start,
467            end_byte: text.len(),
468            exclusions: vec![(dash, dash + '—'.len_utf8())],
469        };
470        // " — " → space + 3 blanked em-dash bytes + space = 5 spaces.
471        let out = range.extract_text(text);
472        assert_eq!(out, "café     done");
473    }
474
475    #[test]
476    fn test_markdown_extraction() -> Result<()> {
477        let language: tree_sitter::Language = tree_sitter_md::LANGUAGE.into();
478        let mut extractor = ProseExtractor::new(language)?;
479
480        let text =
481            "# Header\n\nThis is a paragraph.\n\n```rust\nfn main() {}\n```\n\nAnother paragraph.";
482        let ranges = extractor.extract(text, "markdown", &LatexExtras::default())?;
483
484        assert!(ranges.len() >= 3);
485
486        let extracted_texts: Vec<&str> = ranges
487            .iter()
488            .map(|r| &text[r.start_byte..r.end_byte])
489            .collect();
490        assert!(extracted_texts.iter().any(|t| t.contains("Header")));
491        assert!(
492            extracted_texts
493                .iter()
494                .any(|t| t.contains("This is a paragraph"))
495        );
496        assert!(
497            extracted_texts
498                .iter()
499                .any(|t| t.contains("Another paragraph"))
500        );
501
502        Ok(())
503    }
504
505    #[test]
506    fn test_overlaps_exclusion() {
507        let range = ProseRange {
508            start_byte: 100,
509            end_byte: 300,
510            exclusions: vec![(150, 200)],
511        };
512
513        // Diagnostic entirely inside exclusion
514        assert!(range.overlaps_exclusion(50, 100)); // local 50..100 = doc 150..200
515        // Diagnostic partially overlapping exclusion
516        assert!(range.overlaps_exclusion(40, 60)); // doc 140..160 overlaps 150..200
517        assert!(range.overlaps_exclusion(90, 110)); // doc 190..210 overlaps 150..200
518        // Diagnostic entirely outside exclusion
519        assert!(!range.overlaps_exclusion(0, 40)); // doc 100..140, before exclusion
520        assert!(!range.overlaps_exclusion(110, 130)); // doc 210..230, after exclusion
521    }
522
523    #[test]
524    fn test_exclusion_adjacency_classifies_position() {
525        // "a #{i} is b" — skip #{i} occupies bytes [2, 6).
526        let text = "a #{i} is b";
527        let range = ProseRange {
528            start_byte: 0,
529            end_byte: text.len(),
530            exclusions: vec![(2, 6)],
531        };
532        // "is" at [7, 9): one space after the skip → whitespace-adjacent.
533        assert_eq!(
534            range.exclusion_adjacency(text, 7, 9),
535            ExclusionAdjacency::WhitespaceAdjacent
536        );
537        // A span landing inside the skip → overlapping.
538        assert_eq!(
539            range.exclusion_adjacency(text, 3, 5),
540            ExclusionAdjacency::Overlapping
541        );
542        // "b" at [10, 11): a real word ("is") lies between it and the skip → none.
543        assert_eq!(
544            range.exclusion_adjacency(text, 10, 11),
545            ExclusionAdjacency::None
546        );
547    }
548
549    #[test]
550    fn test_exclusion_adjacency_detects_glued_fragment() {
551        // "#{n}th word" — skip #{n} is [0, 4); "th" is glued to it at [4, 6).
552        let text = "#{n}th word";
553        let range = ProseRange {
554            start_byte: 0,
555            end_byte: text.len(),
556            exclusions: vec![(0, 4)],
557        };
558        assert_eq!(
559            range.exclusion_adjacency(text, 4, 6),
560            ExclusionAdjacency::Glued
561        );
562    }
563
564    #[test]
565    fn test_exclusion_swallowing_flanking_space_is_not_glued() {
566        // Inline-math delimiter exclusions can include the flanking space, so the
567        // skip range starts at the space (byte 3), not at `#`. A real word ending
568        // exactly where the exclusion begins must still read as whitespace-
569        // separated, not glued.  Regression for spelling typos beside #{X}.
570        let text = "teh #{G} ok"; // exclusion ` #{` = bytes [3, 6)
571        let range = ProseRange {
572            start_byte: 0,
573            end_byte: text.len(),
574            exclusions: vec![(3, 6)],
575        };
576        assert_eq!(
577            range.exclusion_adjacency(text, 0, 3),
578            ExclusionAdjacency::WhitespaceAdjacent
579        );
580        // A genuine typo here is kept; only the grammar/structure noise is dropped.
581        assert!(!range.suppresses_diagnostic(text, 0, 3, "spelling.typo"));
582        assert!(range.suppresses_diagnostic(text, 0, 3, "typography.capitalization"));
583    }
584
585    #[test]
586    fn test_suppresses_diagnostic_keeps_spelling_near_skip() {
587        // "a #{i} wrd b" — skip at [2, 6); the misspelling "wrd" is at [7, 10),
588        // whitespace-adjacent to the skip.
589        let text = "a #{i} wrd b";
590        let range = ProseRange {
591            start_byte: 0,
592            end_byte: text.len(),
593            exclusions: vec![(2, 6)],
594        };
595        // Grammar/typography noise flanking the cut is suppressed...
596        assert!(range.suppresses_diagnostic(text, 7, 10, "typography.capitalization"));
597        // ...but a genuine adjacent typo is kept.
598        assert!(!range.suppresses_diagnostic(text, 7, 10, "spelling.typo"));
599    }
600
601    #[test]
602    fn test_suppresses_diagnostic_drops_glued_fragment_spelling() {
603        // "#{n}th word" — "th" is a fragment created by cutting the skip, so even
604        // a spelling diagnostic on it is suppressed.
605        let text = "#{n}th word";
606        let range = ProseRange {
607            start_byte: 0,
608            end_byte: text.len(),
609            exclusions: vec![(0, 4)],
610        };
611        assert!(range.suppresses_diagnostic(text, 4, 6, "spelling.typo"));
612        // A real word with text between it and the skip is untouched.
613        assert!(!range.suppresses_diagnostic(text, 7, 11, "spelling.typo"));
614    }
615
616    #[test]
617    fn type_override_latex_in_markdown() -> Result<()> {
618        let text = "\
619# Title
620
621Some intro text.
622
623<!-- lang-check-begin type:latex -->
624\\emph{Hello} world and \\textbf{bold} text.
625<!-- lang-check-end -->
626
627Final paragraph.";
628
629        let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
630
631        let texts: Vec<&str> = ranges
632            .iter()
633            .map(|r| &text[r.start_byte..r.end_byte])
634            .collect();
635
636        // Surrounding markdown prose is preserved.
637        assert!(texts.iter().any(|t| t.contains("Title")));
638        assert!(texts.iter().any(|t| t.contains("intro text")));
639        assert!(texts.iter().any(|t| t.contains("Final paragraph")));
640
641        // The LaTeX region was re-extracted: the prose content from
642        // \emph{Hello} and \textbf{bold} should appear in ranges.
643        assert!(
644            texts.iter().any(|t| t.contains("Hello")),
645            "expected LaTeX extractor to produce range containing 'Hello', got: {texts:?}"
646        );
647
648        Ok(())
649    }
650
651    #[test]
652    fn type_override_unknown_skipped() -> Result<()> {
653        let text = "\
654# Title
655
656<!-- lang-check-begin type:foobar -->
657Some content here.
658<!-- lang-check-end -->
659
660Trailing text.";
661
662        let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
663
664        let texts: Vec<&str> = ranges
665            .iter()
666            .map(|r| &text[r.start_byte..r.end_byte])
667            .collect();
668
669        // Surrounding ranges preserved.
670        assert!(texts.iter().any(|t| t.contains("Title")));
671        assert!(texts.iter().any(|t| t.contains("Trailing text")));
672
673        // The unknown-type region's base ranges were filtered out, and no
674        // re-extraction happened, so "Some content" should be absent.
675        assert!(
676            !texts.iter().any(|t| t.contains("Some content")),
677            "expected unknown type region to be skipped, got: {texts:?}"
678        );
679
680        Ok(())
681    }
682
683    #[test]
684    fn type_override_preserves_surrounding() -> Result<()> {
685        let text = "\
686First paragraph before.
687
688<!-- lang-check-begin type:latex -->
689\\section{Test}
690Some LaTeX prose.
691<!-- lang-check-end -->
692
693Last paragraph after.";
694
695        let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
696
697        let texts: Vec<&str> = ranges
698            .iter()
699            .map(|r| &text[r.start_byte..r.end_byte])
700            .collect();
701
702        // Both surrounding paragraphs must be present and unmodified.
703        assert!(
704            texts.iter().any(|t| t.contains("First paragraph before")),
705            "pre-region range missing: {texts:?}"
706        );
707        assert!(
708            texts.iter().any(|t| t.contains("Last paragraph after")),
709            "post-region range missing: {texts:?}"
710        );
711
712        Ok(())
713    }
714
715    #[test]
716    fn strip_unmatched_orphan_close() {
717        let mut bytes = b"hello } world".to_vec();
718        strip_unmatched_brackets(&mut bytes);
719        assert_eq!(&bytes, b"hello   world");
720    }
721
722    #[test]
723    fn strip_unmatched_orphan_open() {
724        let mut bytes = b"hello ( world".to_vec();
725        strip_unmatched_brackets(&mut bytes);
726        assert_eq!(&bytes, b"hello   world");
727    }
728
729    #[test]
730    fn strip_unmatched_preserves_matched() {
731        let mut bytes = b"f(x) and [y]".to_vec();
732        strip_unmatched_brackets(&mut bytes);
733        assert_eq!(&bytes, b"f(x) and [y]");
734    }
735
736    #[test]
737    fn strip_unmatched_mixed() {
738        // '}' is unmatched, '(x)' is matched
739        let mut bytes = b"value } is f(x)".to_vec();
740        strip_unmatched_brackets(&mut bytes);
741        assert_eq!(&bytes, b"value   is f(x)");
742    }
743
744    #[test]
745    fn strip_unmatched_via_extract_text() {
746        let range = ProseRange {
747            start_byte: 0,
748            end_byte: 20,
749            exclusions: vec![(5, 10)],
750        };
751        // "text } rest" after blanking exclusion [5,10) -> "text      rest"
752        // but if original is "text #{x+y} rest", after blanking the #{x+y}
753        // region we get "text        rest" with no unmatched brackets.
754        let text = "text #{x+y} rest____";
755        let clean = range.extract_text(text);
756        // The #{x+y} was blanked, no unmatched brackets remain
757        assert!(!clean.contains('#'));
758        assert!(!clean.contains('{'));
759        assert!(!clean.contains('}'));
760    }
761}