Skip to main content

lang_check/
ignore_rules.rs

1use std::ops::Range;
2
3use crate::checker::Diagnostic;
4
5/// Type of ignore directive.
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub enum DirectiveKind {
8    /// Disable checking from this point until a matching Enable.
9    Disable,
10    /// Re-enable checking (closes the most recent Disable).
11    Enable,
12    /// Disable checking for the next non-comment line only.
13    DisableNextLine,
14    /// Begin a scoped region with options (language, type, line count, etc.).
15    Begin,
16    /// End the most recent scoped Begin region.
17    End,
18}
19
20/// Options for a `lang-check-begin` directive.
21#[derive(Debug, Clone, Default, PartialEq, Eq)]
22pub struct BeginOptions {
23    /// Only suppress these rule IDs; if empty, suppress all.
24    pub rule_ids: Vec<String>,
25    /// Override natural language for this region (e.g. "fr", "de").
26    pub language: Option<String>,
27    /// Re-parse region as this format (e.g. "latex"). Deferred implementation.
28    pub doc_type: Option<String>,
29    /// Scope applies to a slice of lines after the directive (no end directive needed).
30    /// `(start, end)` in 0-indexed line offsets, like Python slice notation `[start:end]`.
31    pub line_slice: Option<(usize, usize)>,
32    /// Only apply to lines matching this regex pattern.
33    pub match_pattern: Option<String>,
34    /// Skip lines matching this regex pattern.
35    pub exclude_pattern: Option<String>,
36    /// Force every prose block in this region to be checked as one continuous
37    /// block, overriding the continuation heuristic (e.g. to keep a paragraph
38    /// split across `\p{}`/math boundaries from being flagged as separate
39    /// sentences). Set by the `block` token on `lang-check-begin`.
40    pub block: bool,
41}
42
43/// A parsed inline ignore directive.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct IgnoreDirective {
46    /// The byte offset of the line containing this directive.
47    pub line_start: usize,
48    /// The byte offset of the end of the line containing this directive.
49    pub line_end: usize,
50    /// What kind of directive this is.
51    pub kind: DirectiveKind,
52    /// If set, only suppress the specified rule IDs (unified or native).
53    /// If empty, suppress all rules. Used by Disable/DisableNextLine.
54    pub rule_ids: Vec<String>,
55    /// Options for Begin directives; `None` for all other kinds.
56    pub options: Option<BeginOptions>,
57}
58
59/// A resolved byte range that should be ignored during checking.
60#[derive(Debug, Clone, PartialEq, Eq)]
61pub struct IgnoreRange {
62    /// Byte range to ignore.
63    pub byte_range: Range<usize>,
64    /// If set, only ignore diagnostics matching these rule IDs.
65    pub rule_ids: Vec<String>,
66}
67
68/// A resolved scoped region from `lang-check-begin` / `lang-check-end`.
69#[derive(Debug, Clone, PartialEq, Eq)]
70pub struct DirectiveRegion {
71    /// Byte range this region covers.
72    pub byte_range: Range<usize>,
73    /// Options carried from the `Begin` directive.
74    pub options: BeginOptions,
75}
76
77/// The full result of resolving all directives: legacy ignore ranges + scoped regions.
78#[derive(Debug, Clone, Default)]
79pub struct ResolvedDirectives {
80    /// Legacy disable/enable and disable-next-line ranges.
81    pub ignore_ranges: Vec<IgnoreRange>,
82    /// Scoped begin/end regions (may carry language overrides, regex filters, etc.).
83    pub regions: Vec<DirectiveRegion>,
84}
85
86/// Parses `lang-check-disable` / `lang-check-enable` / `lang-check-disable-next-line`
87/// and `lang-check-begin` / `lang-check-end` directives from document text.
88pub struct IgnoreParser;
89
90impl IgnoreParser {
91    /// Parse all ignore directives from the given text.
92    #[must_use]
93    pub fn parse_directives(text: &str) -> Vec<IgnoreDirective> {
94        let mut directives = Vec::new();
95
96        for (line_start, line) in line_byte_offsets(text) {
97            let line_end = line_start + line.len();
98
99            if let Some((kind, rule_ids, options)) = Self::extract_directive(line) {
100                directives.push(IgnoreDirective {
101                    line_start,
102                    line_end,
103                    kind,
104                    rule_ids,
105                    options,
106                });
107            }
108        }
109
110        directives
111    }
112
113    /// Resolve parsed directives into concrete byte ranges that should be ignored.
114    #[must_use]
115    pub fn resolve(text: &str, directives: &[IgnoreDirective]) -> Vec<IgnoreRange> {
116        let mut ranges = Vec::new();
117
118        // Track open disable directives (stack for nesting)
119        let mut open_disables: Vec<&IgnoreDirective> = Vec::new();
120
121        for directive in directives {
122            match &directive.kind {
123                DirectiveKind::Disable => {
124                    open_disables.push(directive);
125                }
126                DirectiveKind::Enable => {
127                    if let Some(disable) = open_disables.pop() {
128                        // The ignored range starts after the disable directive line,
129                        // and ends at the start of the enable directive line.
130                        let start = next_line_start(text, disable.line_end);
131                        ranges.push(IgnoreRange {
132                            byte_range: start..directive.line_start,
133                            rule_ids: disable.rule_ids.clone(),
134                        });
135                    }
136                }
137                DirectiveKind::DisableNextLine => {
138                    // Find the next non-empty, non-directive line after this one
139                    let start = next_line_start(text, directive.line_end);
140                    if start < text.len() {
141                        let end = line_end_at(text, start);
142                        ranges.push(IgnoreRange {
143                            byte_range: start..end,
144                            rule_ids: directive.rule_ids.clone(),
145                        });
146                    }
147                }
148                // Begin/End are handled by resolve_regions(); skip here.
149                DirectiveKind::Begin | DirectiveKind::End => {}
150            }
151        }
152
153        // Any unclosed disable directives extend to EOF
154        for disable in open_disables {
155            let start = next_line_start(text, disable.line_end);
156            if start < text.len() {
157                ranges.push(IgnoreRange {
158                    byte_range: start..text.len(),
159                    rule_ids: disable.rule_ids.clone(),
160                });
161            }
162        }
163
164        ranges
165    }
166
167    /// Check whether a diagnostic should be suppressed by any of the ignore ranges.
168    #[must_use]
169    pub fn should_ignore(diagnostic: &Diagnostic, ranges: &[IgnoreRange]) -> bool {
170        let d_start = diagnostic.start_byte as usize;
171
172        for range in ranges {
173            if range.byte_range.contains(&d_start) {
174                // If no specific rules, ignore everything
175                if range.rule_ids.is_empty() {
176                    return true;
177                }
178                // Check if the diagnostic's rule matches
179                if range
180                    .rule_ids
181                    .iter()
182                    .any(|r| r == &diagnostic.unified_id || r == &diagnostic.rule_id)
183                {
184                    return true;
185                }
186            }
187        }
188
189        false
190    }
191
192    /// Parse all directives and resolve to ranges in one step.
193    #[must_use]
194    pub fn parse(text: &str) -> Vec<IgnoreRange> {
195        let directives = Self::parse_directives(text);
196        Self::resolve(text, &directives)
197    }
198
199    /// Resolve all directives into both legacy ignore ranges and scoped regions.
200    #[must_use]
201    pub fn resolve_all(text: &str, directives: &[IgnoreDirective]) -> ResolvedDirectives {
202        let ignore_ranges = Self::resolve(text, directives);
203        let regions = Self::resolve_regions(text, directives);
204        ResolvedDirectives {
205            ignore_ranges,
206            regions,
207        }
208    }
209
210    /// Resolve `Begin`/`End` directives into `DirectiveRegion` entries.
211    /// Byte ranges of `lang-check-begin block` … `lang-check-end` regions,
212    /// within which prose blocks are force-merged for continuation checking.
213    #[must_use]
214    pub fn block_regions(text: &str) -> Vec<Range<usize>> {
215        let directives = Self::parse_directives(text);
216        Self::resolve_regions(text, &directives)
217            .into_iter()
218            .filter(|region| region.options.block)
219            .map(|region| region.byte_range)
220            .collect()
221    }
222
223    fn resolve_regions(text: &str, directives: &[IgnoreDirective]) -> Vec<DirectiveRegion> {
224        let mut regions = Vec::new();
225        let mut open_begins: Vec<&IgnoreDirective> = Vec::new();
226
227        for directive in directives {
228            match &directive.kind {
229                DirectiveKind::Begin => {
230                    let opts = directive.options.clone().unwrap_or_default();
231
232                    if let Some((a, b)) = opts.line_slice {
233                        // Auto-closing: scope covers lines a..b after the directive
234                        let first_line = next_line_start(text, directive.line_end);
235                        let start = advance_n_lines(text, first_line, a);
236                        let end = advance_n_lines(text, first_line, b);
237                        if start < text.len() {
238                            regions.push(DirectiveRegion {
239                                byte_range: start..end,
240                                options: opts,
241                            });
242                        }
243                    } else {
244                        open_begins.push(directive);
245                    }
246                }
247                DirectiveKind::End => {
248                    if let Some(begin) = open_begins.pop() {
249                        let opts = begin.options.clone().unwrap_or_default();
250                        let start = next_line_start(text, begin.line_end);
251                        let end = directive.line_start;
252                        if start < end {
253                            regions.push(DirectiveRegion {
254                                byte_range: start..end,
255                                options: opts,
256                            });
257                        }
258                    }
259                }
260                _ => {}
261            }
262        }
263
264        // Unclosed begins extend to EOF
265        for begin in open_begins {
266            let opts = begin.options.clone().unwrap_or_default();
267            let start = next_line_start(text, begin.line_end);
268            if start < text.len() {
269                regions.push(DirectiveRegion {
270                    byte_range: start..text.len(),
271                    options: opts,
272                });
273            }
274        }
275
276        regions
277    }
278
279    /// Check whether a diagnostic should be suppressed by any directive region.
280    ///
281    /// Regions with only a `language` override (no `rule_ids`) do NOT suppress;
282    /// they are language-override-only regions.
283    #[must_use]
284    pub fn should_ignore_by_region(
285        diagnostic: &Diagnostic,
286        text: &str,
287        regions: &[DirectiveRegion],
288    ) -> bool {
289        let d_start = diagnostic.start_byte as usize;
290
291        for region in regions {
292            if !region.byte_range.contains(&d_start) {
293                continue;
294            }
295
296            // Language-only regions don't suppress diagnostics
297            if region.options.rule_ids.is_empty()
298                && region.options.language.is_some()
299                && region.options.match_pattern.is_none()
300                && region.options.exclude_pattern.is_none()
301            {
302                continue;
303            }
304
305            // Check match/exclude regex filters
306            if !line_matches_filters(text, d_start, &region.options) {
307                continue;
308            }
309
310            // Rule ID filtering
311            if region.options.rule_ids.is_empty() {
312                return true;
313            }
314            if region
315                .options
316                .rule_ids
317                .iter()
318                .any(|r| r == &diagnostic.unified_id || r == &diagnostic.rule_id)
319            {
320                return true;
321            }
322        }
323
324        false
325    }
326
327    /// Extract a directive from a single line of text.
328    fn extract_directive(line: &str) -> Option<(DirectiveKind, Vec<String>, Option<BeginOptions>)> {
329        crate::text_util::in_comment(line, Self::parse_directive_content)
330    }
331
332    /// Parse the content after the comment markers.
333    fn parse_directive_content(
334        content: &str,
335    ) -> Option<(DirectiveKind, Vec<String>, Option<BeginOptions>)> {
336        if let Some(rest) = content.strip_prefix("lang-check-disable-next-line") {
337            let rule_ids = parse_rule_ids(rest);
338            return Some((DirectiveKind::DisableNextLine, rule_ids, None));
339        }
340
341        if let Some(rest) = content.strip_prefix("lang-check-disable") {
342            let rule_ids = parse_rule_ids(rest);
343            return Some((DirectiveKind::Disable, rule_ids, None));
344        }
345
346        if content.starts_with("lang-check-enable") {
347            return Some((DirectiveKind::Enable, Vec::new(), None));
348        }
349
350        if let Some(rest) = content.strip_prefix("lang-check-begin") {
351            let options = parse_begin_options(rest);
352            return Some((DirectiveKind::Begin, Vec::new(), Some(options)));
353        }
354
355        if content.starts_with("lang-check-end") {
356            return Some((DirectiveKind::End, Vec::new(), None));
357        }
358
359        None
360    }
361}
362
363/// Parse optional rule IDs from the remainder of a directive.
364fn parse_rule_ids(rest: &str) -> Vec<String> {
365    rest.split_whitespace()
366        .filter(|s| !s.is_empty())
367        .map(String::from)
368        .collect()
369}
370
371/// Parse the options after `lang-check-begin`.
372///
373/// Tokens are space-separated. Recognized option prefixes:
374/// - `lang:xx` → language override
375/// - `type:xx` → document type override
376/// - `check[a:b]` or `check[:b]` → line slice (0-indexed, like `[start:end]`)
377/// - `match:/PATTERN/` → regex include filter
378/// - `exclude:/PATTERN/` → regex exclude filter
379/// - anything else → treated as a rule ID
380fn parse_begin_options(rest: &str) -> BeginOptions {
381    let mut opts = BeginOptions::default();
382
383    for token in rest.split_whitespace() {
384        if let Some(lang) = token.strip_prefix("lang:") {
385            opts.language = Some(lang.to_string());
386        } else if let Some(dtype) = token.strip_prefix("type:") {
387            opts.doc_type = Some(dtype.to_string());
388        } else if let Some(inner) = token.strip_prefix("check[")
389            && let Some(slice) = inner.strip_suffix(']')
390            && let Some((a_str, b_str)) = slice.split_once(':')
391            && let Ok(b) = b_str.parse::<usize>()
392        {
393            let a = if a_str.is_empty() {
394                0
395            } else if let Ok(v) = a_str.parse::<usize>() {
396                v
397            } else {
398                continue;
399            };
400            opts.line_slice = Some((a, b));
401        } else if let Some(pat) = token.strip_prefix("match:") {
402            // e.g. "match:/^>\s/"
403            let pat = pat.strip_prefix('/').unwrap_or(pat);
404            let pat = pat.strip_suffix('/').unwrap_or(pat);
405            opts.match_pattern = Some(pat.to_string());
406        } else if let Some(pat) = token.strip_prefix("exclude:") {
407            let pat = pat.strip_prefix('/').unwrap_or(pat);
408            let pat = pat.strip_suffix('/').unwrap_or(pat);
409            opts.exclude_pattern = Some(pat.to_string());
410        } else if token == "block" {
411            opts.block = true;
412        } else {
413            opts.rule_ids.push(token.to_string());
414        }
415    }
416
417    opts
418}
419
420/// Iterate over lines in text, yielding (`byte_offset`, `line_content`) pairs.
421fn line_byte_offsets(text: &str) -> impl Iterator<Item = (usize, &str)> {
422    text.split('\n').scan(0usize, |offset, line| {
423        let start = *offset;
424        *offset += line.len() + 1; // +1 for the newline
425        Some((start, line))
426    })
427}
428
429/// Return the byte offset of the start of the next line after `pos`.
430fn next_line_start(text: &str, pos: usize) -> usize {
431    text[pos..].find('\n').map_or(text.len(), |nl| pos + nl + 1)
432}
433
434/// Return the byte offset of the end of the line starting at `pos`.
435fn line_end_at(text: &str, pos: usize) -> usize {
436    text[pos..].find('\n').map_or(text.len(), |nl| pos + nl)
437}
438
439/// Advance `n` lines from `start` and return the byte offset of the end of the Nth line.
440fn advance_n_lines(text: &str, start: usize, n: usize) -> usize {
441    let mut pos = start;
442    for _ in 0..n {
443        match text[pos..].find('\n') {
444            Some(nl) => pos = pos + nl + 1,
445            None => return text.len(),
446        }
447    }
448    // pos is now at the start of line n+1; the region covers up to here
449    pos
450}
451
452/// Extract the line containing byte offset `pos` from `text`.
453fn line_at(text: &str, pos: usize) -> &str {
454    let start = text[..pos].rfind('\n').map_or(0, |nl| nl + 1);
455    let end = text[pos..].find('\n').map_or(text.len(), |nl| pos + nl);
456    &text[start..end]
457}
458
459/// Resolve the effective language at a byte offset.
460///
461/// Directive regions with `lang:` take precedence over `ScopeParser` regions.
462/// Returns `None` if neither system overrides the language at this position.
463#[must_use]
464pub fn resolve_language<'a>(
465    byte_offset: usize,
466    regions: &'a [DirectiveRegion],
467    scope_regions: &'a [crate::scoping::ScopedRegion],
468) -> Option<&'a str> {
469    // Directive regions take precedence
470    for region in regions {
471        if region.byte_range.contains(&byte_offset)
472            && let Some(ref lang) = region.options.language
473        {
474            return Some(lang.as_str());
475        }
476    }
477    // Fall back to legacy scope parser
478    crate::scoping::ScopeParser::language_at(scope_regions, byte_offset)
479}
480
481/// Check whether a diagnostic position passes the match/exclude regex filters.
482fn line_matches_filters(text: &str, byte_pos: usize, opts: &BeginOptions) -> bool {
483    let line = line_at(text, byte_pos);
484
485    if let Some(ref pat) = opts.match_pattern
486        && let Ok(re) = regex::Regex::new(pat)
487        && !re.is_match(line)
488    {
489        return false;
490    }
491
492    if let Some(ref pat) = opts.exclude_pattern
493        && let Ok(re) = regex::Regex::new(pat)
494        && re.is_match(line)
495    {
496        return false;
497    }
498
499    true
500}
501
502#[cfg(test)]
503mod tests {
504    use super::*;
505
506    #[test]
507    fn block_regions_parses_block_option() {
508        let text = "% lang-check-begin block\nsome prose here\n% lang-check-end\nafter";
509        let regions = IgnoreParser::block_regions(text);
510        assert_eq!(regions.len(), 1, "one block region expected");
511        let region = &regions[0];
512        assert!(text[region.clone()].contains("some prose here"));
513        assert!(!text[region.clone()].contains("after"));
514    }
515
516    #[test]
517    fn block_regions_ignores_plain_begin() {
518        // A `lang-check-begin` without the `block` token is not a force-merge region.
519        let text = "% lang-check-begin lang:fr\nbonjour\n% lang-check-end";
520        assert!(IgnoreParser::block_regions(text).is_empty());
521    }
522
523    fn make_diag(text: &str, needle: &str, rule_id: &str, unified_id: &str) -> Diagnostic {
524        let start = text.find(needle).unwrap();
525        Diagnostic {
526            start_byte: start as u32,
527            end_byte: (start + needle.len()) as u32,
528            message: "test".to_string(),
529            suggestions: vec![],
530            rule_id: rule_id.to_string(),
531            severity: 2,
532            unified_id: unified_id.to_string(),
533            confidence: 0.9,
534            language: String::new(),
535            pack_installable: false,
536        }
537    }
538
539    #[test]
540    fn parse_html_disable_enable() {
541        let text = "Line one\n<!-- lang-check-disable -->\nBad text here\n<!-- lang-check-enable -->\nGood text";
542        let ranges = IgnoreParser::parse(text);
543        assert_eq!(ranges.len(), 1);
544        assert!(text[ranges[0].byte_range.clone()].contains("Bad text here"));
545        assert!(!text[ranges[0].byte_range.clone()].contains("Good text"));
546        assert!(ranges[0].rule_ids.is_empty());
547    }
548
549    #[test]
550    fn parse_disable_next_line() {
551        let text = "Line one\n<!-- lang-check-disable-next-line -->\nBad line\nGood line";
552        let ranges = IgnoreParser::parse(text);
553        assert_eq!(ranges.len(), 1);
554        assert_eq!(&text[ranges[0].byte_range.clone()], "Bad line");
555    }
556
557    #[test]
558    fn parse_disable_with_rule_id() {
559        let text =
560            "<!-- lang-check-disable spelling.typo -->\nsome text\n<!-- lang-check-enable -->";
561        let ranges = IgnoreParser::parse(text);
562        assert_eq!(ranges.len(), 1);
563        assert_eq!(ranges[0].rule_ids, vec!["spelling.typo"]);
564    }
565
566    #[test]
567    fn parse_disable_multiple_rule_ids() {
568        let text = "<!-- lang-check-disable spelling.typo grammar.article -->\ntext\n<!-- lang-check-enable -->";
569        let ranges = IgnoreParser::parse(text);
570        assert_eq!(ranges.len(), 1);
571        assert_eq!(ranges[0].rule_ids, vec!["spelling.typo", "grammar.article"]);
572    }
573
574    #[test]
575    fn parse_line_comment_format() {
576        let text = "code\n// lang-check-disable\nsome text\n// lang-check-enable\nmore code";
577        let ranges = IgnoreParser::parse(text);
578        assert_eq!(ranges.len(), 1);
579        assert!(text[ranges[0].byte_range.clone()].contains("some text"));
580    }
581
582    #[test]
583    fn parse_block_comment_format() {
584        let text = "/* lang-check-disable-next-line */\nbad line\ngood line";
585        let ranges = IgnoreParser::parse(text);
586        assert_eq!(ranges.len(), 1);
587        assert_eq!(&text[ranges[0].byte_range.clone()], "bad line");
588    }
589
590    #[test]
591    fn parse_latex_comment_format() {
592        let text = "% lang-check-disable\nbad text\n% lang-check-enable\ngood text";
593        let ranges = IgnoreParser::parse(text);
594        assert_eq!(ranges.len(), 1);
595        assert!(text[ranges[0].byte_range.clone()].contains("bad text"));
596    }
597
598    #[test]
599    fn unclosed_disable_extends_to_eof() {
600        let text = "Good text\n<!-- lang-check-disable -->\nBad text\nMore bad text";
601        let ranges = IgnoreParser::parse(text);
602        assert_eq!(ranges.len(), 1);
603        assert_eq!(ranges[0].byte_range.end, text.len());
604    }
605
606    #[test]
607    fn no_directives_no_ranges() {
608        let text = "Just normal text\nwith no directives.";
609        let ranges = IgnoreParser::parse(text);
610        assert!(ranges.is_empty());
611    }
612
613    #[test]
614    fn should_ignore_all_rules() {
615        let text = "Hello\n<!-- lang-check-disable -->\nBad text\n<!-- lang-check-enable -->\nGood";
616        let ranges = IgnoreParser::parse(text);
617        assert_eq!(ranges.len(), 1);
618
619        let d_inside = make_diag(text, "Bad", "some_rule", "spelling.typo");
620        assert!(IgnoreParser::should_ignore(&d_inside, &ranges));
621
622        let d_outside = make_diag(text, "Hello", "some_rule", "spelling.typo");
623        assert!(!IgnoreParser::should_ignore(&d_outside, &ranges));
624    }
625
626    #[test]
627    fn should_ignore_specific_rule_only() {
628        let text =
629            "<!-- lang-check-disable spelling.typo -->\nBad text\n<!-- lang-check-enable -->";
630        let ranges = IgnoreParser::parse(text);
631
632        let d_match = make_diag(text, "Bad", "harper::spelling", "spelling.typo");
633        assert!(IgnoreParser::should_ignore(&d_match, &ranges));
634
635        let d_no_match = make_diag(text, "Bad", "grammar_check", "grammar.article");
636        assert!(!IgnoreParser::should_ignore(&d_no_match, &ranges));
637    }
638
639    #[test]
640    fn disable_next_line_with_rule_id() {
641        let text = "// lang-check-disable-next-line grammar.article\nThe the error\nClean line";
642        let ranges = IgnoreParser::parse(text);
643        assert_eq!(ranges.len(), 1);
644        assert_eq!(&text[ranges[0].byte_range.clone()], "The the error");
645        assert_eq!(ranges[0].rule_ids, vec!["grammar.article"]);
646    }
647
648    // ── Begin/End directive tests ────────────────────────────────────
649
650    #[test]
651    fn parse_begin_end_basic() {
652        let text = "Good\n<!-- lang-check-begin -->\nBad text\n<!-- lang-check-end -->\nGood";
653        let directives = IgnoreParser::parse_directives(text);
654        let resolved = IgnoreParser::resolve_all(text, &directives);
655        assert_eq!(resolved.regions.len(), 1);
656        let region_text = &text[resolved.regions[0].byte_range.clone()];
657        assert!(region_text.contains("Bad text"));
658        assert!(!region_text.contains("Good"));
659    }
660
661    #[test]
662    fn parse_begin_with_rule_ids() {
663        let text = "<!-- lang-check-begin spelling.typo -->\ntext\n<!-- lang-check-end -->";
664        let directives = IgnoreParser::parse_directives(text);
665        let resolved = IgnoreParser::resolve_all(text, &directives);
666        assert_eq!(resolved.regions.len(), 1);
667        assert_eq!(resolved.regions[0].options.rule_ids, vec!["spelling.typo"]);
668    }
669
670    #[test]
671    fn parse_begin_with_lang() {
672        let text = "<!-- lang-check-begin lang:fr -->\nTexte\n<!-- lang-check-end -->";
673        let directives = IgnoreParser::parse_directives(text);
674        let resolved = IgnoreParser::resolve_all(text, &directives);
675        assert_eq!(resolved.regions.len(), 1);
676        assert_eq!(resolved.regions[0].options.language, Some("fr".to_string()));
677    }
678
679    #[test]
680    fn parse_begin_with_line_count() {
681        let text = "<!-- lang-check-begin check[:2] -->\nLine one\nLine two\nLine three";
682        let directives = IgnoreParser::parse_directives(text);
683        let resolved = IgnoreParser::resolve_all(text, &directives);
684        assert_eq!(resolved.regions.len(), 1);
685        let region_text = &text[resolved.regions[0].byte_range.clone()];
686        assert!(region_text.contains("Line one"));
687        assert!(region_text.contains("Line two"));
688        assert!(!region_text.contains("Line three"));
689    }
690
691    #[test]
692    fn parse_begin_with_match_exclude() {
693        let text =
694            "<!-- lang-check-begin match:/^>/ exclude:/TODO/ -->\ntext\n<!-- lang-check-end -->";
695        let directives = IgnoreParser::parse_directives(text);
696        let resolved = IgnoreParser::resolve_all(text, &directives);
697        assert_eq!(resolved.regions.len(), 1);
698        assert_eq!(
699            resolved.regions[0].options.match_pattern,
700            Some("^>".to_string())
701        );
702        assert_eq!(
703            resolved.regions[0].options.exclude_pattern,
704            Some("TODO".to_string())
705        );
706    }
707
708    #[test]
709    fn parse_begin_multiple_options() {
710        let text =
711            "<!-- lang-check-begin lang:de spelling.typo check[:3] -->\nZeile\nZwei\nDrei\nVier";
712        let directives = IgnoreParser::parse_directives(text);
713        let resolved = IgnoreParser::resolve_all(text, &directives);
714        assert_eq!(resolved.regions.len(), 1);
715        let opts = &resolved.regions[0].options;
716        assert_eq!(opts.language, Some("de".to_string()));
717        assert_eq!(opts.rule_ids, vec!["spelling.typo"]);
718        assert_eq!(opts.line_slice, Some((0, 3)));
719    }
720
721    #[test]
722    fn parse_begin_unclosed_extends_to_eof() {
723        let text = "Good\n<!-- lang-check-begin -->\nBad text\nMore bad text";
724        let directives = IgnoreParser::parse_directives(text);
725        let resolved = IgnoreParser::resolve_all(text, &directives);
726        assert_eq!(resolved.regions.len(), 1);
727        assert_eq!(resolved.regions[0].byte_range.end, text.len());
728    }
729
730    #[test]
731    fn begin_end_suppress_all() {
732        let text = "Good\n<!-- lang-check-begin -->\nBad text\n<!-- lang-check-end -->\nGood";
733        let directives = IgnoreParser::parse_directives(text);
734        let resolved = IgnoreParser::resolve_all(text, &directives);
735
736        let d_inside = make_diag(text, "Bad", "some_rule", "spelling.typo");
737        assert!(IgnoreParser::should_ignore_by_region(
738            &d_inside,
739            text,
740            &resolved.regions
741        ));
742
743        let d_outside = make_diag(text, "Good", "some_rule", "spelling.typo");
744        assert!(!IgnoreParser::should_ignore_by_region(
745            &d_outside,
746            text,
747            &resolved.regions
748        ));
749    }
750
751    #[test]
752    fn begin_end_suppress_specific_rule() {
753        let text = "<!-- lang-check-begin spelling.typo -->\nBad text\n<!-- lang-check-end -->";
754        let directives = IgnoreParser::parse_directives(text);
755        let resolved = IgnoreParser::resolve_all(text, &directives);
756
757        let d_match = make_diag(text, "Bad", "harper::spelling", "spelling.typo");
758        assert!(IgnoreParser::should_ignore_by_region(
759            &d_match,
760            text,
761            &resolved.regions
762        ));
763
764        let d_no_match = make_diag(text, "Bad", "grammar_check", "grammar.article");
765        assert!(!IgnoreParser::should_ignore_by_region(
766            &d_no_match,
767            text,
768            &resolved.regions
769        ));
770    }
771
772    #[test]
773    fn begin_line_count_no_end_needed() {
774        let text = "<!-- lang-check-begin check[:1] -->\nBad line\nGood line";
775        let directives = IgnoreParser::parse_directives(text);
776        let resolved = IgnoreParser::resolve_all(text, &directives);
777        assert_eq!(resolved.regions.len(), 1);
778
779        let d_bad = make_diag(text, "Bad", "r", "spelling.typo");
780        assert!(IgnoreParser::should_ignore_by_region(
781            &d_bad,
782            text,
783            &resolved.regions
784        ));
785
786        let d_good = make_diag(text, "Good", "r", "spelling.typo");
787        assert!(!IgnoreParser::should_ignore_by_region(
788            &d_good,
789            text,
790            &resolved.regions
791        ));
792    }
793
794    #[test]
795    fn begin_end_with_match_filter() {
796        let text = "<!-- lang-check-begin match:/^>/ -->\n> Quoted line\nNormal line\n<!-- lang-check-end -->";
797        let directives = IgnoreParser::parse_directives(text);
798        let resolved = IgnoreParser::resolve_all(text, &directives);
799
800        // Diagnostic on the quoted line — should be suppressed
801        let d_quoted = make_diag(text, "Quoted", "r", "spelling.typo");
802        assert!(IgnoreParser::should_ignore_by_region(
803            &d_quoted,
804            text,
805            &resolved.regions
806        ));
807
808        // Diagnostic on the normal line — should NOT be suppressed
809        let d_normal = make_diag(text, "Normal", "r", "spelling.typo");
810        assert!(!IgnoreParser::should_ignore_by_region(
811            &d_normal,
812            text,
813            &resolved.regions
814        ));
815    }
816
817    #[test]
818    fn begin_end_with_exclude_filter() {
819        let text = "<!-- lang-check-begin exclude:/TODO/ -->\nCheck this\nTODO skip this\n<!-- lang-check-end -->";
820        let directives = IgnoreParser::parse_directives(text);
821        let resolved = IgnoreParser::resolve_all(text, &directives);
822
823        // "Check this" — should be suppressed
824        let d_check = make_diag(text, "Check", "r", "spelling.typo");
825        assert!(IgnoreParser::should_ignore_by_region(
826            &d_check,
827            text,
828            &resolved.regions
829        ));
830
831        // "TODO skip this" — excluded, should NOT be suppressed
832        let d_todo = make_diag(text, "TODO", "r", "spelling.typo");
833        assert!(!IgnoreParser::should_ignore_by_region(
834            &d_todo,
835            text,
836            &resolved.regions
837        ));
838    }
839
840    #[test]
841    fn mixed_disable_and_begin() {
842        let text = "<!-- lang-check-disable -->\nDisabled\n<!-- lang-check-enable -->\n<!-- lang-check-begin -->\nBegin region\n<!-- lang-check-end -->\nClean";
843        let directives = IgnoreParser::parse_directives(text);
844        let resolved = IgnoreParser::resolve_all(text, &directives);
845
846        // Legacy disable range
847        assert_eq!(resolved.ignore_ranges.len(), 1);
848        assert!(text[resolved.ignore_ranges[0].byte_range.clone()].contains("Disabled"));
849
850        // Begin/end region
851        assert_eq!(resolved.regions.len(), 1);
852        assert!(text[resolved.regions[0].byte_range.clone()].contains("Begin region"));
853
854        // Both systems suppress their respective content
855        let d_disabled = make_diag(text, "Disabled", "r", "spelling.typo");
856        assert!(IgnoreParser::should_ignore(
857            &d_disabled,
858            &resolved.ignore_ranges
859        ));
860
861        let d_begin = make_diag(text, "Begin region", "r", "spelling.typo");
862        assert!(IgnoreParser::should_ignore_by_region(
863            &d_begin,
864            text,
865            &resolved.regions
866        ));
867
868        let d_clean = make_diag(text, "Clean", "r", "spelling.typo");
869        assert!(!IgnoreParser::should_ignore(
870            &d_clean,
871            &resolved.ignore_ranges
872        ));
873        assert!(!IgnoreParser::should_ignore_by_region(
874            &d_clean,
875            text,
876            &resolved.regions
877        ));
878    }
879
880    #[test]
881    fn nested_begin_end() {
882        let text = "<!-- lang-check-begin -->\nOuter\n<!-- lang-check-begin spelling.typo -->\nInner\n<!-- lang-check-end -->\nStill outer\n<!-- lang-check-end -->";
883        let directives = IgnoreParser::parse_directives(text);
884        let resolved = IgnoreParser::resolve_all(text, &directives);
885
886        // Two regions: the inner one closes first (stack semantics)
887        assert_eq!(resolved.regions.len(), 2);
888
889        // Inner region has spelling.typo filter
890        let inner = resolved
891            .regions
892            .iter()
893            .find(|r| !r.options.rule_ids.is_empty())
894            .unwrap();
895        assert_eq!(inner.options.rule_ids, vec!["spelling.typo"]);
896        assert!(text[inner.byte_range.clone()].contains("Inner"));
897
898        // Outer region has no rule filter (suppress all)
899        let outer = resolved
900            .regions
901            .iter()
902            .find(|r| r.options.rule_ids.is_empty())
903            .unwrap();
904        assert!(text[outer.byte_range.clone()].contains("Outer"));
905        assert!(text[outer.byte_range.clone()].contains("Still outer"));
906    }
907
908    #[test]
909    fn lang_override_does_not_suppress() {
910        // A region with only lang: and no rule_ids should NOT suppress diagnostics
911        let text = "<!-- lang-check-begin lang:fr -->\nTexte\n<!-- lang-check-end -->";
912        let directives = IgnoreParser::parse_directives(text);
913        let resolved = IgnoreParser::resolve_all(text, &directives);
914
915        let d = make_diag(text, "Texte", "r", "spelling.typo");
916        assert!(!IgnoreParser::should_ignore_by_region(
917            &d,
918            text,
919            &resolved.regions
920        ));
921    }
922
923    #[test]
924    fn resolve_language_directive_takes_precedence() {
925        let text = "<!-- lang-check-begin lang:fr -->\nTexte\n<!-- lang-check-end -->";
926        let directives = IgnoreParser::parse_directives(text);
927        let resolved = IgnoreParser::resolve_all(text, &directives);
928
929        let texte_offset = text.find("Texte").unwrap();
930
931        // Directive region provides French
932        assert_eq!(
933            resolve_language(texte_offset, &resolved.regions, &[]),
934            Some("fr")
935        );
936
937        // Before the region — no override
938        assert_eq!(resolve_language(0, &resolved.regions, &[]), None);
939    }
940}