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