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 let force_regions = crate::ignore_rules::IgnoreParser::block_regions(text);
66 let mut ranges = shared::merge_continuations(ranges, text, &force_regions);
67 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
78pub 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#[derive(Debug, Clone)]
96pub struct Extraction {
97 pub ranges: Vec<ProseRange>,
98 pub syntax: String,
103}
104
105pub 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
123pub 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 for range in &mut ranges {
169 shared::trim_leading_blanks(range, text);
170 }
171 ranges.retain(|range| range.start_byte < range.end_byte);
172 Ok(Extraction {
175 ranges: shared::split_oversized(ranges, text, max_range_bytes),
176 syntax: canonical_lang.to_string(),
177 })
178}
179
180fn 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(®ion.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
229fn 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 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 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
278fn 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 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 pub exclusions: Vec<(usize, usize)>,
344 pub language: Option<String>,
349 pub language_span: Option<(usize, usize)>,
360}
361
362impl ProseRange {
363 #[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 #[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 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 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 #[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 #[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 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; }
455 }
456 best
457 }
458
459 #[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 #[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
514pub struct ProseUnit {
515 pub text: String,
516 pub language: String,
519}
520
521#[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
536pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
563pub enum ExclusionAdjacency {
564 Overlapping,
566 Glued,
568 WhitespaceAdjacent,
570 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 #[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
595fn 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
617const fn separates_words(c: char) -> bool {
634 c.is_whitespace() || matches!(c, '[' | ']' | '_' | '*' | '`')
635}
636
637fn 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
644fn 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#[must_use]
653pub fn is_spelling_category(unified_id: &str) -> bool {
654 unified_id.starts_with("spelling.")
655}
656
657fn 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
695fn is_word_byte(bytes: &[u8], i: usize) -> bool {
703 if i >= bytes.len() {
704 return false;
705 }
706 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
717fn reseat_quotes_across_blanks(bytes: &mut [u8], blanked: &[(usize, usize)]) {
738 for &(start, end) in blanked {
739 if start >= end {
740 continue;
741 }
742 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 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 #[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 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 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 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 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 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 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 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 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 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 assert!(range.overlaps_exclusion(50, 100)); assert!(range.overlaps_exclusion(40, 60)); assert!(range.overlaps_exclusion(90, 110)); assert!(!range.overlaps_exclusion(0, 40)); assert!(!range.overlaps_exclusion(110, 130)); }
1069
1070 #[test]
1071 fn test_exclusion_adjacency_classifies_position() {
1072 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 assert_eq!(
1083 range.exclusion_adjacency(text, 7, 9),
1084 ExclusionAdjacency::WhitespaceAdjacent
1085 );
1086 assert_eq!(
1088 range.exclusion_adjacency(text, 3, 5),
1089 ExclusionAdjacency::Overlapping
1090 );
1091 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 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 let text = "teh #{G} ok"; 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 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 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 assert!(range.suppresses_diagnostic(text, 7, 10, "typography.capitalization"));
1152 assert!(!range.suppresses_diagnostic(text, 7, 10, "spelling.typo"));
1154 }
1155
1156 #[test]
1157 fn test_content_bracket_edge_is_not_glued() {
1158 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 assert!(!range.suppresses_diagnostic(text, 8, 11, "spelling.typo"));
1175 }
1176
1177 #[test]
1178 fn test_math_delimiter_edge_is_still_glued() {
1179 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 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 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 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 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 assert!(texts.iter().any(|t| t.contains("Title")));
1268 assert!(texts.iter().any(|t| t.contains("Trailing text")));
1269
1270 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 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 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 let text = "text #{x+y} rest____";
1354 let clean = range.extract_text(text);
1355 assert!(!clean.contains('#'));
1357 assert!(!clean.contains('{'));
1358 assert!(!clean.contains('}'));
1359 }
1360
1361 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}