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