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 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        // Merge prose blocks split across markup boundaries (e.g. \p{…} math
61        // \p{…}) so a continuation isn't flagged as a new, uncapitalized
62        // sentence. Honors explicit `lang-check-begin block` overrides.
63        let force_regions = crate::ignore_rules::IgnoreParser::block_regions(text);
64        Ok(shared::merge_continuations(ranges, text, &force_regions))
65    }
66}
67
68/// Extract prose using a built-in tree-sitter extractor or an SLS fallback.
69///
70/// When the file extension matches a loaded SLS schema and that extension has
71/// no built-in tree-sitter extractor, the schema takes over. Built-in
72/// extensions always keep precedence.
73pub 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
108/// Re-extract prose for regions tagged with `type:FORMAT`.
109///
110/// For each type-override region, slices the document text, runs the specified
111/// format's extractor, and rebases the resulting ranges to document-level
112/// offsets. Base ranges whose `start_byte` falls inside a type-override region
113/// are removed and replaced with the re-extracted ranges.
114fn 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    // Keep base ranges that don't start inside any type-override region.
123    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    /// Byte ranges (document-level) within this prose range that should be
171    /// excluded from grammar checking (e.g. display math). These regions are
172    /// replaced with spaces when extracting text, preserving byte offsets.
173    pub exclusions: Vec<(usize, usize)>,
174}
175
176impl ProseRange {
177    /// Extract the prose text from the full document, replacing any excluded
178    /// regions with spaces so that byte offsets remain stable.
179    #[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        // Each exclusion must be a char-aligned byte range: we blank it with
186        // ASCII spaces, and overwriting only part of a multibyte character
187        // would corrupt the UTF-8 buffer (UB via the `as_bytes_mut` write).
188        // Exclusion boundaries originate from tree-sitter node offsets and
189        // prose-range boundaries, which are always char-aligned — assert it in
190        // debug builds so a regression fails loudly instead of silently.
191        #[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        // SAFETY: every write below blanks a whole, char-aligned byte range
203        // with ASCII spaces (0x20), which preserves the UTF-8 validity of `buf`.
204        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            // Convert document-level offsets to slice-local offsets, clamping
208            // both ends into range so a stray exclusion can never index OOB.
209            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    /// Check whether a local byte range (relative to this prose range)
222    /// overlaps with any exclusion zone.
223    #[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    /// Classify how a diagnostic (range-local byte span) sits relative to the
236    /// skipped (excluded) segments in this range. Excluded segments are blanked
237    /// to spaces before checking, which breaks the surrounding sentence and
238    /// provokes false positives on the flanking text — this drives which of
239    /// those to suppress (see [`Self::suppresses_diagnostic`]).
240    #[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            // No overlap, so the diagnostic lies entirely before or after this
255            // skip; the gap is the text between the two. When that gap is empty,
256            // the skip edge char decides glued-vs-adjacent: exclusion ranges can
257            // swallow a flanking space (e.g. inline-math delimiters), so a skip
258            // edge that is itself whitespace still means a real word separated by
259            // space, not a word-fragment fused to skip content.
260            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; // strongest reachable here (overlap already handled)
268            }
269        }
270        best
271    }
272
273    /// Whether a diagnostic should be dropped as a skip-induced false positive.
274    ///
275    /// - Overlapping a skip, or glued to one with no character between them
276    ///   (blanking split a real word into a fragment): always suppressed.
277    /// - Separated from a skip by whitespace only (a real word flanking the
278    ///   cut): suppressed unless it is a spelling diagnostic. Removing a
279    ///   neighbour cannot misspell a real word, so genuine typos beside formulas
280    ///   are kept; the structural grammar/typography/style noise is dropped.
281    /// - Otherwise: kept.
282    #[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    /// Take ownership of an engine's findings for this range: drop the
298    /// skip-induced false positives, then rebase the survivors from range-local
299    /// onto document byte offsets.
300    #[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/// The checkable text of every range, in order — the input to
312/// [`crate::orchestrator::Orchestrator::check_batch`].
313#[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/// How a diagnostic span sits relative to a range's skipped segments.
322#[derive(Debug, Clone, Copy, PartialEq, Eq)]
323pub enum ExclusionAdjacency {
324    /// The diagnostic span intersects a skip.
325    Overlapping,
326    /// The diagnostic directly abuts a skip with no character between them.
327    Glued,
328    /// The diagnostic is separated from a skip by whitespace only.
329    WhitespaceAdjacent,
330    /// The diagnostic is not near any skip.
331    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    /// The stronger (higher-priority) of two classifications.
345    #[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
355/// Classify the document text in `[lo, hi)` as the gap between a diagnostic and
356/// a skip: an all-whitespace (non-empty) gap is
357/// [`ExclusionAdjacency::WhitespaceAdjacent`], anything else (a real word lies
358/// between) is [`ExclusionAdjacency::None`]. When the gap is empty the two touch
359/// directly, and `skip_edge_is_whitespace` (the skip's boundary char) decides:
360/// whitespace there means a real word separated by a swallowed space
361/// ([`ExclusionAdjacency::WhitespaceAdjacent`]); otherwise the diagnostic is a
362/// word-fragment fused to skip content ([`ExclusionAdjacency::Glued`]).
363fn 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
382/// Whether the character ending at byte `pos` (i.e. just before it) is whitespace.
383fn 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
389/// Whether the character starting at byte `pos` is whitespace.
390fn 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/// Whether a unified rule id denotes a spelling diagnostic (e.g. `spelling.typo`).
397#[must_use]
398pub fn is_spelling_category(unified_id: &str) -> bool {
399    unified_id.starts_with("spelling.")
400}
401
402/// Replace provably-unmatched brackets `()[]{}` with spaces.
403///
404/// Uses a single O(n) pass with per-type stacks. Only brackets that have no
405/// matching partner anywhere in the text are replaced — correctly paired
406/// brackets (even across exclusion gaps) are left untouched.
407fn 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
440/// Whether the character covering byte `i` is alphanumeric — the neighbour test
441/// behind a quote's role: a quote hugging a word is an opener on the word's left
442/// and a closer on its right.
443///
444/// `bytes` is always a valid UTF-8 buffer, so the character `i` falls inside is
445/// decoded rather than assuming every non-ASCII byte is a letter — an em-dash
446/// must not read as a word.
447fn is_word_byte(bytes: &[u8], i: usize) -> bool {
448    if i >= bytes.len() {
449        return false;
450    }
451    // Walk back off any continuation byte (`0b10xxxxxx`) to the char's lead byte.
452    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
462/// Slide straight double quotes across an adjacent blanked region so that
463/// blanking cannot flip their open/close role.
464///
465/// Exclusions are blanked to spaces in place to keep byte offsets stable, which
466/// strands a quote against whitespace that was not there in the source:
467/// `"#{m} is a map"` becomes `"␣␣␣␣␣is a map"`. Grammar engines infer a quote's
468/// role from its neighbours — `LanguageTool`'s `EN_UNPAIRED_QUOTES` reads a
469/// quote followed by a space as a *closing* quote — so the opener is misread and
470/// the genuine closer is reported as unpaired. Swapping the quote with the space
471/// that now hugs the word restores the neighbour it had in the source, and since
472/// it is a swap the buffer's length and offsets are untouched.
473///
474/// Only ASCII `"` is reseated: curly quotes are multi-byte and could not be
475/// swapped with a one-byte space, and `'` is ambiguous with apostrophes. The
476/// scan crosses plain spaces only, so a quote never migrates over a line break.
477///
478/// A reseated quote can land inside the skip it crossed, so a report about a
479/// quote that really is unpaired next to math is dropped by
480/// [`ProseRange::suppresses_diagnostic`] — the same trade the skip machinery
481/// already makes for structural noise around excluded regions.
482fn reseat_quotes_across_blanks(bytes: &mut [u8], blanked: &[(usize, usize)]) {
483    for &(start, end) in blanked {
484        if start >= end {
485            continue;
486        }
487        // `"␣␣R` → `␣␣"R`: an opener (no word in front of it) stranded before the
488        // blank, with a word past the run to re-attach to.
489        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        // `R␣␣"` → `R"␣␣`: the mirror case, a closer stranded behind the blank.
503        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    // ---- extract_text byte-blanking (FFI-free; also exercised under Miri) ----
523
524    #[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        // "café" keeps its multibyte 'é'; the ascii 'X' region is blanked.
540        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        // Excluding the em-dash (3 UTF-8 bytes) must blank all 3 and stay valid.
555        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        // Range starts partway into the document; exclusions are document-level.
570        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        // " — " → space + 3 blanked em-dash bytes + space = 5 spaces.
579        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        // Without the reseat the opener reads as a closer (it is followed by the
595        // blank), so engines report the real closing quote as unpaired.
596        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        // "two" is at range-local 4..7.
646        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        // Overlapping the skip, and a non-spelling diagnostic beside it.
667        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        // A quote must not migrate onto the next line, so the scan stops at `\n`.
702        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        // Diagnostic entirely inside exclusion
746        assert!(range.overlaps_exclusion(50, 100)); // local 50..100 = doc 150..200
747        // Diagnostic partially overlapping exclusion
748        assert!(range.overlaps_exclusion(40, 60)); // doc 140..160 overlaps 150..200
749        assert!(range.overlaps_exclusion(90, 110)); // doc 190..210 overlaps 150..200
750        // Diagnostic entirely outside exclusion
751        assert!(!range.overlaps_exclusion(0, 40)); // doc 100..140, before exclusion
752        assert!(!range.overlaps_exclusion(110, 130)); // doc 210..230, after exclusion
753    }
754
755    #[test]
756    fn test_exclusion_adjacency_classifies_position() {
757        // "a #{i} is b" — skip #{i} occupies bytes [2, 6).
758        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        // "is" at [7, 9): one space after the skip → whitespace-adjacent.
765        assert_eq!(
766            range.exclusion_adjacency(text, 7, 9),
767            ExclusionAdjacency::WhitespaceAdjacent
768        );
769        // A span landing inside the skip → overlapping.
770        assert_eq!(
771            range.exclusion_adjacency(text, 3, 5),
772            ExclusionAdjacency::Overlapping
773        );
774        // "b" at [10, 11): a real word ("is") lies between it and the skip → none.
775        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        // "#{n}th word" — skip #{n} is [0, 4); "th" is glued to it at [4, 6).
784        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        // Inline-math delimiter exclusions can include the flanking space, so the
799        // skip range starts at the space (byte 3), not at `#`. A real word ending
800        // exactly where the exclusion begins must still read as whitespace-
801        // separated, not glued.  Regression for spelling typos beside #{X}.
802        let text = "teh #{G} ok"; // exclusion ` #{` = bytes [3, 6)
803        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        // A genuine typo here is kept; only the grammar/structure noise is dropped.
813        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        // "a #{i} wrd b" — skip at [2, 6); the misspelling "wrd" is at [7, 10),
820        // whitespace-adjacent to the skip.
821        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        // Grammar/typography noise flanking the cut is suppressed...
828        assert!(range.suppresses_diagnostic(text, 7, 10, "typography.capitalization"));
829        // ...but a genuine adjacent typo is kept.
830        assert!(!range.suppresses_diagnostic(text, 7, 10, "spelling.typo"));
831    }
832
833    #[test]
834    fn test_suppresses_diagnostic_drops_glued_fragment_spelling() {
835        // "#{n}th word" — "th" is a fragment created by cutting the skip, so even
836        // a spelling diagnostic on it is suppressed.
837        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        // A real word with text between it and the skip is untouched.
845        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        // Surrounding markdown prose is preserved.
869        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        // The LaTeX region was re-extracted: the prose content from
874        // \emph{Hello} and \textbf{bold} should appear in ranges.
875        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        // Surrounding ranges preserved.
902        assert!(texts.iter().any(|t| t.contains("Title")));
903        assert!(texts.iter().any(|t| t.contains("Trailing text")));
904
905        // The unknown-type region's base ranges were filtered out, and no
906        // re-extraction happened, so "Some content" should be absent.
907        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        // Both surrounding paragraphs must be present and unmodified.
935        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        // '}' is unmatched, '(x)' is matched
971        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        // "text } rest" after blanking exclusion [5,10) -> "text      rest"
984        // but if original is "text #{x+y} rest", after blanking the #{x+y}
985        // region we get "text        rest" with no unmatched brackets.
986        let text = "text #{x+y} rest____";
987        let clean = range.extract_text(text);
988        // The #{x+y} was blanked, no unmatched brackets remain
989        assert!(!clean.contains('#'));
990        assert!(!clean.contains('{'));
991        assert!(!clean.contains('}'));
992    }
993}