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 Ok(shared::merge_continuations(ranges, text, &force_regions))
67 }
68}
69
70pub 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#[derive(Debug, Clone)]
88pub struct Extraction {
89 pub ranges: Vec<ProseRange>,
90 pub syntax: String,
95}
96
97pub 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
115pub 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 Ok(Extraction {
156 ranges: shared::split_oversized(ranges, text, max_range_bytes),
157 syntax: canonical_lang.to_string(),
158 })
159}
160
161fn 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(®ion.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
198fn 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 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 pub exclusions: Vec<(usize, usize)>,
264 pub language: Option<String>,
269}
270
271impl ProseRange {
272 #[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 #[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 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 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 #[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 #[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 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; }
364 }
365 best
366 }
367
368 #[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 #[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#[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#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct ProseUnit {
424 pub text: String,
425 pub language: String,
428}
429
430#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
447pub enum ExclusionAdjacency {
448 Overlapping,
450 Glued,
452 WhitespaceAdjacent,
454 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 #[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
479fn 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
501const fn separates_words(c: char) -> bool {
518 c.is_whitespace() || matches!(c, '[' | ']' | '_' | '*' | '`')
519}
520
521fn 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
528fn 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#[must_use]
537pub fn is_spelling_category(unified_id: &str) -> bool {
538 unified_id.starts_with("spelling.")
539}
540
541fn 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
579fn is_word_byte(bytes: &[u8], i: usize) -> bool {
587 if i >= bytes.len() {
588 return false;
589 }
590 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
601fn reseat_quotes_across_blanks(bytes: &mut [u8], blanked: &[(usize, usize)]) {
622 for &(start, end) in blanked {
623 if start >= end {
624 continue;
625 }
626 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 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 #[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 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 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 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 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 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 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 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 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 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)); }
905
906 #[test]
907 fn test_exclusion_adjacency_classifies_position() {
908 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 assert_eq!(
918 range.exclusion_adjacency(text, 7, 9),
919 ExclusionAdjacency::WhitespaceAdjacent
920 );
921 assert_eq!(
923 range.exclusion_adjacency(text, 3, 5),
924 ExclusionAdjacency::Overlapping
925 );
926 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 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 let text = "teh #{G} ok"; 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 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 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 assert!(range.suppresses_diagnostic(text, 7, 10, "typography.capitalization"));
984 assert!(!range.suppresses_diagnostic(text, 7, 10, "spelling.typo"));
986 }
987
988 #[test]
989 fn test_content_bracket_edge_is_not_glued() {
990 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 assert!(!range.suppresses_diagnostic(text, 8, 11, "spelling.typo"));
1006 }
1007
1008 #[test]
1009 fn test_math_delimiter_edge_is_still_glued() {
1010 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 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 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 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 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 assert!(texts.iter().any(|t| t.contains("Title")));
1097 assert!(texts.iter().any(|t| t.contains("Trailing text")));
1098
1099 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 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 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 let text = "text #{x+y} rest____";
1182 let clean = range.extract_text(text);
1183 assert!(!clean.contains('#'));
1185 assert!(!clean.contains('{'));
1186 assert!(!clean.contains('}'));
1187 }
1188
1189 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}