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