1mod bibtex;
2mod forester;
3pub mod latex;
4mod org;
5mod query;
6mod rst;
7mod shared;
8mod sweave;
9mod tinylang;
10mod typst;
11
12use anyhow::{Result, anyhow};
13use std::ops::Range;
14use std::path::Path;
15use tree_sitter::{Language, Parser};
16
17use crate::ignore_rules::{DirectiveRegion, IgnoreParser};
18
19use crate::sls::SchemaRegistry;
20
21pub struct ProseExtractor {
22 parser: Parser,
23 language: Language,
24}
25
26impl ProseExtractor {
27 pub fn new(language: Language) -> Result<Self> {
28 let mut parser = Parser::new();
29 parser.set_language(&language)?;
30 Ok(Self { parser, language })
31 }
32
33 pub fn extract(
34 &mut self,
35 text: &str,
36 lang_id: &str,
37 latex_extras: &latex::LatexExtras,
38 ) -> Result<Vec<ProseRange>> {
39 let tree = self
40 .parser
41 .parse(text, None)
42 .ok_or_else(|| anyhow!("Failed to parse text"))?;
43
44 let root = tree.root_node();
45
46 let ranges = match lang_id {
47 "latex" => latex::extract(text, root, latex_extras),
48 "sweave" => sweave::extract(text, root, latex_extras),
49 "forester" => forester::extract(text, root),
50 "tinylang" => tinylang::extract(text, root),
51 "rst" => rst::extract(text, root),
52 "bibtex" => bibtex::extract(text, root),
53 "org" => org::extract(text, root),
54 "typst" => typst::extract(text, root),
55 lang => query::extract(text, root, &self.language, lang)?,
56 };
57
58 let force_regions = crate::ignore_rules::IgnoreParser::block_regions(text);
62 Ok(shared::merge_continuations(ranges, text, &force_regions))
63 }
64}
65
66pub fn extract_with_fallback(
72 text: &str,
73 lang_id: &str,
74 path: Option<&Path>,
75 schema_registry: Option<&SchemaRegistry>,
76 latex_extras: &latex::LatexExtras,
77) -> Result<Vec<ProseRange>> {
78 if let Some(ext) = path
79 .and_then(|value| value.extension())
80 .and_then(|value| value.to_str())
81 && crate::languages::builtin_language_for_extension(ext).is_none()
82 && let Some(schema) = schema_registry.and_then(|registry| registry.find_by_extension(ext))
83 {
84 return Ok(schema.extract(text));
85 }
86
87 let canonical_lang = crate::languages::resolve_language_id(lang_id);
88 let language = crate::languages::resolve_ts_language(canonical_lang);
89 let mut extractor = ProseExtractor::new(language)?;
90 let mut ranges = extractor.extract(text, canonical_lang, latex_extras)?;
91
92 let directives = IgnoreParser::parse_directives(text);
93 let resolved = IgnoreParser::resolve_all(text, &directives);
94 let type_regions: Vec<_> = resolved
95 .regions
96 .iter()
97 .filter(|r| r.options.doc_type.is_some())
98 .collect();
99 if !type_regions.is_empty() {
100 ranges = apply_type_overrides(text, ranges, &type_regions, latex_extras)?;
101 }
102
103 Ok(ranges)
104}
105
106fn apply_type_overrides(
113 text: &str,
114 base_ranges: Vec<ProseRange>,
115 type_regions: &[&DirectiveRegion],
116 latex_extras: &latex::LatexExtras,
117) -> Result<Vec<ProseRange>> {
118 let override_spans: Vec<&Range<usize>> = type_regions.iter().map(|r| &r.byte_range).collect();
119
120 let mut result: Vec<ProseRange> = base_ranges
122 .into_iter()
123 .filter(|r| {
124 !override_spans
125 .iter()
126 .any(|span| span.contains(&r.start_byte))
127 })
128 .collect();
129
130 for region in type_regions {
131 let doc_type = region.options.doc_type.as_deref().unwrap();
132 let canonical = crate::languages::resolve_language_id(doc_type);
133
134 if !crate::languages::SUPPORTED_LANGUAGE_IDS.contains(&canonical) {
135 eprintln!("lang-check: `type:{doc_type}` is not a supported language; skipping region");
136 continue;
137 }
138
139 let slice = &text[region.byte_range.clone()];
140 let ts_lang = crate::languages::resolve_ts_language(canonical);
141 let mut ext = ProseExtractor::new(ts_lang)?;
142 let sub_ranges = ext.extract(slice, canonical, latex_extras)?;
143
144 let offset = region.byte_range.start;
145 for mut r in sub_ranges {
146 r.start_byte += offset;
147 r.end_byte += offset;
148 r.exclusions = r
149 .exclusions
150 .into_iter()
151 .map(|(s, e)| (s + offset, e + offset))
152 .collect();
153 result.push(r);
154 }
155 }
156
157 result.sort_by_key(|r| r.start_byte);
158 Ok(result)
159}
160
161#[derive(Debug, Clone, PartialEq, Eq)]
162pub struct ProseRange {
163 pub start_byte: usize,
164 pub end_byte: usize,
165 pub exclusions: Vec<(usize, usize)>,
169}
170
171impl ProseRange {
172 #[must_use]
175 pub fn extract_text<'a>(&self, text: &'a str) -> std::borrow::Cow<'a, str> {
176 let slice = &text[self.start_byte..self.end_byte];
177 if self.exclusions.is_empty() {
178 return std::borrow::Cow::Borrowed(slice);
179 }
180 #[cfg(debug_assertions)]
187 for &(exc_start, exc_end) in &self.exclusions {
188 let s = exc_start.saturating_sub(self.start_byte).min(slice.len());
189 let e = exc_end.saturating_sub(self.start_byte).min(slice.len());
190 debug_assert!(
191 slice.is_char_boundary(s) && slice.is_char_boundary(e),
192 "exclusion ({s}, {e}) is not on a char boundary in {slice:?}"
193 );
194 }
195
196 let mut buf = slice.to_string();
197 let bytes = unsafe { buf.as_bytes_mut() };
200 for &(exc_start, exc_end) in &self.exclusions {
201 let local_start = exc_start.saturating_sub(self.start_byte).min(bytes.len());
204 let local_end = exc_end.saturating_sub(self.start_byte).min(bytes.len());
205 if local_start < local_end {
206 bytes[local_start..local_end].fill(b' ');
207 }
208 }
209 strip_unmatched_brackets(bytes);
210 std::borrow::Cow::Owned(buf)
211 }
212
213 #[must_use]
216 #[allow(clippy::cast_possible_truncation)]
217 pub fn overlaps_exclusion(&self, local_start: u32, local_end: u32) -> bool {
218 let doc_start = self.start_byte as u32 + local_start;
219 let doc_end = self.start_byte as u32 + local_end;
220 self.exclusions.iter().any(|&(exc_start, exc_end)| {
221 let es = exc_start as u32;
222 let ee = exc_end as u32;
223 doc_start < ee && doc_end > es
224 })
225 }
226
227 #[must_use]
233 pub fn exclusion_adjacency(
234 &self,
235 text: &str,
236 local_start: u32,
237 local_end: u32,
238 ) -> ExclusionAdjacency {
239 if self.overlaps_exclusion(local_start, local_end) {
240 return ExclusionAdjacency::Overlapping;
241 }
242 let doc_start = self.start_byte + local_start as usize;
243 let doc_end = self.start_byte + local_end as usize;
244 let mut best = ExclusionAdjacency::None;
245 for &(es, ee) in &self.exclusions {
246 let rel = if doc_start >= ee {
253 classify_gap(text, ee, doc_start, byte_before_is_whitespace(text, ee))
254 } else {
255 classify_gap(text, doc_end, es, byte_at_is_whitespace(text, es))
256 };
257 best = best.max_severity(rel);
258 if best == ExclusionAdjacency::Glued {
259 break; }
261 }
262 best
263 }
264
265 #[must_use]
275 pub fn suppresses_diagnostic(
276 &self,
277 text: &str,
278 local_start: u32,
279 local_end: u32,
280 unified_id: &str,
281 ) -> bool {
282 match self.exclusion_adjacency(text, local_start, local_end) {
283 ExclusionAdjacency::Overlapping | ExclusionAdjacency::Glued => true,
284 ExclusionAdjacency::WhitespaceAdjacent => !is_spelling_category(unified_id),
285 ExclusionAdjacency::None => false,
286 }
287 }
288}
289
290#[derive(Debug, Clone, Copy, PartialEq, Eq)]
292pub enum ExclusionAdjacency {
293 Overlapping,
295 Glued,
297 WhitespaceAdjacent,
299 None,
301}
302
303impl ExclusionAdjacency {
304 const fn rank(self) -> u8 {
305 match self {
306 Self::None => 0,
307 Self::WhitespaceAdjacent => 1,
308 Self::Glued => 2,
309 Self::Overlapping => 3,
310 }
311 }
312
313 #[must_use]
315 const fn max_severity(self, other: Self) -> Self {
316 if other.rank() > self.rank() {
317 other
318 } else {
319 self
320 }
321 }
322}
323
324fn classify_gap(
333 text: &str,
334 lo: usize,
335 hi: usize,
336 skip_edge_is_whitespace: bool,
337) -> ExclusionAdjacency {
338 if lo == hi {
339 return if skip_edge_is_whitespace {
340 ExclusionAdjacency::WhitespaceAdjacent
341 } else {
342 ExclusionAdjacency::Glued
343 };
344 }
345 match text.get(lo..hi) {
346 Some(gap) if gap.chars().all(char::is_whitespace) => ExclusionAdjacency::WhitespaceAdjacent,
347 _ => ExclusionAdjacency::None,
348 }
349}
350
351fn byte_before_is_whitespace(text: &str, pos: usize) -> bool {
353 text.get(..pos)
354 .and_then(|s| s.chars().next_back())
355 .is_some_and(char::is_whitespace)
356}
357
358fn byte_at_is_whitespace(text: &str, pos: usize) -> bool {
360 text.get(pos..)
361 .and_then(|s| s.chars().next())
362 .is_some_and(char::is_whitespace)
363}
364
365#[must_use]
367pub fn is_spelling_category(unified_id: &str) -> bool {
368 unified_id.starts_with("spelling.")
369}
370
371fn strip_unmatched_brackets(bytes: &mut [u8]) {
377 let mut paren_stack: Vec<usize> = Vec::new();
378 let mut bracket_stack: Vec<usize> = Vec::new();
379 let mut brace_stack: Vec<usize> = Vec::new();
380 let mut unmatched: Vec<usize> = Vec::new();
381
382 for (i, &b) in bytes.iter().enumerate() {
383 match b {
384 b'(' => paren_stack.push(i),
385 b')' if paren_stack.pop().is_none() => {
386 unmatched.push(i);
387 }
388 b'[' => bracket_stack.push(i),
389 b']' if bracket_stack.pop().is_none() => {
390 unmatched.push(i);
391 }
392 b'{' => brace_stack.push(i),
393 b'}' if brace_stack.pop().is_none() => {
394 unmatched.push(i);
395 }
396 _ => {}
397 }
398 }
399
400 unmatched.extend(paren_stack);
401 unmatched.extend(bracket_stack);
402 unmatched.extend(brace_stack);
403
404 for idx in unmatched {
405 bytes[idx] = b' ';
406 }
407}
408
409#[cfg(test)]
410mod tests {
411 use super::*;
412 use latex::LatexExtras;
413
414 #[test]
417 fn extract_text_no_exclusions_is_borrowed() {
418 let text = "café — touché";
419 let range = ProseRange {
420 start_byte: 0,
421 end_byte: text.len(),
422 exclusions: Vec::new(),
423 };
424 let out = range.extract_text(text);
425 assert!(matches!(out, std::borrow::Cow::Borrowed(_)));
426 assert_eq!(out, text);
427 }
428
429 #[test]
430 fn extract_text_blanks_excluded_ascii_keeping_multibyte() {
431 let text = "café X tea";
433 let x = text.find('X').unwrap();
434 let range = ProseRange {
435 start_byte: 0,
436 end_byte: text.len(),
437 exclusions: vec![(x, x + 1)],
438 };
439 let out = range.extract_text(text);
440 assert_eq!(out, "café tea");
441 assert!(std::str::from_utf8(out.as_bytes()).is_ok());
442 }
443
444 #[test]
445 fn extract_text_blanks_a_whole_multibyte_char() {
446 let text = "a—b";
448 let dash_start = text.find('—').unwrap();
449 let dash_end = dash_start + '—'.len_utf8();
450 let range = ProseRange {
451 start_byte: 0,
452 end_byte: text.len(),
453 exclusions: vec![(dash_start, dash_end)],
454 };
455 let out = range.extract_text(text);
456 assert_eq!(out, "a b");
457 }
458
459 #[test]
460 fn extract_text_handles_document_level_offsets() {
461 let text = "PREFIX café — done";
463 let start = text.find("café").unwrap();
464 let dash = text.find('—').unwrap();
465 let range = ProseRange {
466 start_byte: start,
467 end_byte: text.len(),
468 exclusions: vec![(dash, dash + '—'.len_utf8())],
469 };
470 let out = range.extract_text(text);
472 assert_eq!(out, "café done");
473 }
474
475 #[test]
476 fn test_markdown_extraction() -> Result<()> {
477 let language: tree_sitter::Language = tree_sitter_md::LANGUAGE.into();
478 let mut extractor = ProseExtractor::new(language)?;
479
480 let text =
481 "# Header\n\nThis is a paragraph.\n\n```rust\nfn main() {}\n```\n\nAnother paragraph.";
482 let ranges = extractor.extract(text, "markdown", &LatexExtras::default())?;
483
484 assert!(ranges.len() >= 3);
485
486 let extracted_texts: Vec<&str> = ranges
487 .iter()
488 .map(|r| &text[r.start_byte..r.end_byte])
489 .collect();
490 assert!(extracted_texts.iter().any(|t| t.contains("Header")));
491 assert!(
492 extracted_texts
493 .iter()
494 .any(|t| t.contains("This is a paragraph"))
495 );
496 assert!(
497 extracted_texts
498 .iter()
499 .any(|t| t.contains("Another paragraph"))
500 );
501
502 Ok(())
503 }
504
505 #[test]
506 fn test_overlaps_exclusion() {
507 let range = ProseRange {
508 start_byte: 100,
509 end_byte: 300,
510 exclusions: vec![(150, 200)],
511 };
512
513 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)); }
522
523 #[test]
524 fn test_exclusion_adjacency_classifies_position() {
525 let text = "a #{i} is b";
527 let range = ProseRange {
528 start_byte: 0,
529 end_byte: text.len(),
530 exclusions: vec![(2, 6)],
531 };
532 assert_eq!(
534 range.exclusion_adjacency(text, 7, 9),
535 ExclusionAdjacency::WhitespaceAdjacent
536 );
537 assert_eq!(
539 range.exclusion_adjacency(text, 3, 5),
540 ExclusionAdjacency::Overlapping
541 );
542 assert_eq!(
544 range.exclusion_adjacency(text, 10, 11),
545 ExclusionAdjacency::None
546 );
547 }
548
549 #[test]
550 fn test_exclusion_adjacency_detects_glued_fragment() {
551 let text = "#{n}th word";
553 let range = ProseRange {
554 start_byte: 0,
555 end_byte: text.len(),
556 exclusions: vec![(0, 4)],
557 };
558 assert_eq!(
559 range.exclusion_adjacency(text, 4, 6),
560 ExclusionAdjacency::Glued
561 );
562 }
563
564 #[test]
565 fn test_exclusion_swallowing_flanking_space_is_not_glued() {
566 let text = "teh #{G} ok"; let range = ProseRange {
572 start_byte: 0,
573 end_byte: text.len(),
574 exclusions: vec![(3, 6)],
575 };
576 assert_eq!(
577 range.exclusion_adjacency(text, 0, 3),
578 ExclusionAdjacency::WhitespaceAdjacent
579 );
580 assert!(!range.suppresses_diagnostic(text, 0, 3, "spelling.typo"));
582 assert!(range.suppresses_diagnostic(text, 0, 3, "typography.capitalization"));
583 }
584
585 #[test]
586 fn test_suppresses_diagnostic_keeps_spelling_near_skip() {
587 let text = "a #{i} wrd b";
590 let range = ProseRange {
591 start_byte: 0,
592 end_byte: text.len(),
593 exclusions: vec![(2, 6)],
594 };
595 assert!(range.suppresses_diagnostic(text, 7, 10, "typography.capitalization"));
597 assert!(!range.suppresses_diagnostic(text, 7, 10, "spelling.typo"));
599 }
600
601 #[test]
602 fn test_suppresses_diagnostic_drops_glued_fragment_spelling() {
603 let text = "#{n}th word";
606 let range = ProseRange {
607 start_byte: 0,
608 end_byte: text.len(),
609 exclusions: vec![(0, 4)],
610 };
611 assert!(range.suppresses_diagnostic(text, 4, 6, "spelling.typo"));
612 assert!(!range.suppresses_diagnostic(text, 7, 11, "spelling.typo"));
614 }
615
616 #[test]
617 fn type_override_latex_in_markdown() -> Result<()> {
618 let text = "\
619# Title
620
621Some intro text.
622
623<!-- lang-check-begin type:latex -->
624\\emph{Hello} world and \\textbf{bold} text.
625<!-- lang-check-end -->
626
627Final paragraph.";
628
629 let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
630
631 let texts: Vec<&str> = ranges
632 .iter()
633 .map(|r| &text[r.start_byte..r.end_byte])
634 .collect();
635
636 assert!(texts.iter().any(|t| t.contains("Title")));
638 assert!(texts.iter().any(|t| t.contains("intro text")));
639 assert!(texts.iter().any(|t| t.contains("Final paragraph")));
640
641 assert!(
644 texts.iter().any(|t| t.contains("Hello")),
645 "expected LaTeX extractor to produce range containing 'Hello', got: {texts:?}"
646 );
647
648 Ok(())
649 }
650
651 #[test]
652 fn type_override_unknown_skipped() -> Result<()> {
653 let text = "\
654# Title
655
656<!-- lang-check-begin type:foobar -->
657Some content here.
658<!-- lang-check-end -->
659
660Trailing text.";
661
662 let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
663
664 let texts: Vec<&str> = ranges
665 .iter()
666 .map(|r| &text[r.start_byte..r.end_byte])
667 .collect();
668
669 assert!(texts.iter().any(|t| t.contains("Title")));
671 assert!(texts.iter().any(|t| t.contains("Trailing text")));
672
673 assert!(
676 !texts.iter().any(|t| t.contains("Some content")),
677 "expected unknown type region to be skipped, got: {texts:?}"
678 );
679
680 Ok(())
681 }
682
683 #[test]
684 fn type_override_preserves_surrounding() -> Result<()> {
685 let text = "\
686First paragraph before.
687
688<!-- lang-check-begin type:latex -->
689\\section{Test}
690Some LaTeX prose.
691<!-- lang-check-end -->
692
693Last paragraph after.";
694
695 let ranges = extract_with_fallback(text, "markdown", None, None, &LatexExtras::default())?;
696
697 let texts: Vec<&str> = ranges
698 .iter()
699 .map(|r| &text[r.start_byte..r.end_byte])
700 .collect();
701
702 assert!(
704 texts.iter().any(|t| t.contains("First paragraph before")),
705 "pre-region range missing: {texts:?}"
706 );
707 assert!(
708 texts.iter().any(|t| t.contains("Last paragraph after")),
709 "post-region range missing: {texts:?}"
710 );
711
712 Ok(())
713 }
714
715 #[test]
716 fn strip_unmatched_orphan_close() {
717 let mut bytes = b"hello } world".to_vec();
718 strip_unmatched_brackets(&mut bytes);
719 assert_eq!(&bytes, b"hello world");
720 }
721
722 #[test]
723 fn strip_unmatched_orphan_open() {
724 let mut bytes = b"hello ( world".to_vec();
725 strip_unmatched_brackets(&mut bytes);
726 assert_eq!(&bytes, b"hello world");
727 }
728
729 #[test]
730 fn strip_unmatched_preserves_matched() {
731 let mut bytes = b"f(x) and [y]".to_vec();
732 strip_unmatched_brackets(&mut bytes);
733 assert_eq!(&bytes, b"f(x) and [y]");
734 }
735
736 #[test]
737 fn strip_unmatched_mixed() {
738 let mut bytes = b"value } is f(x)".to_vec();
740 strip_unmatched_brackets(&mut bytes);
741 assert_eq!(&bytes, b"value is f(x)");
742 }
743
744 #[test]
745 fn strip_unmatched_via_extract_text() {
746 let range = ProseRange {
747 start_byte: 0,
748 end_byte: 20,
749 exclusions: vec![(5, 10)],
750 };
751 let text = "text #{x+y} rest____";
755 let clean = range.extract_text(text);
756 assert!(!clean.contains('#'));
758 assert!(!clean.contains('{'));
759 assert!(!clean.contains('}'));
760 }
761}