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