Skip to main content

nginx_lint_common/
ignore.rs

1//! Ignore comment support for nginx-lint
2//!
3//! This module provides support for inline ignore comments that ignore
4//! lint errors on specific lines.
5//!
6//! # Comment Formats
7//!
8//! ## Comment-only line (targets next line)
9//! ```nginx
10//! # nginx-lint:ignore rule-name reason
11//! server_tokens on;
12//! ```
13//!
14//! ## Inline comment (targets current line)
15//! ```nginx
16//! server_tokens on; # nginx-lint:ignore rule-name reason
17//! ```
18//!
19//! - `rule-name`: Required. The name of the rule to ignore
20//! - `reason`: Required. A reason explaining why the rule is ignored
21
22use std::collections::{HashMap, HashSet};
23
24use crate::linter::{Fix, LintError, Severity, compute_line_starts};
25
26/// A warning generated from parsing ignore comments
27#[derive(Debug, Clone)]
28pub struct IgnoreWarning {
29    /// Line number where the warning occurred
30    pub line: usize,
31    /// Warning message
32    pub message: String,
33    /// Fixes for the warning
34    pub fixes: Vec<Fix>,
35}
36
37/// Information about a single ignore directive
38#[derive(Debug, Clone)]
39struct IgnoreDirective {
40    /// Line number where the comment is located
41    comment_line: usize,
42    /// Line number that this directive targets
43    target_line: usize,
44    /// Rule name to ignore
45    rule_name: String,
46    /// Whether this directive was used to ignore an error
47    used: bool,
48    /// Start byte offset for the fix (for removing unused directives)
49    fix_start_offset: usize,
50    /// End byte offset for the fix (exclusive)
51    fix_end_offset: usize,
52    /// Replacement text for the fix (empty for delete, content for inline)
53    fix_replacement: String,
54}
55
56/// Tracks ignored rules per line
57#[derive(Debug, Default)]
58pub struct IgnoreTracker {
59    /// Map from target line number to set of ignored rule names
60    ignored_lines: HashMap<usize, HashSet<String>>,
61    /// All ignore directives for tracking usage
62    directives: Vec<IgnoreDirective>,
63    /// Rule names that are intentionally not running in this invocation
64    /// (e.g. filtered out by `--rule-only`). Ignore directives targeting
65    /// these rules are excluded from "unused" warnings so the caller can
66    /// disable rules per-invocation without churning the user's config.
67    dormant_rules: HashSet<String>,
68}
69
70impl IgnoreTracker {
71    /// Create a new empty ignore tracker
72    pub fn new() -> Self {
73        Self::default()
74    }
75
76    /// Check if a rule is ignored on a specific line
77    pub fn is_ignored(&self, rule: &str, line: usize) -> bool {
78        self.ignored_lines
79            .get(&line)
80            .map(|rules| rules.contains(rule))
81            .unwrap_or(false)
82    }
83
84    /// Build an ignore tracker from content, returning any warnings
85    pub fn from_content(content: &str) -> (Self, Vec<IgnoreWarning>) {
86        Self::from_content_with_rules(content, None)
87    }
88
89    /// Build an ignore tracker from content with optional rule name validation
90    pub fn from_content_with_rules(
91        content: &str,
92        valid_rules: Option<&HashSet<String>>,
93    ) -> (Self, Vec<IgnoreWarning>) {
94        let mut tracker = Self::new();
95        let mut warnings = Vec::new();
96        let line_starts = compute_line_starts(content);
97
98        // First pass: parse all ignore comments
99        let lines: Vec<&str> = content.lines().collect();
100        let mut parsed_comments: Vec<(usize, Result<ParsedIgnoreComment, IgnoreWarning>)> =
101            Vec::new();
102
103        for (line_idx, line) in lines.iter().enumerate() {
104            let line_number = line_idx + 1; // Convert to 1-indexed
105            if let Some(result) = parse_ignore_comment(line, line_number) {
106                parsed_comments.push((line_idx, result));
107            }
108        }
109
110        // Second pass: adjust target lines for consecutive comment-only ignores
111        // They should all target the first non-ignore-comment line
112        for i in 0..parsed_comments.len() {
113            let (line_idx, ref result) = parsed_comments[i];
114
115            match result {
116                Ok(parsed) if !parsed.is_inline => {
117                    // Find the first non-ignore-comment line after this one
118                    let mut target_idx = line_idx + 1;
119                    while target_idx < lines.len() {
120                        // Check if this line is also a ignore comment
121                        let is_ignore_comment = parsed_comments.iter().any(|(idx, r)| {
122                            *idx == target_idx && matches!(r, Ok(p) if !p.is_inline)
123                        });
124                        if !is_ignore_comment {
125                            break;
126                        }
127                        target_idx += 1;
128                    }
129                    let actual_target_line = target_idx + 1; // Convert to 1-indexed
130
131                    // Check if rule name is valid
132                    if let Some(valid) = valid_rules
133                        && !valid.contains(&parsed.rule_name)
134                    {
135                        warnings.push(IgnoreWarning {
136                            line: parsed.comment_line,
137                            message: format!(
138                                "unknown rule '{}' in nginx-lint:ignore comment",
139                                parsed.rule_name
140                            ),
141                            fixes: Vec::new(),
142                        });
143                    }
144
145                    tracker
146                        .ignored_lines
147                        .entry(actual_target_line)
148                        .or_default()
149                        .insert(parsed.rule_name.clone());
150
151                    // Compute byte offsets for deleting the comment line
152                    let comment_idx = parsed.comment_line - 1;
153                    let num_lines = line_starts.len() - 1;
154                    let (fix_start, fix_end) = if comment_idx + 1 < num_lines {
155                        // Not last line: include trailing newline
156                        (line_starts[comment_idx], line_starts[comment_idx + 1])
157                    } else if line_starts[comment_idx] > 0 {
158                        // Last line: include preceding newline
159                        (line_starts[comment_idx] - 1, line_starts[comment_idx + 1])
160                    } else {
161                        (line_starts[comment_idx], line_starts[comment_idx + 1])
162                    };
163
164                    tracker.directives.push(IgnoreDirective {
165                        comment_line: parsed.comment_line,
166                        target_line: actual_target_line,
167                        rule_name: parsed.rule_name.clone(),
168                        used: false,
169                        fix_start_offset: fix_start,
170                        fix_end_offset: fix_end,
171                        fix_replacement: String::new(),
172                    });
173                }
174                Ok(parsed) => {
175                    // Inline comment - targets current line
176                    if let Some(valid) = valid_rules
177                        && !valid.contains(&parsed.rule_name)
178                    {
179                        warnings.push(IgnoreWarning {
180                            line: parsed.comment_line,
181                            message: format!(
182                                "unknown rule '{}' in nginx-lint:ignore comment",
183                                parsed.rule_name
184                            ),
185                            fixes: Vec::new(),
186                        });
187                    }
188
189                    tracker
190                        .ignored_lines
191                        .entry(parsed.target_line)
192                        .or_default()
193                        .insert(parsed.rule_name.clone());
194
195                    // Compute byte offsets for replacing line with content before comment
196                    let comment_idx = parsed.comment_line - 1;
197                    let line_start = line_starts[comment_idx];
198                    let next_line_start = line_starts[comment_idx + 1];
199                    let line_end = if next_line_start > line_start
200                        && content.as_bytes().get(next_line_start - 1) == Some(&b'\n')
201                    {
202                        next_line_start - 1
203                    } else {
204                        next_line_start
205                    };
206                    let replacement = parsed.content_before_comment.clone().unwrap_or_default();
207
208                    tracker.directives.push(IgnoreDirective {
209                        comment_line: parsed.comment_line,
210                        target_line: parsed.target_line,
211                        rule_name: parsed.rule_name.clone(),
212                        used: false,
213                        fix_start_offset: line_start,
214                        fix_end_offset: line_end,
215                        fix_replacement: replacement,
216                    });
217                }
218                Err(warning) => {
219                    warnings.push(warning.clone());
220                }
221            }
222        }
223
224        (tracker, warnings)
225    }
226
227    /// Mark a rule as used on a specific line
228    fn mark_used(&mut self, rule: &str, line: usize) {
229        for directive in &mut self.directives {
230            if directive.target_line == line && directive.rule_name == rule {
231                directive.used = true;
232            }
233        }
234    }
235
236    /// Mark rule names as dormant — unused ignore directives targeting these
237    /// rules will be excluded from [`unused_warnings`](Self::unused_warnings).
238    ///
239    /// Takes a borrowed set so the caller can reuse it across many trackers
240    /// (e.g. when linting an include tree) without paying a clone per call
241    /// site; the tracker stores its own copy internally.
242    pub fn set_dormant_rules(&mut self, dormant: &HashSet<String>) {
243        self.dormant_rules = dormant.clone();
244    }
245
246    /// Get warnings for unused ignore directives
247    pub fn unused_warnings(&self) -> Vec<IgnoreWarning> {
248        self.directives
249            .iter()
250            .filter(|d| !d.used && !self.dormant_rules.contains(&d.rule_name))
251            .map(|d| {
252                let fix =
253                    Fix::replace_range(d.fix_start_offset, d.fix_end_offset, &d.fix_replacement);
254
255                IgnoreWarning {
256                    line: d.comment_line,
257                    message: format!(
258                        "unused nginx-lint:ignore comment for rule '{}'",
259                        d.rule_name
260                    ),
261                    fixes: vec![fix],
262                }
263            })
264            .collect()
265    }
266
267    /// Add an ignore rule for a specific line
268    ///
269    /// Note: fix offsets are set to dummy values (0, 0) since this helper
270    /// is only used to test filtering logic, not fix generation.
271    #[cfg(test)]
272    pub fn add_ignore(&mut self, rule: &str, line: usize) {
273        self.ignored_lines
274            .entry(line)
275            .or_default()
276            .insert(rule.to_string());
277        self.directives.push(IgnoreDirective {
278            comment_line: line.saturating_sub(1).max(1),
279            target_line: line,
280            rule_name: rule.to_string(),
281            used: false,
282            fix_start_offset: 0,
283            fix_end_offset: 0,
284            fix_replacement: String::new(),
285        });
286    }
287}
288
289/// Parsed result of a ignore comment
290#[derive(Debug)]
291struct ParsedIgnoreComment {
292    /// Rule name to ignore
293    rule_name: String,
294    /// Target line number (the line to ignore errors on)
295    target_line: usize,
296    /// Comment line number (where the comment is located)
297    comment_line: usize,
298    /// Whether this is an inline comment
299    is_inline: bool,
300    /// For inline comments, the content before the comment (trimmed)
301    content_before_comment: Option<String>,
302}
303
304/// Parse a ignore comment from a line
305///
306/// Supports two formats:
307/// 1. Comment-only line: `# nginx-lint:ignore rule-name reason` → targets next line
308/// 2. Inline comment: `directive; # nginx-lint:ignore rule-name reason` → targets current line
309///
310/// Returns:
311/// - `None` if the line does not contain a ignore comment
312/// - `Some(Ok(ParsedIgnoreComment))` if valid
313/// - `Some(Err(warning))` if the comment is malformed
314fn parse_ignore_comment(
315    line: &str,
316    line_number: usize,
317) -> Option<Result<ParsedIgnoreComment, IgnoreWarning>> {
318    const IGNORE_PREFIX: &str = "nginx-lint:ignore";
319
320    // Find the comment marker
321    let comment_start = line.find('#')?;
322    let comment_part = &line[comment_start..];
323    let comment = comment_part.trim_start_matches('#').trim();
324
325    // Check for nginx-lint:ignore prefix
326    let rest = comment.strip_prefix(IGNORE_PREFIX)?;
327    let rest = rest.trim();
328
329    // Determine if this is a comment-only line or inline comment
330    let before_comment_trimmed = line[..comment_start].trim();
331    let is_inline = !before_comment_trimmed.is_empty();
332
333    // Parse rule name and reason
334    let parts: Vec<&str> = rest.splitn(2, |c: char| c.is_whitespace()).collect();
335
336    // Check for missing rule name
337    if parts.is_empty() || parts[0].is_empty() {
338        return Some(Err(IgnoreWarning {
339            line: line_number,
340            message: "nginx-lint:ignore requires a rule name".to_string(),
341            fixes: Vec::new(),
342        }));
343    }
344
345    let rule_name = parts[0].to_string();
346
347    // Check for missing reason
348    if parts.len() < 2 || parts[1].trim().is_empty() {
349        return Some(Err(IgnoreWarning {
350            line: line_number,
351            message: format!("nginx-lint:ignore {} requires a reason", rule_name),
352            fixes: Vec::new(),
353        }));
354    }
355
356    // Inline comment targets current line, comment-only line targets next line
357    let target_line = if is_inline {
358        line_number
359    } else {
360        line_number + 1
361    };
362
363    // Content before comment for inline fixes (preserve leading whitespace, trim trailing)
364    let content_before = if is_inline {
365        Some(line[..comment_start].trim_end().to_string())
366    } else {
367        None
368    };
369
370    Some(Ok(ParsedIgnoreComment {
371        rule_name,
372        target_line,
373        comment_line: line_number,
374        is_inline,
375        content_before_comment: content_before,
376    }))
377}
378
379/// Result of filtering errors with ignore tracker
380#[derive(Debug)]
381pub struct FilterResult {
382    /// Errors that were not ignored
383    pub errors: Vec<LintError>,
384    /// Number of errors that were ignored
385    pub ignored_count: usize,
386    /// Warnings for unused ignore directives
387    pub unused_warnings: Vec<IgnoreWarning>,
388}
389
390/// Filter errors using an ignore tracker, returning remaining errors and ignored count
391pub fn filter_errors(errors: Vec<LintError>, tracker: &mut IgnoreTracker) -> FilterResult {
392    let mut remaining = Vec::new();
393    let mut ignored_count = 0;
394
395    for error in errors {
396        if let Some(line) = error.line
397            && tracker.is_ignored(&error.rule, line)
398        {
399            tracker.mark_used(&error.rule, line);
400            ignored_count += 1;
401            continue;
402        }
403        remaining.push(error);
404    }
405
406    let unused_warnings = tracker.unused_warnings();
407
408    FilterResult {
409        errors: remaining,
410        ignored_count,
411        unused_warnings,
412    }
413}
414
415/// Convert ignore warnings to lint errors
416pub fn warnings_to_errors(warnings: Vec<IgnoreWarning>) -> Vec<LintError> {
417    warnings
418        .into_iter()
419        .map(|warning| {
420            let mut error = LintError::new(
421                "invalid-nginx-lint-ignore",
422                "ignore",
423                &warning.message,
424                Severity::Warning,
425            )
426            .with_location(warning.line, 1);
427
428            for fix in warning.fixes {
429                error = error.with_fix(fix);
430            }
431
432            error
433        })
434        .collect()
435}
436
437/// Prefix for context comments
438const CONTEXT_PREFIX: &str = "nginx-lint:context";
439
440/// Parse context comment from file content
441///
442/// Looks for `# nginx-lint:context http,server` in the first few lines of the file.
443/// Returns the context as a vector of block names, or None if no context comment found.
444///
445/// # Example
446/// ```
447/// use nginx_lint_common::ignore::parse_context_comment;
448///
449/// let content = "# nginx-lint:context http,server\nserver { listen 80; }";
450/// let context = parse_context_comment(content);
451/// assert_eq!(context, Some(vec!["http".to_string(), "server".to_string()]));
452/// ```
453pub fn parse_context_comment(content: &str) -> Option<Vec<String>> {
454    // Only check first 10 lines for context comment
455    for line in content.lines().take(10) {
456        let trimmed = line.trim();
457
458        // Skip empty lines
459        if trimmed.is_empty() {
460            continue;
461        }
462
463        // Must be a comment
464        if !trimmed.starts_with('#') {
465            // If we hit a non-comment, non-empty line, stop looking
466            break;
467        }
468
469        let comment = trimmed.trim_start_matches('#').trim();
470
471        // Check for nginx-lint:context prefix
472        if let Some(rest) = comment.strip_prefix(CONTEXT_PREFIX) {
473            let context_str = rest.trim();
474            if context_str.is_empty() {
475                return None;
476            }
477
478            let context: Vec<String> = context_str
479                .split(',')
480                .map(|s| s.trim().to_string())
481                .filter(|s| !s.is_empty())
482                .collect();
483
484            if context.is_empty() {
485                return None;
486            }
487
488            return Some(context);
489        }
490    }
491
492    None
493}
494
495#[cfg(test)]
496mod tests {
497    use super::*;
498
499    #[test]
500    fn test_parse_valid_ignore_comment() {
501        let result = parse_ignore_comment(
502            "# nginx-lint:ignore server-tokens-enabled for dev environment",
503            5,
504        );
505        assert!(result.is_some());
506        let parsed = result.unwrap().unwrap();
507        assert_eq!(parsed.rule_name, "server-tokens-enabled");
508        assert_eq!(parsed.target_line, 6); // Next line
509        assert_eq!(parsed.comment_line, 5);
510        assert!(!parsed.is_inline);
511        assert!(parsed.content_before_comment.is_none());
512    }
513
514    #[test]
515    fn test_parse_ignore_comment_with_japanese_reason() {
516        let result =
517            parse_ignore_comment("# nginx-lint:ignore server-tokens-enabled 開発環境用", 5);
518        assert!(result.is_some());
519        let parsed = result.unwrap().unwrap();
520        assert_eq!(parsed.rule_name, "server-tokens-enabled");
521        assert_eq!(parsed.target_line, 6);
522        assert_eq!(parsed.comment_line, 5);
523        assert!(!parsed.is_inline);
524        assert!(parsed.content_before_comment.is_none());
525    }
526
527    #[test]
528    fn test_parse_missing_rule_name() {
529        let result = parse_ignore_comment("# nginx-lint:ignore", 5);
530        assert!(result.is_some());
531        let warning = result.unwrap().unwrap_err();
532        assert_eq!(warning.line, 5);
533        assert!(
534            warning
535                .message
536                .contains("nginx-lint:ignore requires a rule name")
537        );
538    }
539
540    #[test]
541    fn test_parse_missing_reason() {
542        let result = parse_ignore_comment("# nginx-lint:ignore server-tokens-enabled", 5);
543        assert!(result.is_some());
544        let warning = result.unwrap().unwrap_err();
545        assert_eq!(warning.line, 5);
546        assert!(
547            warning
548                .message
549                .contains("nginx-lint:ignore server-tokens-enabled requires a reason")
550        );
551    }
552
553    #[test]
554    fn test_parse_not_a_comment() {
555        let result = parse_ignore_comment("server_tokens on;", 5);
556        assert!(result.is_none());
557    }
558
559    #[test]
560    fn test_parse_regular_comment() {
561        let result = parse_ignore_comment("# This is a regular comment", 5);
562        assert!(result.is_none());
563    }
564
565    #[test]
566    fn test_ignore_tracker_is_ignored() {
567        let mut tracker = IgnoreTracker::new();
568        tracker.add_ignore("server-tokens-enabled", 10);
569
570        assert!(tracker.is_ignored("server-tokens-enabled", 10));
571        assert!(!tracker.is_ignored("server-tokens-enabled", 11));
572        assert!(!tracker.is_ignored("other-rule", 10));
573    }
574
575    #[test]
576    fn test_ignore_tracker_from_content() {
577        let content = r#"
578# nginx-lint:ignore server-tokens-enabled dev environment
579server_tokens on;
580"#;
581        let (tracker, warnings) = IgnoreTracker::from_content(content);
582        assert!(warnings.is_empty());
583        assert!(tracker.is_ignored("server-tokens-enabled", 3));
584        assert!(!tracker.is_ignored("server-tokens-enabled", 2));
585    }
586
587    #[test]
588    fn test_ignore_tracker_from_content_with_warnings() {
589        let content = r#"
590# nginx-lint:ignore
591server_tokens on;
592"#;
593        let (_, warnings) = IgnoreTracker::from_content(content);
594        assert_eq!(warnings.len(), 1);
595        assert!(warnings[0].message.contains("requires a rule name"));
596    }
597
598    #[test]
599    fn test_filter_errors() {
600        let mut tracker = IgnoreTracker::new();
601        tracker.add_ignore("server-tokens-enabled", 5);
602
603        let errors = vec![
604            LintError::new(
605                "server-tokens-enabled",
606                "security",
607                "test error",
608                Severity::Warning,
609            )
610            .with_location(5, 1),
611            LintError::new(
612                "server-tokens-enabled",
613                "security",
614                "test error",
615                Severity::Warning,
616            )
617            .with_location(6, 1),
618            LintError::new("other-rule", "security", "test error", Severity::Warning)
619                .with_location(5, 1),
620        ];
621
622        let result = filter_errors(errors, &mut tracker);
623        assert_eq!(result.errors.len(), 2);
624        assert_eq!(result.ignored_count, 1);
625        // Line 5 server-tokens-enabled should be filtered out
626        assert!(
627            result
628                .errors
629                .iter()
630                .all(|e| !(e.rule == "server-tokens-enabled" && e.line == Some(5)))
631        );
632        // The used directive should have no unused warnings for that rule
633        assert!(result.unused_warnings.is_empty());
634    }
635
636    #[test]
637    fn test_filter_errors_without_line_info() {
638        let mut tracker = IgnoreTracker::new();
639        tracker.add_ignore("some-rule", 5);
640
641        let errors = vec![LintError::new(
642            "some-rule",
643            "test",
644            "error without line",
645            Severity::Warning,
646        )];
647
648        let result = filter_errors(errors, &mut tracker);
649        assert_eq!(result.errors.len(), 1); // Should not be filtered
650        assert_eq!(result.ignored_count, 0);
651        // The directive was not used, so there should be an unused warning
652        assert_eq!(result.unused_warnings.len(), 1);
653    }
654
655    #[test]
656    fn test_only_affects_next_line() {
657        let content = r#"
658# nginx-lint:ignore server-tokens-enabled reason
659server_tokens on;
660server_tokens on;
661"#;
662        let (tracker, warnings) = IgnoreTracker::from_content(content);
663        assert!(warnings.is_empty());
664        assert!(tracker.is_ignored("server-tokens-enabled", 3)); // Line after comment
665        assert!(!tracker.is_ignored("server-tokens-enabled", 4)); // Second occurrence
666    }
667
668    #[test]
669    fn test_consecutive_ignore_comments() {
670        // Multiple consecutive ignore comments should all target the same line
671        let content = r#"
672# nginx-lint:ignore server-tokens-enabled reason1
673# nginx-lint:ignore autoindex-enabled reason2
674server_tokens on;
675"#;
676        let (tracker, warnings) = IgnoreTracker::from_content(content);
677        assert!(warnings.is_empty());
678        // Both rules should be ignored on line 4 (the directive line)
679        assert!(tracker.is_ignored("server-tokens-enabled", 4));
680        assert!(tracker.is_ignored("autoindex-enabled", 4));
681        // Should not be ignored on the comment lines themselves
682        assert!(!tracker.is_ignored("server-tokens-enabled", 2));
683        assert!(!tracker.is_ignored("autoindex-enabled", 3));
684    }
685
686    #[test]
687    fn test_three_consecutive_ignore_comments() {
688        let content = r#"
689# nginx-lint:ignore server-tokens-enabled reason1
690# nginx-lint:ignore autoindex-enabled reason2
691# nginx-lint:ignore gzip-not-enabled reason3
692server_tokens on;
693"#;
694        let (tracker, warnings) = IgnoreTracker::from_content(content);
695        assert!(warnings.is_empty());
696        // All three rules should be ignored on line 5
697        assert!(tracker.is_ignored("server-tokens-enabled", 5));
698        assert!(tracker.is_ignored("autoindex-enabled", 5));
699        assert!(tracker.is_ignored("gzip-not-enabled", 5));
700    }
701
702    #[test]
703    fn test_warnings_to_errors() {
704        let warnings = vec![IgnoreWarning {
705            line: 5,
706            message: "test warning".to_string(),
707            fixes: Vec::new(),
708        }];
709
710        let errors = warnings_to_errors(warnings);
711        assert_eq!(errors.len(), 1);
712        assert_eq!(errors[0].rule, "invalid-nginx-lint-ignore");
713        assert_eq!(errors[0].category, "ignore");
714        assert_eq!(errors[0].message, "test warning");
715        assert_eq!(errors[0].severity, Severity::Warning);
716        assert_eq!(errors[0].line, Some(5));
717        assert!(errors[0].fixes.is_empty());
718    }
719
720    #[test]
721    fn test_warnings_to_errors_with_fix() {
722        let warnings = vec![IgnoreWarning {
723            line: 5,
724            message: "test warning".to_string(),
725            fixes: vec![Fix::replace_range(10, 50, "")],
726        }];
727
728        let errors = warnings_to_errors(warnings);
729        assert_eq!(errors.len(), 1);
730        assert!(!errors[0].fixes.is_empty());
731        let fix = &errors[0].fixes[0];
732        assert!(fix.is_range_based());
733        assert_eq!(fix.start_offset, Some(10));
734        assert_eq!(fix.end_offset, Some(50));
735        assert_eq!(fix.new_text, "");
736    }
737
738    #[test]
739    fn test_parse_inline_comment() {
740        let result = parse_ignore_comment(
741            "server_tokens on; # nginx-lint:ignore server-tokens-enabled dev environment",
742            5,
743        );
744        assert!(result.is_some());
745        let parsed = result.unwrap().unwrap();
746        assert_eq!(parsed.rule_name, "server-tokens-enabled");
747        assert_eq!(parsed.target_line, 5); // Same line (inline)
748        assert_eq!(parsed.comment_line, 5);
749        assert!(parsed.is_inline);
750        assert_eq!(
751            parsed.content_before_comment,
752            Some("server_tokens on;".to_string())
753        );
754    }
755
756    #[test]
757    fn test_parse_inline_comment_with_japanese_reason() {
758        let result = parse_ignore_comment(
759            "server_tokens on; # nginx-lint:ignore server-tokens-enabled 開発環境用",
760            5,
761        );
762        assert!(result.is_some());
763        let parsed = result.unwrap().unwrap();
764        assert_eq!(parsed.rule_name, "server-tokens-enabled");
765        assert_eq!(parsed.target_line, 5); // Same line (inline)
766        assert_eq!(parsed.comment_line, 5);
767        assert!(parsed.is_inline);
768        assert_eq!(
769            parsed.content_before_comment,
770            Some("server_tokens on;".to_string())
771        );
772    }
773
774    #[test]
775    fn test_inline_comment_missing_reason() {
776        let result = parse_ignore_comment(
777            "server_tokens on; # nginx-lint:ignore server-tokens-enabled",
778            5,
779        );
780        assert!(result.is_some());
781        let warning = result.unwrap().unwrap_err();
782        assert_eq!(warning.line, 5);
783        assert!(warning.message.contains("requires a reason"));
784    }
785
786    #[test]
787    fn test_ignore_tracker_inline_comment() {
788        let content = r#"
789server_tokens on; # nginx-lint:ignore server-tokens-enabled dev environment
790"#;
791        let (tracker, warnings) = IgnoreTracker::from_content(content);
792        assert!(warnings.is_empty());
793        assert!(tracker.is_ignored("server-tokens-enabled", 2)); // Same line
794        assert!(!tracker.is_ignored("server-tokens-enabled", 3));
795    }
796
797    #[test]
798    fn test_both_comment_styles() {
799        let content = r#"
800# nginx-lint:ignore server-tokens-enabled reason for next line
801server_tokens on;
802autoindex on; # nginx-lint:ignore autoindex-enabled reason for this line
803"#;
804        let (tracker, warnings) = IgnoreTracker::from_content(content);
805        assert!(warnings.is_empty());
806        // Comment-only line targets next line
807        assert!(tracker.is_ignored("server-tokens-enabled", 3));
808        // Inline comment targets same line
809        assert!(tracker.is_ignored("autoindex-enabled", 4));
810    }
811
812    #[test]
813    fn test_unknown_rule_name() {
814        let content = r#"
815# nginx-lint:ignore unknown-rule-name some reason
816server_tokens on;
817"#;
818        let valid_rules: HashSet<String> = ["server-tokens-enabled", "autoindex-enabled"]
819            .iter()
820            .map(|s| s.to_string())
821            .collect();
822        let (_, warnings) = IgnoreTracker::from_content_with_rules(content, Some(&valid_rules));
823        assert_eq!(warnings.len(), 1);
824        assert!(
825            warnings[0]
826                .message
827                .contains("unknown rule 'unknown-rule-name'")
828        );
829    }
830
831    #[test]
832    fn test_unused_ignore_directive() {
833        let content = r#"
834# nginx-lint:ignore server-tokens-enabled reason
835server_tokens off;
836"#;
837        let (mut tracker, _) = IgnoreTracker::from_content(content);
838
839        // No errors to filter
840        let errors: Vec<LintError> = vec![];
841        let result = filter_errors(errors, &mut tracker);
842
843        // Should have unused warning
844        assert_eq!(result.unused_warnings.len(), 1);
845        assert!(
846            result.unused_warnings[0]
847                .message
848                .contains("unused nginx-lint:ignore")
849        );
850        assert!(
851            result.unused_warnings[0]
852                .message
853                .contains("server-tokens-enabled")
854        );
855    }
856
857    #[test]
858    fn test_dormant_rule_suppresses_unused_warning() {
859        let content = r#"
860# nginx-lint:ignore server-tokens-enabled reason
861server_tokens off;
862"#;
863        let (mut tracker, _) = IgnoreTracker::from_content(content);
864
865        // Marking the rule as dormant should suppress the unused warning
866        // even though no error consumed the directive.
867        let mut dormant = HashSet::new();
868        dormant.insert("server-tokens-enabled".to_string());
869        tracker.set_dormant_rules(&dormant);
870
871        let result = filter_errors(Vec::<LintError>::new(), &mut tracker);
872
873        assert!(
874            result.unused_warnings.is_empty(),
875            "dormant rule should suppress unused-ignore warning, got: {:?}",
876            result
877                .unused_warnings
878                .iter()
879                .map(|w| &w.message)
880                .collect::<Vec<_>>()
881        );
882    }
883
884    #[test]
885    fn test_dormant_rule_does_not_affect_other_rules() {
886        let content = r#"
887# nginx-lint:ignore server-tokens-enabled reason
888# nginx-lint:ignore autoindex-enabled reason
889server_tokens off;
890"#;
891        let (mut tracker, _) = IgnoreTracker::from_content(content);
892
893        // Only mark one rule dormant; the other should still produce
894        // the unused warning.
895        let mut dormant = HashSet::new();
896        dormant.insert("server-tokens-enabled".to_string());
897        tracker.set_dormant_rules(&dormant);
898
899        let result = filter_errors(Vec::<LintError>::new(), &mut tracker);
900
901        assert_eq!(result.unused_warnings.len(), 1);
902        assert!(
903            result.unused_warnings[0]
904                .message
905                .contains("autoindex-enabled")
906        );
907    }
908
909    #[test]
910    fn test_used_ignore_directive_no_warning() {
911        let content = r#"
912# nginx-lint:ignore server-tokens-enabled reason
913server_tokens on;
914"#;
915        let (mut tracker, _) = IgnoreTracker::from_content(content);
916
917        // Create an error that will be filtered
918        let errors = vec![
919            LintError::new(
920                "server-tokens-enabled",
921                "security",
922                "test error",
923                Severity::Warning,
924            )
925            .with_location(3, 1),
926        ];
927
928        let result = filter_errors(errors, &mut tracker);
929
930        // Should have no unused warnings
931        assert!(result.unused_warnings.is_empty());
932        assert_eq!(result.ignored_count, 1);
933    }
934
935    #[test]
936    fn test_unused_comment_only_line_fix() {
937        let content = "\n# nginx-lint:ignore server-tokens-enabled reason\nserver_tokens off;\n";
938        let (mut tracker, _) = IgnoreTracker::from_content(content);
939
940        let errors: Vec<LintError> = vec![];
941        let result = filter_errors(errors, &mut tracker);
942
943        // Should have unused warning with range-based delete fix
944        assert_eq!(result.unused_warnings.len(), 1);
945        let fix = &result.unused_warnings[0].fixes[0];
946        assert!(fix.is_range_based());
947        // Should delete the comment line including trailing newline
948        assert_eq!(fix.new_text, "");
949        // Verify the fix removes the correct range
950        let mut fixed = content.to_string();
951        fixed.replace_range(
952            fix.start_offset.unwrap()..fix.end_offset.unwrap(),
953            &fix.new_text,
954        );
955        assert_eq!(fixed, "\nserver_tokens off;\n");
956    }
957
958    #[test]
959    fn test_unused_inline_comment_fix() {
960        let content = "\nserver_tokens off; # nginx-lint:ignore server-tokens-enabled reason\n";
961        let (mut tracker, _) = IgnoreTracker::from_content(content);
962
963        let errors: Vec<LintError> = vec![];
964        let result = filter_errors(errors, &mut tracker);
965
966        // Should have unused warning with range-based replace fix
967        assert_eq!(result.unused_warnings.len(), 1);
968        let fix = &result.unused_warnings[0].fixes[0];
969        assert!(fix.is_range_based());
970        assert_eq!(fix.new_text, "server_tokens off;");
971        // Verify the fix replaces the correct range
972        let mut fixed = content.to_string();
973        fixed.replace_range(
974            fix.start_offset.unwrap()..fix.end_offset.unwrap(),
975            &fix.new_text,
976        );
977        assert_eq!(fixed, "\nserver_tokens off;\n");
978    }
979
980    #[test]
981    fn test_unused_comment_on_first_line_fix() {
982        let content = "# nginx-lint:ignore server-tokens-enabled reason\nserver_tokens off;\n";
983        let (mut tracker, _) = IgnoreTracker::from_content(content);
984
985        let errors: Vec<LintError> = vec![];
986        let result = filter_errors(errors, &mut tracker);
987
988        assert_eq!(result.unused_warnings.len(), 1);
989        let fix = &result.unused_warnings[0].fixes[0];
990        assert!(fix.is_range_based());
991        let mut fixed = content.to_string();
992        fixed.replace_range(
993            fix.start_offset.unwrap()..fix.end_offset.unwrap(),
994            &fix.new_text,
995        );
996        assert_eq!(fixed, "server_tokens off;\n");
997    }
998
999    #[test]
1000    fn test_unused_comment_on_last_line_no_trailing_newline_fix() {
1001        let content = "server_tokens off;\n# nginx-lint:ignore server-tokens-enabled reason";
1002        let (mut tracker, _) = IgnoreTracker::from_content(content);
1003
1004        let errors: Vec<LintError> = vec![];
1005        let result = filter_errors(errors, &mut tracker);
1006
1007        assert_eq!(result.unused_warnings.len(), 1);
1008        let fix = &result.unused_warnings[0].fixes[0];
1009        assert!(fix.is_range_based());
1010        let mut fixed = content.to_string();
1011        fixed.replace_range(
1012            fix.start_offset.unwrap()..fix.end_offset.unwrap(),
1013            &fix.new_text,
1014        );
1015        // Should remove the preceding newline + comment line
1016        assert_eq!(fixed, "server_tokens off;");
1017    }
1018
1019    // Context comment tests
1020
1021    #[test]
1022    fn test_parse_context_comment_simple() {
1023        let content = "# nginx-lint:context http\nserver { listen 80; }";
1024        let context = parse_context_comment(content);
1025        assert_eq!(context, Some(vec!["http".to_string()]));
1026    }
1027
1028    #[test]
1029    fn test_parse_context_comment_multiple() {
1030        let content = "# nginx-lint:context http,server\nlocation / { }";
1031        let context = parse_context_comment(content);
1032        assert_eq!(
1033            context,
1034            Some(vec!["http".to_string(), "server".to_string()])
1035        );
1036    }
1037
1038    #[test]
1039    fn test_parse_context_comment_with_spaces() {
1040        let content = "# nginx-lint:context http, server\nlocation / { }";
1041        let context = parse_context_comment(content);
1042        assert_eq!(
1043            context,
1044            Some(vec!["http".to_string(), "server".to_string()])
1045        );
1046    }
1047
1048    #[test]
1049    fn test_parse_context_comment_after_empty_lines() {
1050        let content = "\n\n# nginx-lint:context http\nserver { }";
1051        let context = parse_context_comment(content);
1052        assert_eq!(context, Some(vec!["http".to_string()]));
1053    }
1054
1055    #[test]
1056    fn test_parse_context_comment_after_other_comments() {
1057        let content = "# Some description\n# nginx-lint:context http\nserver { }";
1058        let context = parse_context_comment(content);
1059        assert_eq!(context, Some(vec!["http".to_string()]));
1060    }
1061
1062    #[test]
1063    fn test_parse_context_comment_not_found() {
1064        let content = "server { listen 80; }";
1065        let context = parse_context_comment(content);
1066        assert_eq!(context, None);
1067    }
1068
1069    #[test]
1070    fn test_parse_context_comment_after_directive() {
1071        // Context comment after a directive should not be found
1072        let content = "server { }\n# nginx-lint:context http";
1073        let context = parse_context_comment(content);
1074        assert_eq!(context, None);
1075    }
1076
1077    #[test]
1078    fn test_parse_context_comment_empty_value() {
1079        let content = "# nginx-lint:context\nserver { }";
1080        let context = parse_context_comment(content);
1081        assert_eq!(context, None);
1082    }
1083}