Skip to main content

lang_check/prose/
mod.rs

1mod bibtex;
2mod forester;
3pub mod gap;
4pub mod latex;
5mod org;
6mod query;
7mod rst;
8mod shared;
9mod sweave;
10mod tinylang;
11mod typst;
12
13use anyhow::{Result, anyhow};
14use std::ops::Range;
15use std::path::Path;
16use tracing::warn;
17use tree_sitter::{Language, Parser};
18
19use crate::checker::Diagnostic;
20use crate::ignore_rules::{DirectiveRegion, IgnoreParser};
21use crate::scoping::{ScopeParser, ScopedRegion};
22
23use crate::sls::SchemaRegistry;
24
25pub struct ProseExtractor {
26    parser: Parser,
27    language: Language,
28}
29
30impl ProseExtractor {
31    pub fn new(language: Language) -> Result<Self> {
32        let mut parser = Parser::new();
33        parser.set_language(&language)?;
34        Ok(Self { parser, language })
35    }
36
37    pub fn extract(
38        &mut self,
39        text: &str,
40        lang_id: &str,
41        latex_extras: &latex::LatexExtras,
42    ) -> Result<Vec<ProseRange>> {
43        let tree = self
44            .parser
45            .parse(text, None)
46            .ok_or_else(|| anyhow!("Failed to parse text"))?;
47
48        let root = tree.root_node();
49
50        let ranges = match lang_id {
51            "latex" => latex::extract(text, root, latex_extras),
52            "sweave" => sweave::extract(text, root, latex_extras),
53            "forester" => forester::extract(text, root),
54            "tinylang" => tinylang::extract(text, root),
55            "rst" => rst::extract(text, root),
56            "bibtex" => bibtex::extract(text, root),
57            "org" => org::extract(text, root),
58            "typst" => typst::extract(text, root),
59            lang => query::extract(text, root, &self.language, lang)?,
60        };
61
62        // Merge prose blocks split across markup boundaries (e.g. \p{…} math
63        // \p{…}) so a continuation isn't flagged as a new, uncapitalized
64        // sentence. Honors explicit `lang-check-begin block` overrides.
65        let force_regions = crate::ignore_rules::IgnoreParser::block_regions(text);
66        Ok(shared::merge_continuations(ranges, text, &force_regions))
67    }
68}
69
70/// Extract prose using a built-in tree-sitter extractor or an SLS fallback.
71///
72/// When the file extension matches a loaded SLS schema and that extension has
73/// no built-in tree-sitter extractor, the schema takes over. Built-in
74/// extensions always keep precedence.
75pub fn extract_with_fallback(
76    text: &str,
77    lang_id: &str,
78    path: Option<&Path>,
79    schema_registry: Option<&SchemaRegistry>,
80    latex_extras: &latex::LatexExtras,
81) -> Result<Vec<ProseRange>> {
82    extract_reporting_syntax(text, lang_id, path, schema_registry, latex_extras)
83        .map(|extraction| extraction.ranges)
84}
85
86/// One document's prose, and the grammar it was read with.
87#[derive(Debug, Clone)]
88pub struct Extraction {
89    pub ranges: Vec<ProseRange>,
90    /// What the text was actually parsed as, for the inspector to show: the
91    /// canonical language id, or an SLS schema's name when one took over. The
92    /// editor's own language id is not always what the core used, and that gap
93    /// is exactly what a user checking the parse needs to see.
94    pub syntax: String,
95}
96
97/// [`extract_with_fallback`], also reporting which grammar was chosen.
98pub fn extract_reporting_syntax(
99    text: &str,
100    lang_id: &str,
101    path: Option<&Path>,
102    schema_registry: Option<&SchemaRegistry>,
103    latex_extras: &latex::LatexExtras,
104) -> Result<Extraction> {
105    extract_with_range_limit(
106        text,
107        lang_id,
108        path,
109        schema_registry,
110        latex_extras,
111        crate::config::PerformanceConfig::default().max_range_bytes,
112    )
113}
114
115/// [`extract_reporting_syntax`], with the range size limit supplied.
116pub fn extract_with_range_limit(
117    text: &str,
118    lang_id: &str,
119    path: Option<&Path>,
120    schema_registry: Option<&SchemaRegistry>,
121    latex_extras: &latex::LatexExtras,
122    max_range_bytes: usize,
123) -> Result<Extraction> {
124    if let Some(ext) = path
125        .and_then(|value| value.extension())
126        .and_then(|value| value.to_str())
127        && crate::languages::builtin_language_for_extension(ext).is_none()
128        && let Some(schema) = schema_registry.and_then(|registry| registry.find_by_extension(ext))
129    {
130        return Ok(Extraction {
131            ranges: shared::split_oversized(schema.extract(text), text, max_range_bytes),
132            syntax: schema.name.clone(),
133        });
134    }
135
136    let canonical_lang = crate::languages::resolve_language_id(lang_id);
137    let language = crate::languages::resolve_ts_language(canonical_lang);
138    let mut extractor = ProseExtractor::new(language)?;
139    let mut ranges = extractor.extract(text, canonical_lang, latex_extras)?;
140
141    let directives = IgnoreParser::parse_directives(text);
142    let resolved = IgnoreParser::resolve_all(text, &directives);
143    let type_regions: Vec<_> = resolved
144        .regions
145        .iter()
146        .filter(|r| r.options.doc_type.is_some())
147        .collect();
148    if !type_regions.is_empty() {
149        ranges = apply_type_overrides(text, ranges, &type_regions, latex_extras)?;
150    }
151
152    apply_language_overrides(&mut ranges, &resolved.regions, &ScopeParser::parse(text));
153    // Last, so a chunk inherits the language of the range it came from and a
154    // split never lands inside an exclusion the extractors just installed.
155    Ok(Extraction {
156        ranges: shared::split_oversized(ranges, text, max_range_bytes),
157        syntax: canonical_lang.to_string(),
158    })
159}
160
161/// Stamp the language a comment declares onto the ranges it covers.
162///
163/// Three sources, strongest first:
164///
165/// 1. `lang-check-begin lang:xx` … `lang-check-end`, innermost region winning;
166/// 2. a `lang: xx` scope marker, which runs until the next marker;
167/// 3. whatever the markup itself said, which the extractor already set — a
168///    Typst `#set text(lang: "de")`, for instance.
169///
170/// A directive is an instruction to the checker and beats what the markup says,
171/// which is how a `#set text(lang: "de")` meant for hyphenation gets overridden
172/// for one quoted passage without touching the typesetting.
173fn apply_language_overrides(
174    ranges: &mut [ProseRange],
175    regions: &[DirectiveRegion],
176    scopes: &[ScopedRegion],
177) {
178    let with_language: Vec<&DirectiveRegion> = regions
179        .iter()
180        .filter(|region| region.options.language.is_some())
181        .collect();
182    if with_language.is_empty() && scopes.is_empty() {
183        return;
184    }
185    for range in ranges {
186        let innermost = with_language
187            .iter()
188            .filter(|region| region.byte_range.contains(&range.start_byte))
189            .min_by_key(|region| region.byte_range.end - region.byte_range.start);
190        if let Some(region) = innermost {
191            range.language.clone_from(&region.options.language);
192        } else if let Some(language) = ScopeParser::language_at(scopes, range.start_byte) {
193            range.language = Some(language.to_string());
194        }
195    }
196}
197
198/// Re-extract prose for regions tagged with `type:FORMAT`.
199///
200/// For each type-override region, slices the document text, runs the specified
201/// format's extractor, and rebases the resulting ranges to document-level
202/// offsets. Base ranges whose `start_byte` falls inside a type-override region
203/// are removed and replaced with the re-extracted ranges.
204fn apply_type_overrides(
205    text: &str,
206    base_ranges: Vec<ProseRange>,
207    type_regions: &[&DirectiveRegion],
208    latex_extras: &latex::LatexExtras,
209) -> Result<Vec<ProseRange>> {
210    let override_spans: Vec<&Range<usize>> = type_regions.iter().map(|r| &r.byte_range).collect();
211
212    // Keep base ranges that don't start inside any type-override region.
213    let mut result: Vec<ProseRange> = base_ranges
214        .into_iter()
215        .filter(|r| {
216            !override_spans
217                .iter()
218                .any(|span| span.contains(&r.start_byte))
219        })
220        .collect();
221
222    for region in type_regions {
223        let doc_type = region.options.doc_type.as_deref().unwrap();
224        let canonical = crate::languages::resolve_language_id(doc_type);
225
226        if !crate::languages::SUPPORTED_LANGUAGE_IDS.contains(&canonical) {
227            warn!(
228                doc_type,
229                "`type:` directive names an unsupported language; skipping region"
230            );
231            continue;
232        }
233
234        let slice = &text[region.byte_range.clone()];
235        let ts_lang = crate::languages::resolve_ts_language(canonical);
236        let mut ext = ProseExtractor::new(ts_lang)?;
237        let sub_ranges = ext.extract(slice, canonical, latex_extras)?;
238
239        let offset = region.byte_range.start;
240        for mut r in sub_ranges {
241            r.start_byte += offset;
242            r.end_byte += offset;
243            r.exclusions = r
244                .exclusions
245                .into_iter()
246                .map(|(s, e)| (s + offset, e + offset))
247                .collect();
248            result.push(r);
249        }
250    }
251
252    result.sort_by_key(|r| r.start_byte);
253    Ok(result)
254}
255
256#[derive(Debug, Clone, PartialEq, Eq)]
257pub struct ProseRange {
258    pub start_byte: usize,
259    pub end_byte: usize,
260    /// Byte ranges (document-level) within this prose range that should be
261    /// excluded from grammar checking (e.g. display math). These regions are
262    /// replaced with spaces when extracting text, preserving byte offsets.
263    pub exclusions: Vec<(usize, usize)>,
264    /// The natural language this prose is written in, as a BCP-47 tag, when the
265    /// document says so — a `lang-check-begin lang:` directive, or the format's
266    /// own declaration such as Typst's `#set text(lang: "fr")`. `None` means the
267    /// configured `spell_language` applies.
268    pub language: Option<String>,
269}
270
271impl ProseRange {
272    /// Extract the prose text from the full document, replacing any excluded
273    /// regions with spaces so that byte offsets remain stable.
274    #[must_use]
275    pub fn extract_text<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
276        let slice = &text[self.start_byte..self.end_byte];
277        if self.exclusions.is_empty() {
278            return std::borrow::Cow::Borrowed(slice);
279        }
280        // Each exclusion must be a char-aligned byte range: we blank it with
281        // ASCII spaces, and overwriting only part of a multibyte character
282        // would corrupt the UTF-8 buffer (UB via the `as_bytes_mut` write).
283        // Exclusion boundaries originate from tree-sitter node offsets and
284        // prose-range boundaries, which are always char-aligned — assert it in
285        // debug builds so a regression fails loudly instead of silently.
286        #[cfg(debug_assertions)]
287        for &(exc_start, exc_end) in &self.exclusions {
288            let s = exc_start.saturating_sub(self.start_byte).min(slice.len());
289            let e = exc_end.saturating_sub(self.start_byte).min(slice.len());
290            debug_assert!(
291                slice.is_char_boundary(s) && slice.is_char_boundary(e),
292                "exclusion ({s}, {e}) is not on a char boundary in {slice:?}"
293            );
294        }
295
296        let mut buf = slice.to_string();
297        // SAFETY: every write below blanks a whole, char-aligned byte range
298        // with ASCII spaces (0x20), which preserves the UTF-8 validity of `buf`.
299        let bytes = unsafe { buf.as_bytes_mut() };
300        let mut blanked: Vec<(usize, usize)> = Vec::with_capacity(self.exclusions.len());
301        for &(exc_start, exc_end) in &self.exclusions {
302            // Convert document-level offsets to slice-local offsets, clamping
303            // both ends into range so a stray exclusion can never index OOB.
304            let local_start = exc_start.saturating_sub(self.start_byte).min(bytes.len());
305            let local_end = exc_end.saturating_sub(self.start_byte).min(bytes.len());
306            if local_start < local_end {
307                bytes[local_start..local_end].fill(b' ');
308                blanked.push((local_start, local_end));
309            }
310        }
311        strip_unmatched_brackets(bytes);
312        reseat_quotes_across_blanks(bytes, &blanked);
313        std::borrow::Cow::Owned(buf)
314    }
315
316    /// Check whether a local byte range (relative to this prose range)
317    /// overlaps with any exclusion zone.
318    #[must_use]
319    #[allow(clippy::cast_possible_truncation)]
320    pub fn overlaps_exclusion(&self, local_start: u32, local_end: u32) -> bool {
321        let doc_start = self.start_byte as u32 + local_start;
322        let doc_end = self.start_byte as u32 + local_end;
323        self.exclusions.iter().any(|&(exc_start, exc_end)| {
324            let es = exc_start as u32;
325            let ee = exc_end as u32;
326            doc_start < ee && doc_end > es
327        })
328    }
329
330    /// Classify how a diagnostic (range-local byte span) sits relative to the
331    /// skipped (excluded) segments in this range. Excluded segments are blanked
332    /// to spaces before checking, which breaks the surrounding sentence and
333    /// provokes false positives on the flanking text — this drives which of
334    /// those to suppress (see [`Self::suppresses_diagnostic`]).
335    #[must_use]
336    pub fn exclusion_adjacency(
337        &self,
338        text: &str,
339        local_start: u32,
340        local_end: u32,
341    ) -> ExclusionAdjacency {
342        if self.overlaps_exclusion(local_start, local_end) {
343            return ExclusionAdjacency::Overlapping;
344        }
345        let doc_start = self.start_byte + local_start as usize;
346        let doc_end = self.start_byte + local_end as usize;
347        let mut best = ExclusionAdjacency::None;
348        for &(es, ee) in &self.exclusions {
349            // No overlap, so the diagnostic lies entirely before or after this
350            // skip; the gap is the text between the two. When that gap is empty,
351            // the skip edge char decides glued-vs-adjacent: exclusion ranges can
352            // swallow a flanking space (e.g. inline-math delimiters), so a skip
353            // edge that is itself whitespace still means a real word separated by
354            // space, not a word-fragment fused to skip content.
355            let rel = if doc_start >= ee {
356                classify_gap(text, ee, doc_start, byte_before_separates(text, ee))
357            } else {
358                classify_gap(text, doc_end, es, byte_at_separates(text, es))
359            };
360            best = best.max_severity(rel);
361            if best == ExclusionAdjacency::Glued {
362                break; // strongest reachable here (overlap already handled)
363            }
364        }
365        best
366    }
367
368    /// Whether a diagnostic should be dropped as a skip-induced false positive.
369    ///
370    /// - Overlapping a skip, or glued to one with no character between them
371    ///   (blanking split a real word into a fragment): always suppressed.
372    /// - Separated from a skip by whitespace only (a real word flanking the
373    ///   cut): suppressed unless it is a spelling diagnostic. Removing a
374    ///   neighbour cannot misspell a real word, so genuine typos beside formulas
375    ///   are kept; the structural grammar/typography/style noise is dropped.
376    /// - Otherwise: kept.
377    #[must_use]
378    pub fn suppresses_diagnostic(
379        &self,
380        text: &str,
381        local_start: u32,
382        local_end: u32,
383        unified_id: &str,
384    ) -> bool {
385        match self.exclusion_adjacency(text, local_start, local_end) {
386            ExclusionAdjacency::Overlapping | ExclusionAdjacency::Glued => true,
387            ExclusionAdjacency::WhitespaceAdjacent => !is_spelling_category(unified_id),
388            ExclusionAdjacency::None => false,
389        }
390    }
391
392    /// Take ownership of an engine's findings for this range: drop the
393    /// skip-induced false positives, then rebase the survivors from range-local
394    /// onto document byte offsets.
395    #[allow(clippy::cast_possible_truncation)]
396    pub fn adopt_diagnostics(&self, text: &str, diagnostics: &mut Vec<Diagnostic>) {
397        diagnostics
398            .retain(|d| !self.suppresses_diagnostic(text, d.start_byte, d.end_byte, &d.unified_id));
399        for d in diagnostics {
400            d.start_byte += self.start_byte as u32;
401            d.end_byte += self.start_byte as u32;
402        }
403    }
404}
405
406/// The checkable text of every range, in order — the input to
407/// [`crate::orchestrator::Orchestrator::check_batch`].
408#[must_use]
409pub fn range_texts(ranges: &[ProseRange], text: &str) -> Vec<String> {
410    ranges
411        .iter()
412        .map(|r| r.extract_text(text).into_owned())
413        .collect()
414}
415
416/// One prose range's text, with the natural language to check it in.
417///
418/// The two travel together because a document can hold more than one language
419/// and the engines have to be told which: Harper has no French, and
420/// `LanguageTool` asked for the wrong language reports every correctly spelled
421/// word as a misspelling.
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct ProseUnit {
424    pub text: String,
425    /// A BCP-47 tag, resolved: the range's own language when the document
426    /// declares one, and `default_language` otherwise.
427    pub language: String,
428}
429
430/// The prose ranges as checkable units, each carrying its resolved language.
431#[must_use]
432pub fn range_units(ranges: &[ProseRange], text: &str, default_language: &str) -> Vec<ProseUnit> {
433    ranges
434        .iter()
435        .map(|r| ProseUnit {
436            text: r.extract_text(text).into_owned(),
437            language: r.language.as_ref().map_or_else(
438                || default_language.to_string(),
439                |declared| crate::languages::resolve_spell_language(declared, default_language),
440            ),
441        })
442        .collect()
443}
444
445/// How a diagnostic span sits relative to a range's skipped segments.
446#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum ExclusionAdjacency {
448    /// The diagnostic span intersects a skip.
449    Overlapping,
450    /// The diagnostic directly abuts a skip with no character between them.
451    Glued,
452    /// The diagnostic is separated from a skip by whitespace only.
453    WhitespaceAdjacent,
454    /// The diagnostic is not near any skip.
455    None,
456}
457
458impl ExclusionAdjacency {
459    const fn rank(self) -> u8 {
460        match self {
461            Self::None => 0,
462            Self::WhitespaceAdjacent => 1,
463            Self::Glued => 2,
464            Self::Overlapping => 3,
465        }
466    }
467
468    /// The stronger (higher-priority) of two classifications.
469    #[must_use]
470    const fn max_severity(self, other: Self) -> Self {
471        if other.rank() > self.rank() {
472            other
473        } else {
474            self
475        }
476    }
477}
478
479/// Classify the document text in `[lo, hi)` as the gap between a diagnostic and
480/// a skip: an all-whitespace (non-empty) gap is
481/// [`ExclusionAdjacency::WhitespaceAdjacent`], anything else (a real word lies
482/// between) is [`ExclusionAdjacency::None`]. When the gap is empty the two touch
483/// directly, and `skip_edge_separates` (the skip's boundary char) decides:
484/// a separator there means a real, whole word next to the cut
485/// ([`ExclusionAdjacency::WhitespaceAdjacent`]); otherwise the diagnostic is a
486/// word-fragment fused to skip content ([`ExclusionAdjacency::Glued`]).
487fn classify_gap(text: &str, lo: usize, hi: usize, skip_edge_separates: bool) -> ExclusionAdjacency {
488    if lo == hi {
489        return if skip_edge_separates {
490            ExclusionAdjacency::WhitespaceAdjacent
491        } else {
492            ExclusionAdjacency::Glued
493        };
494    }
495    match text.get(lo..hi) {
496        Some(gap) if gap.chars().all(char::is_whitespace) => ExclusionAdjacency::WhitespaceAdjacent,
497        _ => ExclusionAdjacency::None,
498    }
499}
500
501/// Whether a skip's boundary character guarantees the word beside it is whole.
502///
503/// Whitespace does: an exclusion that swallowed a space still leaves a real
504/// word on the other side. A square bracket does too, because it delimits a
505/// group rather than carrying text -- in `#emph[a word]` the exclusion is
506/// `" #emph["` and `a` touches its `[`, but `a` is a complete word, not the
507/// tail of a blanked one. Other characters do not: `$k$th` blanks to `   th`,
508/// where `th` really is a fragment fused to the formula.
509///
510/// The inline-emphasis delimiters are the bracket case in another spelling.
511/// `_réception_` blanks to ` réception `, a whole word touching the skip on
512/// both sides, and without them here every emphasised word in a Markdown or
513/// Typst document is dropped as a fragment and never checked at all. The cost
514/// is `a**b**c`, where the delimiters really do sit inside a word and the
515/// halves are now offered to the speller; intra-word emphasis is rare enough
516/// to be the better trade against silently skipping every italic.
517const fn separates_words(c: char) -> bool {
518    c.is_whitespace() || matches!(c, '[' | ']' | '_' | '*' | '`')
519}
520
521/// Whether the character ending at byte `pos` (i.e. just before it) separates words.
522fn byte_before_separates(text: &str, pos: usize) -> bool {
523    text.get(..pos)
524        .and_then(|s| s.chars().next_back())
525        .is_some_and(separates_words)
526}
527
528/// Whether the character starting at byte `pos` separates words.
529fn byte_at_separates(text: &str, pos: usize) -> bool {
530    text.get(pos..)
531        .and_then(|s| s.chars().next())
532        .is_some_and(separates_words)
533}
534
535/// Whether a unified rule id denotes a spelling diagnostic (e.g. `spelling.typo`).
536#[must_use]
537pub fn is_spelling_category(unified_id: &str) -> bool {
538    unified_id.starts_with("spelling.")
539}
540
541/// Replace provably-unmatched brackets `()[]{}` with spaces.
542///
543/// Uses a single O(n) pass with per-type stacks. Only brackets that have no
544/// matching partner anywhere in the text are replaced — correctly paired
545/// brackets (even across exclusion gaps) are left untouched.
546fn strip_unmatched_brackets(bytes: &mut [u8]) {
547    let mut paren_stack: Vec<usize> = Vec::new();
548    let mut bracket_stack: Vec<usize> = Vec::new();
549    let mut brace_stack: Vec<usize> = Vec::new();
550    let mut unmatched: Vec<usize> = Vec::new();
551
552    for (i, &b) in bytes.iter().enumerate() {
553        match b {
554            b'(' => paren_stack.push(i),
555            b')' if paren_stack.pop().is_none() => {
556                unmatched.push(i);
557            }
558            b'[' => bracket_stack.push(i),
559            b']' if bracket_stack.pop().is_none() => {
560                unmatched.push(i);
561            }
562            b'{' => brace_stack.push(i),
563            b'}' if brace_stack.pop().is_none() => {
564                unmatched.push(i);
565            }
566            _ => {}
567        }
568    }
569
570    unmatched.extend(paren_stack);
571    unmatched.extend(bracket_stack);
572    unmatched.extend(brace_stack);
573
574    for idx in unmatched {
575        bytes[idx] = b' ';
576    }
577}
578
579/// Whether the character covering byte `i` is alphanumeric — the neighbour test
580/// behind a quote's role: a quote hugging a word is an opener on the word's left
581/// and a closer on its right.
582///
583/// `bytes` is always a valid UTF-8 buffer, so the character `i` falls inside is
584/// decoded rather than assuming every non-ASCII byte is a letter — an em-dash
585/// must not read as a word.
586fn is_word_byte(bytes: &[u8], i: usize) -> bool {
587    if i >= bytes.len() {
588        return false;
589    }
590    // Walk back off any continuation byte (`0b10xxxxxx`) to the char's lead byte.
591    let mut start = i;
592    while start > 0 && bytes[start] & 0b1100_0000 == 0b1000_0000 {
593        start -= 1;
594    }
595    (1..=4)
596        .find_map(|len| std::str::from_utf8(bytes.get(start..start + len)?).ok())
597        .and_then(|s| s.chars().next())
598        .is_some_and(char::is_alphanumeric)
599}
600
601/// Slide straight double quotes across an adjacent blanked region so that
602/// blanking cannot flip their open/close role.
603///
604/// Exclusions are blanked to spaces in place to keep byte offsets stable, which
605/// strands a quote against whitespace that was not there in the source:
606/// `"#{m} is a map"` becomes `"␣␣␣␣␣is a map"`. Grammar engines infer a quote's
607/// role from its neighbours — `LanguageTool`'s `EN_UNPAIRED_QUOTES` reads a
608/// quote followed by a space as a *closing* quote — so the opener is misread and
609/// the genuine closer is reported as unpaired. Swapping the quote with the space
610/// that now hugs the word restores the neighbour it had in the source, and since
611/// it is a swap the buffer's length and offsets are untouched.
612///
613/// Only ASCII `"` is reseated: curly quotes are multi-byte and could not be
614/// swapped with a one-byte space, and `'` is ambiguous with apostrophes. The
615/// scan crosses plain spaces only, so a quote never migrates over a line break.
616///
617/// A reseated quote can land inside the skip it crossed, so a report about a
618/// quote that really is unpaired next to math is dropped by
619/// [`ProseRange::suppresses_diagnostic`] — the same trade the skip machinery
620/// already makes for structural noise around excluded regions.
621fn reseat_quotes_across_blanks(bytes: &mut [u8], blanked: &[(usize, usize)]) {
622    for &(start, end) in blanked {
623        if start >= end {
624            continue;
625        }
626        // `"␣␣R` → `␣␣"R`: an opener (no word in front of it) stranded before the
627        // blank, with a word past the run to re-attach to.
628        if start > 0
629            && bytes[start - 1] == b'"'
630            && !start.checked_sub(2).is_some_and(|i| is_word_byte(bytes, i))
631        {
632            let word = (end..bytes.len())
633                .find(|&i| bytes[i] != b' ')
634                .filter(|&i| is_word_byte(bytes, i));
635            if let Some(word) = word {
636                bytes[start - 1] = b' ';
637                bytes[word - 1] = b'"';
638                continue;
639            }
640        }
641        // `R␣␣"` → `R"␣␣`: the mirror case, a closer stranded behind the blank.
642        if bytes.get(end) == Some(&b'"') && !is_word_byte(bytes, end + 1) {
643            let after_word = (0..start)
644                .rev()
645                .find(|&i| bytes[i] != b' ')
646                .filter(|&i| is_word_byte(bytes, i))
647                .map(|i| i + 1);
648            if let Some(after_word) = after_word {
649                bytes[end] = b' ';
650                bytes[after_word] = b'"';
651            }
652        }
653    }
654}
655
656#[cfg(test)]
657mod tests {
658    use super::*;
659    use latex::LatexExtras;
660
661    // ---- extract_text byte-blanking (FFI-free; also exercised under Miri) ----
662
663    #[test]
664    fn extract_text_no_exclusions_is_borrowed() {
665        let text = "café — touché";
666        let range = ProseRange {
667            start_byte: 0,
668            end_byte: text.len(),
669            exclusions: Vec::new(),
670            language: None,
671        };
672        let out = range.extract_text(text);
673        assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
674        assert_eq!(out, text);
675    }
676
677    #[test]
678    fn extract_text_blanks_excluded_ascii_keeping_multibyte() {
679        // "café" keeps its multibyte 'é'; the ascii 'X' region is blanked.
680        let text = "café X tea";
681        let x = text.find('X').unwrap();
682        let range = ProseRange {
683            start_byte: 0,
684            end_byte: text.len(),
685            exclusions: vec![(x, x + 1)],
686            language: None,
687        };
688        let out = range.extract_text(text);
689        assert_eq!(out, "café   tea");
690        assert!(std::str::from_utf8(out.as_bytes()).is_ok());
691    }
692
693    #[test]
694    fn extract_text_blanks_a_whole_multibyte_char() {
695        // Excluding the em-dash (3 UTF-8 bytes) must blank all 3 and stay valid.
696        let text = "a—b";
697        let dash_start = text.find('—').unwrap();
698        let dash_end = dash_start + '—'.len_utf8();
699        let range = ProseRange {
700            start_byte: 0,
701            end_byte: text.len(),
702            exclusions: vec![(dash_start, dash_end)],
703            language: None,
704        };
705        let out = range.extract_text(text);
706        assert_eq!(out, "a   b");
707    }
708
709    #[test]
710    fn extract_text_handles_document_level_offsets() {
711        // Range starts partway into the document; exclusions are document-level.
712        let text = "PREFIX café — done";
713        let start = text.find("café").unwrap();
714        let dash = text.find('—').unwrap();
715        let range = ProseRange {
716            start_byte: start,
717            end_byte: text.len(),
718            exclusions: vec![(dash, dash + '—'.len_utf8())],
719            language: None,
720        };
721        // " — " → space + 3 blanked em-dash bytes + space = 5 spaces.
722        let out = range.extract_text(text);
723        assert_eq!(out, "café     done");
724    }
725
726    fn range_excluding(text: &str, excluded: &str) -> ProseRange {
727        let start = text.find(excluded).unwrap();
728        ProseRange {
729            start_byte: 0,
730            end_byte: text.len(),
731            exclusions: vec![(start, start + excluded.len())],
732            language: None,
733        }
734    }
735
736    #[test]
737    fn extract_text_reseats_opening_quote_stranded_by_a_blank() {
738        // Without the reseat the opener reads as a closer (it is followed by the
739        // blank), so engines report the real closing quote as unpaired.
740        let text = r##"He said "#{m} is fine"."##;
741        let out = range_excluding(text, "#{m}").extract_text(text);
742        assert_eq!(out, r#"He said      "is fine"."#);
743    }
744
745    #[test]
746    fn extract_text_reseats_closing_quote_stranded_by_a_blank() {
747        let text = r#"He said "it is #{m}"."#;
748        let out = range_excluding(text, "#{m}").extract_text(text);
749        assert_eq!(out, r#"He said "it is"     ."#);
750    }
751
752    #[test]
753    fn extract_text_leaves_quotes_that_still_hug_their_word() {
754        let text = r#"He said "fine #{m} here"."#;
755        let out = range_excluding(text, "#{m}").extract_text(text);
756        assert_eq!(out, r#"He said "fine      here"."#);
757    }
758
759    #[test]
760    fn extract_text_reseat_keeps_utf8_valid_around_multibyte_words() {
761        let text = r##"Il dit "#{m} café"."##;
762        let out = range_excluding(text, "#{m}").extract_text(text);
763        assert_eq!(out, r#"Il dit      "café"."#);
764        assert!(std::str::from_utf8(out.as_bytes()).is_ok());
765    }
766
767    fn diagnostic(start: u32, end: u32, unified_id: &str) -> Diagnostic {
768        Diagnostic {
769            start_byte: start,
770            end_byte: end,
771            message: String::new(),
772            suggestions: Vec::new(),
773            rule_id: String::new(),
774            severity: 2,
775            unified_id: unified_id.to_string(),
776            confidence: 1.0,
777            language: String::new(),
778            pack_installable: false,
779        }
780    }
781
782    #[test]
783    fn adopt_diagnostics_rebases_survivors_onto_document_offsets() {
784        let text = "PREFIX one two";
785        let start = text.find("one").unwrap();
786        let range = ProseRange {
787            start_byte: start,
788            end_byte: text.len(),
789            exclusions: Vec::new(),
790            language: None,
791        };
792        // "two" is at range-local 4..7.
793        let mut diagnostics = vec![diagnostic(4, 7, "spelling.typo")];
794        range.adopt_diagnostics(text, &mut diagnostics);
795
796        assert_eq!(diagnostics.len(), 1);
797        let d = &diagnostics[0];
798        assert_eq!(
799            &text[d.start_byte as usize..d.end_byte as usize],
800            "two",
801            "rebased span must slice the same word out of the document"
802        );
803    }
804
805    #[test]
806    fn adopt_diagnostics_drops_skip_induced_false_positives() {
807        let text = "one XXX two";
808        let range = ProseRange {
809            start_byte: 0,
810            end_byte: text.len(),
811            exclusions: vec![(4, 7)],
812            language: None,
813        };
814        // Overlapping the skip, and a non-spelling diagnostic beside it.
815        let mut diagnostics = vec![
816            diagnostic(4, 7, "spelling.typo"),
817            diagnostic(8, 11, "typography.capitalization"),
818        ];
819        range.adopt_diagnostics(text, &mut diagnostics);
820
821        assert!(diagnostics.is_empty(), "got: {diagnostics:?}");
822    }
823
824    #[test]
825    fn range_texts_matches_per_range_extraction() {
826        let text = "alpha SKIP beta";
827        let ranges = vec![
828            ProseRange {
829                start_byte: 0,
830                end_byte: 5,
831                exclusions: Vec::new(),
832                language: None,
833            },
834            ProseRange {
835                start_byte: 6,
836                end_byte: text.len(),
837                exclusions: vec![(6, 10)],
838                language: None,
839            },
840        ];
841        let texts = range_texts(&ranges, text);
842
843        assert_eq!(texts.len(), ranges.len());
844        for (range, extracted) in ranges.iter().zip(&texts) {
845            assert_eq!(*extracted, range.extract_text(text));
846        }
847    }
848
849    #[test]
850    fn extract_text_reseat_does_not_cross_a_line_break() {
851        // A quote must not migrate onto the next line, so the scan stops at `\n`.
852        let text = "He said \"#{m}\nis fine\".";
853        let out = range_excluding(text, "#{m}").extract_text(text);
854        assert_eq!(out, "He said \"    \nis fine\".");
855    }
856
857    #[test]
858    fn test_markdown_extraction() -> Result<()> {
859        let language: tree_sitter::Language = tree_sitter_md::LANGUAGE.into();
860        let mut extractor = ProseExtractor::new(language)?;
861
862        let text =
863            "# Header\n\nThis is a paragraph.\n\n```rust\nfn main() {}\n```\n\nAnother paragraph.";
864        let ranges = extractor.extract(text, "markdown", &LatexExtras::default())?;
865
866        assert!(ranges.len() >= 3);
867
868        let extracted_texts: Vec<&str> = ranges
869            .iter()
870            .map(|r| &text[r.start_byte..r.end_byte])
871            .collect();
872        assert!(extracted_texts.iter().any(|t| t.contains("Header")));
873        assert!(
874            extracted_texts
875                .iter()
876                .any(|t| t.contains("This is a paragraph"))
877        );
878        assert!(
879            extracted_texts
880                .iter()
881                .any(|t| t.contains("Another paragraph"))
882        );
883
884        Ok(())
885    }
886
887    #[test]
888    fn test_overlaps_exclusion() {
889        let range = ProseRange {
890            start_byte: 100,
891            end_byte: 300,
892            exclusions: vec![(150, 200)],
893            language: None,
894        };
895
896        // Diagnostic entirely inside exclusion
897        assert!(range.overlaps_exclusion(50, 100)); // local 50..100 = doc 150..200
898        // Diagnostic partially overlapping exclusion
899        assert!(range.overlaps_exclusion(40, 60)); // doc 140..160 overlaps 150..200
900        assert!(range.overlaps_exclusion(90, 110)); // doc 190..210 overlaps 150..200
901        // Diagnostic entirely outside exclusion
902        assert!(!range.overlaps_exclusion(0, 40)); // doc 100..140, before exclusion
903        assert!(!range.overlaps_exclusion(110, 130)); // doc 210..230, after exclusion
904    }
905
906    #[test]
907    fn test_exclusion_adjacency_classifies_position() {
908        // "a #{i} is b" — skip #{i} occupies bytes [2, 6).
909        let text = "a #{i} is b";
910        let range = ProseRange {
911            start_byte: 0,
912            end_byte: text.len(),
913            exclusions: vec![(2, 6)],
914            language: None,
915        };
916        // "is" at [7, 9): one space after the skip → whitespace-adjacent.
917        assert_eq!(
918            range.exclusion_adjacency(text, 7, 9),
919            ExclusionAdjacency::WhitespaceAdjacent
920        );
921        // A span landing inside the skip → overlapping.
922        assert_eq!(
923            range.exclusion_adjacency(text, 3, 5),
924            ExclusionAdjacency::Overlapping
925        );
926        // "b" at [10, 11): a real word ("is") lies between it and the skip → none.
927        assert_eq!(
928            range.exclusion_adjacency(text, 10, 11),
929            ExclusionAdjacency::None
930        );
931    }
932
933    #[test]
934    fn test_exclusion_adjacency_detects_glued_fragment() {
935        // "#{n}th word" — skip #{n} is [0, 4); "th" is glued to it at [4, 6).
936        let text = "#{n}th word";
937        let range = ProseRange {
938            start_byte: 0,
939            end_byte: text.len(),
940            exclusions: vec![(0, 4)],
941            language: None,
942        };
943        assert_eq!(
944            range.exclusion_adjacency(text, 4, 6),
945            ExclusionAdjacency::Glued
946        );
947    }
948
949    #[test]
950    fn test_exclusion_swallowing_flanking_space_is_not_glued() {
951        // Inline-math delimiter exclusions can include the flanking space, so the
952        // skip range starts at the space (byte 3), not at `#`. A real word ending
953        // exactly where the exclusion begins must still read as whitespace-
954        // separated, not glued.  Regression for spelling typos beside #{X}.
955        let text = "teh #{G} ok"; // exclusion ` #{` = bytes [3, 6)
956        let range = ProseRange {
957            start_byte: 0,
958            end_byte: text.len(),
959            exclusions: vec![(3, 6)],
960            language: None,
961        };
962        assert_eq!(
963            range.exclusion_adjacency(text, 0, 3),
964            ExclusionAdjacency::WhitespaceAdjacent
965        );
966        // A genuine typo here is kept; only the grammar/structure noise is dropped.
967        assert!(!range.suppresses_diagnostic(text, 0, 3, "spelling.typo"));
968        assert!(range.suppresses_diagnostic(text, 0, 3, "typography.capitalization"));
969    }
970
971    #[test]
972    fn test_suppresses_diagnostic_keeps_spelling_near_skip() {
973        // "a #{i} wrd b" — skip at [2, 6); the misspelling "wrd" is at [7, 10),
974        // whitespace-adjacent to the skip.
975        let text = "a #{i} wrd b";
976        let range = ProseRange {
977            start_byte: 0,
978            end_byte: text.len(),
979            exclusions: vec![(2, 6)],
980            language: None,
981        };
982        // Grammar/typography noise flanking the cut is suppressed...
983        assert!(range.suppresses_diagnostic(text, 7, 10, "typography.capitalization"));
984        // ...but a genuine adjacent typo is kept.
985        assert!(!range.suppresses_diagnostic(text, 7, 10, "spelling.typo"));
986    }
987
988    #[test]
989    fn test_content_bracket_edge_is_not_glued() {
990        // "a #emph[wrd] b" — the merged exclusion ` #emph[` is [1, 8), so the
991        // word "wrd" starts exactly where it ends. The skip's last char is `[`,
992        // a group delimiter, so "wrd" is a whole word and not a fragment.
993        let text = "a #emph[wrd] b";
994        let range = ProseRange {
995            start_byte: 0,
996            end_byte: text.len(),
997            exclusions: vec![(1, 8), (11, 13)],
998            language: None,
999        };
1000        assert_eq!(
1001            range.exclusion_adjacency(text, 8, 11),
1002            ExclusionAdjacency::WhitespaceAdjacent
1003        );
1004        // First and last word of the content block both keep their typos.
1005        assert!(!range.suppresses_diagnostic(text, 8, 11, "spelling.typo"));
1006    }
1007
1008    #[test]
1009    fn test_math_delimiter_edge_is_still_glued() {
1010        // The bracket exception must not reach `$`: blanking `$k$` out of
1011        // "$k$th" leaves "th", which really is a fragment.
1012        let text = "$k$th word";
1013        let range = ProseRange {
1014            start_byte: 0,
1015            end_byte: text.len(),
1016            exclusions: vec![(0, 3)],
1017            language: None,
1018        };
1019        assert_eq!(
1020            range.exclusion_adjacency(text, 3, 5),
1021            ExclusionAdjacency::Glued
1022        );
1023        assert!(range.suppresses_diagnostic(text, 3, 5, "spelling.typo"));
1024    }
1025
1026    #[test]
1027    fn test_suppresses_diagnostic_drops_glued_fragment_spelling() {
1028        // "#{n}th word" — "th" is a fragment created by cutting the skip, so even
1029        // a spelling diagnostic on it is suppressed.
1030        let text = "#{n}th word";
1031        let range = ProseRange {
1032            start_byte: 0,
1033            end_byte: text.len(),
1034            exclusions: vec![(0, 4)],
1035            language: None,
1036        };
1037        assert!(range.suppresses_diagnostic(text, 4, 6, "spelling.typo"));
1038        // A real word with text between it and the skip is untouched.
1039        assert!(!range.suppresses_diagnostic(text, 7, 11, "spelling.typo"));
1040    }
1041
1042    #[test]
1043    fn type_override_latex_in_markdown() -> Result<()> {
1044        let text = "\
1045# Title
1046
1047Some intro text.
1048
1049<!-- lang-check-begin type:latex -->
1050\\emph{Hello} world and \\textbf{bold} text.
1051<!-- lang-check-end -->
1052
1053Final paragraph.";
1054
1055        let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
1056
1057        let texts: Vec<&str> = ranges
1058            .iter()
1059            .map(|r| &text[r.start_byte..r.end_byte])
1060            .collect();
1061
1062        // Surrounding markdown prose is preserved.
1063        assert!(texts.iter().any(|t| t.contains("Title")));
1064        assert!(texts.iter().any(|t| t.contains("intro text")));
1065        assert!(texts.iter().any(|t| t.contains("Final paragraph")));
1066
1067        // The LaTeX region was re-extracted: the prose content from
1068        // \emph{Hello} and \textbf{bold} should appear in ranges.
1069        assert!(
1070            texts.iter().any(|t| t.contains("Hello")),
1071            "expected LaTeX extractor to produce range containing 'Hello', got: {texts:?}"
1072        );
1073
1074        Ok(())
1075    }
1076
1077    #[test]
1078    fn type_override_unknown_skipped() -> Result<()> {
1079        let text = "\
1080# Title
1081
1082<!-- lang-check-begin type:foobar -->
1083Some content here.
1084<!-- lang-check-end -->
1085
1086Trailing text.";
1087
1088        let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
1089
1090        let texts: Vec<&str> = ranges
1091            .iter()
1092            .map(|r| &text[r.start_byte..r.end_byte])
1093            .collect();
1094
1095        // Surrounding ranges preserved.
1096        assert!(texts.iter().any(|t| t.contains("Title")));
1097        assert!(texts.iter().any(|t| t.contains("Trailing text")));
1098
1099        // The unknown-type region's base ranges were filtered out, and no
1100        // re-extraction happened, so "Some content" should be absent.
1101        assert!(
1102            !texts.iter().any(|t| t.contains("Some content")),
1103            "expected unknown type region to be skipped, got: {texts:?}"
1104        );
1105
1106        Ok(())
1107    }
1108
1109    #[test]
1110    fn type_override_preserves_surrounding() -> Result<()> {
1111        let text = "\
1112First paragraph before.
1113
1114<!-- lang-check-begin type:latex -->
1115\\section{Test}
1116Some LaTeX prose.
1117<!-- lang-check-end -->
1118
1119Last paragraph after.";
1120
1121        let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
1122
1123        let texts: Vec<&str> = ranges
1124            .iter()
1125            .map(|r| &text[r.start_byte..r.end_byte])
1126            .collect();
1127
1128        // Both surrounding paragraphs must be present and unmodified.
1129        assert!(
1130            texts.iter().any(|t| t.contains("First paragraph before")),
1131            "pre-region range missing: {texts:?}"
1132        );
1133        assert!(
1134            texts.iter().any(|t| t.contains("Last paragraph after")),
1135            "post-region range missing: {texts:?}"
1136        );
1137
1138        Ok(())
1139    }
1140
1141    #[test]
1142    fn strip_unmatched_orphan_close() {
1143        let mut bytes = b"hello } world".to_vec();
1144        strip_unmatched_brackets(&mut bytes);
1145        assert_eq!(&bytes, b"hello   world");
1146    }
1147
1148    #[test]
1149    fn strip_unmatched_orphan_open() {
1150        let mut bytes = b"hello ( world".to_vec();
1151        strip_unmatched_brackets(&mut bytes);
1152        assert_eq!(&bytes, b"hello   world");
1153    }
1154
1155    #[test]
1156    fn strip_unmatched_preserves_matched() {
1157        let mut bytes = b"f(x) and [y]".to_vec();
1158        strip_unmatched_brackets(&mut bytes);
1159        assert_eq!(&bytes, b"f(x) and [y]");
1160    }
1161
1162    #[test]
1163    fn strip_unmatched_mixed() {
1164        // '}' is unmatched, '(x)' is matched
1165        let mut bytes = b"value } is f(x)".to_vec();
1166        strip_unmatched_brackets(&mut bytes);
1167        assert_eq!(&bytes, b"value   is f(x)");
1168    }
1169
1170    #[test]
1171    fn strip_unmatched_via_extract_text() {
1172        let range = ProseRange {
1173            start_byte: 0,
1174            end_byte: 20,
1175            exclusions: vec![(5, 10)],
1176            language: None,
1177        };
1178        // "text } rest" after blanking exclusion [5,10) -> "text      rest"
1179        // but if original is "text #{x+y} rest", after blanking the #{x+y}
1180        // region we get "text        rest" with no unmatched brackets.
1181        let text = "text #{x+y} rest____";
1182        let clean = range.extract_text(text);
1183        // The #{x+y} was blanked, no unmatched brackets remain
1184        assert!(!clean.contains('#'));
1185        assert!(!clean.contains('{'));
1186        assert!(!clean.contains('}'));
1187    }
1188
1189    /// `(prose, resolved language)` for every range, through the whole
1190    /// extraction path so the language sources are exercised in the order they
1191    /// actually resolve.
1192    fn languages_of(text: &str, lang_id: &str, default_language: &str) -> Vec<(String, String)> {
1193        let ranges =
1194            extract_with_fallback(text, lang_id, None, None, &latex::LatexExtras::default())
1195                .expect("extraction");
1196        range_units(&ranges, text, default_language)
1197            .into_iter()
1198            .map(|unit| (unit.text.trim().to_string(), unit.language))
1199            .collect()
1200    }
1201
1202    #[test]
1203    fn a_scope_marker_runs_until_the_next_one() {
1204        let text = "English here.\n\n<!-- lang: fr -->\n\nDu francais ici.\n\n                    <!-- lang: en-GB -->\n\nEnglish again.\n";
1205        let tagged: Vec<String> = languages_of(text, "markdown", "en-US")
1206            .into_iter()
1207            .map(|(_, lang)| lang)
1208            .collect();
1209        assert_eq!(tagged, vec!["en-US", "fr", "en-GB"]);
1210    }
1211
1212    #[test]
1213    fn a_begin_directive_beats_a_scope_marker() {
1214        let text = "<!-- lang: fr -->\n\nDu francais ici.\n\n                    <!-- lang-check-begin lang:de -->\nEin deutscher Satz.\n                    <!-- lang-check-end -->\n";
1215        let languages = languages_of(text, "markdown", "en-US");
1216        assert_eq!(languages[0].1, "fr");
1217        assert_eq!(
1218            languages[1].1, "de-DE",
1219            "the directive wins, and `de` resolves to a variant"
1220        );
1221    }
1222
1223    #[test]
1224    fn prose_before_the_first_marker_takes_the_configured_language() {
1225        let text = "Before any marker.\n\n<!-- lang: fr -->\n\nApres.\n";
1226        assert_eq!(languages_of(text, "markdown", "en-GB")[0].1, "en-GB");
1227    }
1228}