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        let trimmed = line.trim();
330
331        // HTML comment: <!-- lang-check-... -->
332        if let Some(rest) = trimmed.strip_prefix("<!--")
333            && let Some(inner) = rest.strip_suffix("-->")
334        {
335            return Self::parse_directive_content(inner.trim());
336        }
337
338        // Line comment: // lang-check-...
339        if let Some(rest) = trimmed.strip_prefix("//") {
340            return Self::parse_directive_content(rest.trim());
341        }
342
343        // Block comment (single line): /* lang-check-... */
344        if let Some(rest) = trimmed.strip_prefix("/*")
345            && let Some(inner) = rest.strip_suffix("*/")
346        {
347            return Self::parse_directive_content(inner.trim());
348        }
349
350        // LaTeX comment: % lang-check-...
351        if let Some(rest) = trimmed.strip_prefix('%') {
352            return Self::parse_directive_content(rest.trim());
353        }
354
355        None
356    }
357
358    /// Parse the content after the comment markers.
359    fn parse_directive_content(
360        content: &str,
361    ) -> Option<(DirectiveKind, Vec<String>, Option<BeginOptions>)> {
362        if let Some(rest) = content.strip_prefix("lang-check-disable-next-line") {
363            let rule_ids = parse_rule_ids(rest);
364            return Some((DirectiveKind::DisableNextLine, rule_ids, None));
365        }
366
367        if let Some(rest) = content.strip_prefix("lang-check-disable") {
368            let rule_ids = parse_rule_ids(rest);
369            return Some((DirectiveKind::Disable, rule_ids, None));
370        }
371
372        if content.starts_with("lang-check-enable") {
373            return Some((DirectiveKind::Enable, Vec::new(), None));
374        }
375
376        if let Some(rest) = content.strip_prefix("lang-check-begin") {
377            let options = parse_begin_options(rest);
378            return Some((DirectiveKind::Begin, Vec::new(), Some(options)));
379        }
380
381        if content.starts_with("lang-check-end") {
382            return Some((DirectiveKind::End, Vec::new(), None));
383        }
384
385        None
386    }
387}
388
389/// Parse optional rule IDs from the remainder of a directive.
390fn parse_rule_ids(rest: &str) -> Vec<String> {
391    rest.split_whitespace()
392        .filter(|s| !s.is_empty())
393        .map(String::from)
394        .collect()
395}
396
397/// Parse the options after `lang-check-begin`.
398///
399/// Tokens are space-separated. Recognized option prefixes:
400/// - `lang:xx` → language override
401/// - `type:xx` → document type override
402/// - `check[a:b]` or `check[:b]` → line slice (0-indexed, like `[start:end]`)
403/// - `match:/PATTERN/` → regex include filter
404/// - `exclude:/PATTERN/` → regex exclude filter
405/// - anything else → treated as a rule ID
406fn parse_begin_options(rest: &str) -> BeginOptions {
407    let mut opts = BeginOptions::default();
408
409    for token in rest.split_whitespace() {
410        if let Some(lang) = token.strip_prefix("lang:") {
411            opts.language = Some(lang.to_string());
412        } else if let Some(dtype) = token.strip_prefix("type:") {
413            opts.doc_type = Some(dtype.to_string());
414        } else if let Some(inner) = token.strip_prefix("check[")
415            && let Some(slice) = inner.strip_suffix(']')
416            && let Some((a_str, b_str)) = slice.split_once(':')
417            && let Ok(b) = b_str.parse::<usize>()
418        {
419            let a = if a_str.is_empty() {
420                0
421            } else if let Ok(v) = a_str.parse::<usize>() {
422                v
423            } else {
424                continue;
425            };
426            opts.line_slice = Some((a, b));
427        } else if let Some(pat) = token.strip_prefix("match:") {
428            // e.g. "match:/^>\s/"
429            let pat = pat.strip_prefix('/').unwrap_or(pat);
430            let pat = pat.strip_suffix('/').unwrap_or(pat);
431            opts.match_pattern = Some(pat.to_string());
432        } else if let Some(pat) = token.strip_prefix("exclude:") {
433            let pat = pat.strip_prefix('/').unwrap_or(pat);
434            let pat = pat.strip_suffix('/').unwrap_or(pat);
435            opts.exclude_pattern = Some(pat.to_string());
436        } else if token == "block" {
437            opts.block = true;
438        } else {
439            opts.rule_ids.push(token.to_string());
440        }
441    }
442
443    opts
444}
445
446/// Iterate over lines in text, yielding (`byte_offset`, `line_content`) pairs.
447fn line_byte_offsets(text: &str) -> impl Iterator<Item = (usize, &str)> {
448    text.split('\n').scan(0usize, |offset, line| {
449        let start = *offset;
450        *offset += line.len() + 1; // +1 for the newline
451        Some((start, line))
452    })
453}
454
455/// Return the byte offset of the start of the next line after `pos`.
456fn next_line_start(text: &str, pos: usize) -> usize {
457    text[pos..].find('\n').map_or(text.len(), |nl| pos + nl + 1)
458}
459
460/// Return the byte offset of the end of the line starting at `pos`.
461fn line_end_at(text: &str, pos: usize) -> usize {
462    text[pos..].find('\n').map_or(text.len(), |nl| pos + nl)
463}
464
465/// Advance `n` lines from `start` and return the byte offset of the end of the Nth line.
466fn advance_n_lines(text: &str, start: usize, n: usize) -> usize {
467    let mut pos = start;
468    for _ in 0..n {
469        match text[pos..].find('\n') {
470            Some(nl) => pos = pos + nl + 1,
471            None => return text.len(),
472        }
473    }
474    // pos is now at the start of line n+1; the region covers up to here
475    pos
476}
477
478/// Extract the line containing byte offset `pos` from `text`.
479fn line_at(text: &str, pos: usize) -> &str {
480    let start = text[..pos].rfind('\n').map_or(0, |nl| nl + 1);
481    let end = text[pos..].find('\n').map_or(text.len(), |nl| pos + nl);
482    &text[start..end]
483}
484
485/// Resolve the effective language at a byte offset.
486///
487/// Directive regions with `lang:` take precedence over `ScopeParser` regions.
488/// Returns `None` if neither system overrides the language at this position.
489#[must_use]
490pub fn resolve_language<'a>(
491    byte_offset: usize,
492    regions: &'a [DirectiveRegion],
493    scope_regions: &'a [crate::scoping::ScopedRegion],
494) -> Option<&'a str> {
495    // Directive regions take precedence
496    for region in regions {
497        if region.byte_range.contains(&byte_offset)
498            && let Some(ref lang) = region.options.language
499        {
500            return Some(lang.as_str());
501        }
502    }
503    // Fall back to legacy scope parser
504    crate::scoping::ScopeParser::language_at(scope_regions, byte_offset)
505}
506
507/// Check whether a diagnostic position passes the match/exclude regex filters.
508fn line_matches_filters(text: &str, byte_pos: usize, opts: &BeginOptions) -> bool {
509    let line = line_at(text, byte_pos);
510
511    if let Some(ref pat) = opts.match_pattern
512        && let Ok(re) = regex::Regex::new(pat)
513        && !re.is_match(line)
514    {
515        return false;
516    }
517
518    if let Some(ref pat) = opts.exclude_pattern
519        && let Ok(re) = regex::Regex::new(pat)
520        && re.is_match(line)
521    {
522        return false;
523    }
524
525    true
526}
527
528#[cfg(test)]
529mod tests {
530    use super::*;
531
532    #[test]
533    fn block_regions_parses_block_option() {
534        let text = "% lang-check-begin block\nsome prose here\n% lang-check-end\nafter";
535        let regions = IgnoreParser::block_regions(text);
536        assert_eq!(regions.len(), 1, "one block region expected");
537        let region = &regions[0];
538        assert!(text[region.clone()].contains("some prose here"));
539        assert!(!text[region.clone()].contains("after"));
540    }
541
542    #[test]
543    fn block_regions_ignores_plain_begin() {
544        // A `lang-check-begin` without the `block` token is not a force-merge region.
545        let text = "% lang-check-begin lang:fr\nbonjour\n% lang-check-end";
546        assert!(IgnoreParser::block_regions(text).is_empty());
547    }
548
549    fn make_diag(text: &str, needle: &str, rule_id: &str, unified_id: &str) -> Diagnostic {
550        let start = text.find(needle).unwrap();
551        Diagnostic {
552            start_byte: start as u32,
553            end_byte: (start + needle.len()) as u32,
554            message: "test".to_string(),
555            suggestions: vec![],
556            rule_id: rule_id.to_string(),
557            severity: 2,
558            unified_id: unified_id.to_string(),
559            confidence: 0.9,
560        }
561    }
562
563    #[test]
564    fn parse_html_disable_enable() {
565        let text = "Line one\n<!-- lang-check-disable -->\nBad text here\n<!-- lang-check-enable -->\nGood text";
566        let ranges = IgnoreParser::parse(text);
567        assert_eq!(ranges.len(), 1);
568        assert!(text[ranges[0].byte_range.clone()].contains("Bad text here"));
569        assert!(!text[ranges[0].byte_range.clone()].contains("Good text"));
570        assert!(ranges[0].rule_ids.is_empty());
571    }
572
573    #[test]
574    fn parse_disable_next_line() {
575        let text = "Line one\n<!-- lang-check-disable-next-line -->\nBad line\nGood line";
576        let ranges = IgnoreParser::parse(text);
577        assert_eq!(ranges.len(), 1);
578        assert_eq!(&text[ranges[0].byte_range.clone()], "Bad line");
579    }
580
581    #[test]
582    fn parse_disable_with_rule_id() {
583        let text =
584            "<!-- lang-check-disable spelling.typo -->\nsome text\n<!-- lang-check-enable -->";
585        let ranges = IgnoreParser::parse(text);
586        assert_eq!(ranges.len(), 1);
587        assert_eq!(ranges[0].rule_ids, vec!["spelling.typo"]);
588    }
589
590    #[test]
591    fn parse_disable_multiple_rule_ids() {
592        let text = "<!-- lang-check-disable spelling.typo grammar.article -->\ntext\n<!-- lang-check-enable -->";
593        let ranges = IgnoreParser::parse(text);
594        assert_eq!(ranges.len(), 1);
595        assert_eq!(ranges[0].rule_ids, vec!["spelling.typo", "grammar.article"]);
596    }
597
598    #[test]
599    fn parse_line_comment_format() {
600        let text = "code\n// lang-check-disable\nsome text\n// lang-check-enable\nmore code";
601        let ranges = IgnoreParser::parse(text);
602        assert_eq!(ranges.len(), 1);
603        assert!(text[ranges[0].byte_range.clone()].contains("some text"));
604    }
605
606    #[test]
607    fn parse_block_comment_format() {
608        let text = "/* lang-check-disable-next-line */\nbad line\ngood line";
609        let ranges = IgnoreParser::parse(text);
610        assert_eq!(ranges.len(), 1);
611        assert_eq!(&text[ranges[0].byte_range.clone()], "bad line");
612    }
613
614    #[test]
615    fn parse_latex_comment_format() {
616        let text = "% lang-check-disable\nbad text\n% lang-check-enable\ngood text";
617        let ranges = IgnoreParser::parse(text);
618        assert_eq!(ranges.len(), 1);
619        assert!(text[ranges[0].byte_range.clone()].contains("bad text"));
620    }
621
622    #[test]
623    fn unclosed_disable_extends_to_eof() {
624        let text = "Good text\n<!-- lang-check-disable -->\nBad text\nMore bad text";
625        let ranges = IgnoreParser::parse(text);
626        assert_eq!(ranges.len(), 1);
627        assert_eq!(ranges[0].byte_range.end, text.len());
628    }
629
630    #[test]
631    fn no_directives_no_ranges() {
632        let text = "Just normal text\nwith no directives.";
633        let ranges = IgnoreParser::parse(text);
634        assert!(ranges.is_empty());
635    }
636
637    #[test]
638    fn should_ignore_all_rules() {
639        let text = "Hello\n<!-- lang-check-disable -->\nBad text\n<!-- lang-check-enable -->\nGood";
640        let ranges = IgnoreParser::parse(text);
641        assert_eq!(ranges.len(), 1);
642
643        let d_inside = make_diag(text, "Bad", "some_rule", "spelling.typo");
644        assert!(IgnoreParser::should_ignore(&d_inside, &ranges));
645
646        let d_outside = make_diag(text, "Hello", "some_rule", "spelling.typo");
647        assert!(!IgnoreParser::should_ignore(&d_outside, &ranges));
648    }
649
650    #[test]
651    fn should_ignore_specific_rule_only() {
652        let text =
653            "<!-- lang-check-disable spelling.typo -->\nBad text\n<!-- lang-check-enable -->";
654        let ranges = IgnoreParser::parse(text);
655
656        let d_match = make_diag(text, "Bad", "harper::spelling", "spelling.typo");
657        assert!(IgnoreParser::should_ignore(&d_match, &ranges));
658
659        let d_no_match = make_diag(text, "Bad", "grammar_check", "grammar.article");
660        assert!(!IgnoreParser::should_ignore(&d_no_match, &ranges));
661    }
662
663    #[test]
664    fn disable_next_line_with_rule_id() {
665        let text = "// lang-check-disable-next-line grammar.article\nThe the error\nClean line";
666        let ranges = IgnoreParser::parse(text);
667        assert_eq!(ranges.len(), 1);
668        assert_eq!(&text[ranges[0].byte_range.clone()], "The the error");
669        assert_eq!(ranges[0].rule_ids, vec!["grammar.article"]);
670    }
671
672    // ── Begin/End directive tests ────────────────────────────────────
673
674    #[test]
675    fn parse_begin_end_basic() {
676        let text = "Good\n<!-- lang-check-begin -->\nBad text\n<!-- lang-check-end -->\nGood";
677        let directives = IgnoreParser::parse_directives(text);
678        let resolved = IgnoreParser::resolve_all(text, &directives);
679        assert_eq!(resolved.regions.len(), 1);
680        let region_text = &text[resolved.regions[0].byte_range.clone()];
681        assert!(region_text.contains("Bad text"));
682        assert!(!region_text.contains("Good"));
683    }
684
685    #[test]
686    fn parse_begin_with_rule_ids() {
687        let text = "<!-- lang-check-begin spelling.typo -->\ntext\n<!-- lang-check-end -->";
688        let directives = IgnoreParser::parse_directives(text);
689        let resolved = IgnoreParser::resolve_all(text, &directives);
690        assert_eq!(resolved.regions.len(), 1);
691        assert_eq!(resolved.regions[0].options.rule_ids, vec!["spelling.typo"]);
692    }
693
694    #[test]
695    fn parse_begin_with_lang() {
696        let text = "<!-- lang-check-begin lang:fr -->\nTexte\n<!-- lang-check-end -->";
697        let directives = IgnoreParser::parse_directives(text);
698        let resolved = IgnoreParser::resolve_all(text, &directives);
699        assert_eq!(resolved.regions.len(), 1);
700        assert_eq!(resolved.regions[0].options.language, Some("fr".to_string()));
701    }
702
703    #[test]
704    fn parse_begin_with_line_count() {
705        let text = "<!-- lang-check-begin check[:2] -->\nLine one\nLine two\nLine three";
706        let directives = IgnoreParser::parse_directives(text);
707        let resolved = IgnoreParser::resolve_all(text, &directives);
708        assert_eq!(resolved.regions.len(), 1);
709        let region_text = &text[resolved.regions[0].byte_range.clone()];
710        assert!(region_text.contains("Line one"));
711        assert!(region_text.contains("Line two"));
712        assert!(!region_text.contains("Line three"));
713    }
714
715    #[test]
716    fn parse_begin_with_match_exclude() {
717        let text =
718            "<!-- lang-check-begin match:/^>/ exclude:/TODO/ -->\ntext\n<!-- lang-check-end -->";
719        let directives = IgnoreParser::parse_directives(text);
720        let resolved = IgnoreParser::resolve_all(text, &directives);
721        assert_eq!(resolved.regions.len(), 1);
722        assert_eq!(
723            resolved.regions[0].options.match_pattern,
724            Some("^>".to_string())
725        );
726        assert_eq!(
727            resolved.regions[0].options.exclude_pattern,
728            Some("TODO".to_string())
729        );
730    }
731
732    #[test]
733    fn parse_begin_multiple_options() {
734        let text =
735            "<!-- lang-check-begin lang:de spelling.typo check[:3] -->\nZeile\nZwei\nDrei\nVier";
736        let directives = IgnoreParser::parse_directives(text);
737        let resolved = IgnoreParser::resolve_all(text, &directives);
738        assert_eq!(resolved.regions.len(), 1);
739        let opts = &resolved.regions[0].options;
740        assert_eq!(opts.language, Some("de".to_string()));
741        assert_eq!(opts.rule_ids, vec!["spelling.typo"]);
742        assert_eq!(opts.line_slice, Some((0, 3)));
743    }
744
745    #[test]
746    fn parse_begin_unclosed_extends_to_eof() {
747        let text = "Good\n<!-- lang-check-begin -->\nBad text\nMore bad text";
748        let directives = IgnoreParser::parse_directives(text);
749        let resolved = IgnoreParser::resolve_all(text, &directives);
750        assert_eq!(resolved.regions.len(), 1);
751        assert_eq!(resolved.regions[0].byte_range.end, text.len());
752    }
753
754    #[test]
755    fn begin_end_suppress_all() {
756        let text = "Good\n<!-- lang-check-begin -->\nBad text\n<!-- lang-check-end -->\nGood";
757        let directives = IgnoreParser::parse_directives(text);
758        let resolved = IgnoreParser::resolve_all(text, &directives);
759
760        let d_inside = make_diag(text, "Bad", "some_rule", "spelling.typo");
761        assert!(IgnoreParser::should_ignore_by_region(
762            &d_inside,
763            text,
764            &resolved.regions
765        ));
766
767        let d_outside = make_diag(text, "Good", "some_rule", "spelling.typo");
768        assert!(!IgnoreParser::should_ignore_by_region(
769            &d_outside,
770            text,
771            &resolved.regions
772        ));
773    }
774
775    #[test]
776    fn begin_end_suppress_specific_rule() {
777        let text = "<!-- lang-check-begin spelling.typo -->\nBad text\n<!-- lang-check-end -->";
778        let directives = IgnoreParser::parse_directives(text);
779        let resolved = IgnoreParser::resolve_all(text, &directives);
780
781        let d_match = make_diag(text, "Bad", "harper::spelling", "spelling.typo");
782        assert!(IgnoreParser::should_ignore_by_region(
783            &d_match,
784            text,
785            &resolved.regions
786        ));
787
788        let d_no_match = make_diag(text, "Bad", "grammar_check", "grammar.article");
789        assert!(!IgnoreParser::should_ignore_by_region(
790            &d_no_match,
791            text,
792            &resolved.regions
793        ));
794    }
795
796    #[test]
797    fn begin_line_count_no_end_needed() {
798        let text = "<!-- lang-check-begin check[:1] -->\nBad line\nGood line";
799        let directives = IgnoreParser::parse_directives(text);
800        let resolved = IgnoreParser::resolve_all(text, &directives);
801        assert_eq!(resolved.regions.len(), 1);
802
803        let d_bad = make_diag(text, "Bad", "r", "spelling.typo");
804        assert!(IgnoreParser::should_ignore_by_region(
805            &d_bad,
806            text,
807            &resolved.regions
808        ));
809
810        let d_good = make_diag(text, "Good", "r", "spelling.typo");
811        assert!(!IgnoreParser::should_ignore_by_region(
812            &d_good,
813            text,
814            &resolved.regions
815        ));
816    }
817
818    #[test]
819    fn begin_end_with_match_filter() {
820        let text = "<!-- lang-check-begin match:/^>/ -->\n> Quoted line\nNormal line\n<!-- lang-check-end -->";
821        let directives = IgnoreParser::parse_directives(text);
822        let resolved = IgnoreParser::resolve_all(text, &directives);
823
824        // Diagnostic on the quoted line — should be suppressed
825        let d_quoted = make_diag(text, "Quoted", "r", "spelling.typo");
826        assert!(IgnoreParser::should_ignore_by_region(
827            &d_quoted,
828            text,
829            &resolved.regions
830        ));
831
832        // Diagnostic on the normal line — should NOT be suppressed
833        let d_normal = make_diag(text, "Normal", "r", "spelling.typo");
834        assert!(!IgnoreParser::should_ignore_by_region(
835            &d_normal,
836            text,
837            &resolved.regions
838        ));
839    }
840
841    #[test]
842    fn begin_end_with_exclude_filter() {
843        let text = "<!-- lang-check-begin exclude:/TODO/ -->\nCheck this\nTODO skip this\n<!-- lang-check-end -->";
844        let directives = IgnoreParser::parse_directives(text);
845        let resolved = IgnoreParser::resolve_all(text, &directives);
846
847        // "Check this" — should be suppressed
848        let d_check = make_diag(text, "Check", "r", "spelling.typo");
849        assert!(IgnoreParser::should_ignore_by_region(
850            &d_check,
851            text,
852            &resolved.regions
853        ));
854
855        // "TODO skip this" — excluded, should NOT be suppressed
856        let d_todo = make_diag(text, "TODO", "r", "spelling.typo");
857        assert!(!IgnoreParser::should_ignore_by_region(
858            &d_todo,
859            text,
860            &resolved.regions
861        ));
862    }
863
864    #[test]
865    fn mixed_disable_and_begin() {
866        let text = "<!-- lang-check-disable -->\nDisabled\n<!-- lang-check-enable -->\n<!-- lang-check-begin -->\nBegin region\n<!-- lang-check-end -->\nClean";
867        let directives = IgnoreParser::parse_directives(text);
868        let resolved = IgnoreParser::resolve_all(text, &directives);
869
870        // Legacy disable range
871        assert_eq!(resolved.ignore_ranges.len(), 1);
872        assert!(text[resolved.ignore_ranges[0].byte_range.clone()].contains("Disabled"));
873
874        // Begin/end region
875        assert_eq!(resolved.regions.len(), 1);
876        assert!(text[resolved.regions[0].byte_range.clone()].contains("Begin region"));
877
878        // Both systems suppress their respective content
879        let d_disabled = make_diag(text, "Disabled", "r", "spelling.typo");
880        assert!(IgnoreParser::should_ignore(
881            &d_disabled,
882            &resolved.ignore_ranges
883        ));
884
885        let d_begin = make_diag(text, "Begin region", "r", "spelling.typo");
886        assert!(IgnoreParser::should_ignore_by_region(
887            &d_begin,
888            text,
889            &resolved.regions
890        ));
891
892        let d_clean = make_diag(text, "Clean", "r", "spelling.typo");
893        assert!(!IgnoreParser::should_ignore(
894            &d_clean,
895            &resolved.ignore_ranges
896        ));
897        assert!(!IgnoreParser::should_ignore_by_region(
898            &d_clean,
899            text,
900            &resolved.regions
901        ));
902    }
903
904    #[test]
905    fn nested_begin_end() {
906        let text = "<!-- lang-check-begin -->\nOuter\n<!-- lang-check-begin spelling.typo -->\nInner\n<!-- lang-check-end -->\nStill outer\n<!-- lang-check-end -->";
907        let directives = IgnoreParser::parse_directives(text);
908        let resolved = IgnoreParser::resolve_all(text, &directives);
909
910        // Two regions: the inner one closes first (stack semantics)
911        assert_eq!(resolved.regions.len(), 2);
912
913        // Inner region has spelling.typo filter
914        let inner = resolved
915            .regions
916            .iter()
917            .find(|r| !r.options.rule_ids.is_empty())
918            .unwrap();
919        assert_eq!(inner.options.rule_ids, vec!["spelling.typo"]);
920        assert!(text[inner.byte_range.clone()].contains("Inner"));
921
922        // Outer region has no rule filter (suppress all)
923        let outer = resolved
924            .regions
925            .iter()
926            .find(|r| r.options.rule_ids.is_empty())
927            .unwrap();
928        assert!(text[outer.byte_range.clone()].contains("Outer"));
929        assert!(text[outer.byte_range.clone()].contains("Still outer"));
930    }
931
932    #[test]
933    fn lang_override_does_not_suppress() {
934        // A region with only lang: and no rule_ids should NOT suppress diagnostics
935        let text = "<!-- lang-check-begin lang:fr -->\nTexte\n<!-- lang-check-end -->";
936        let directives = IgnoreParser::parse_directives(text);
937        let resolved = IgnoreParser::resolve_all(text, &directives);
938
939        let d = make_diag(text, "Texte", "r", "spelling.typo");
940        assert!(!IgnoreParser::should_ignore_by_region(
941            &d,
942            text,
943            &resolved.regions
944        ));
945    }
946
947    #[test]
948    fn resolve_language_directive_takes_precedence() {
949        let text = "<!-- lang-check-begin lang:fr -->\nTexte\n<!-- lang-check-end -->";
950        let directives = IgnoreParser::parse_directives(text);
951        let resolved = IgnoreParser::resolve_all(text, &directives);
952
953        let texte_offset = text.find("Texte").unwrap();
954
955        // Directive region provides French
956        assert_eq!(
957            resolve_language(texte_offset, &resolved.regions, &[]),
958            Some("fr")
959        );
960
961        // Before the region — no override
962        assert_eq!(resolve_language(0, &resolved.regions, &[]), None);
963    }
964}