syncable_cli/analyzer/dclint/
pragma.rs

1//! Pragma handling for inline rule disabling.
2//!
3//! Supports comment-based rule disabling similar to ESLint:
4//! - `# dclint-disable` - Disable all rules for the rest of the file
5//! - `# dclint-disable rule-name` - Disable specific rule(s) globally
6//! - `# dclint-disable-next-line` - Disable all rules for the next line
7//! - `# dclint-disable-next-line rule-name` - Disable specific rule(s) for next line
8//! - `# dclint-disable-file` - Disable all rules for the entire file
9
10use std::collections::{HashMap, HashSet};
11
12use crate::analyzer::dclint::types::RuleCode;
13
14/// Tracks which rules are disabled at which lines.
15#[derive(Debug, Clone, Default)]
16pub struct PragmaState {
17    /// Rules disabled for the entire file (global).
18    pub global_disabled: HashSet<String>,
19    /// Whether all rules are disabled globally.
20    pub all_disabled: bool,
21    /// Rules disabled for specific lines.
22    pub line_disabled: HashMap<u32, HashSet<String>>,
23    /// Lines where all rules are disabled.
24    pub all_disabled_lines: HashSet<u32>,
25}
26
27impl PragmaState {
28    pub fn new() -> Self {
29        Self::default()
30    }
31
32    /// Check if a rule is ignored at a specific line.
33    pub fn is_ignored(&self, code: &RuleCode, line: u32) -> bool {
34        // Check global disables
35        if self.all_disabled {
36            return true;
37        }
38        if self.global_disabled.contains(code.as_str()) || self.global_disabled.contains("*") {
39            return true;
40        }
41
42        // Check line-specific disables
43        if self.all_disabled_lines.contains(&line) {
44            return true;
45        }
46        if let Some(rules) = self.line_disabled.get(&line)
47            && (rules.contains("*") || rules.contains(code.as_str()))
48        {
49            return true;
50        }
51
52        false
53    }
54
55    /// Add a globally disabled rule.
56    pub fn disable_global(&mut self, rule: impl Into<String>) {
57        let rule = rule.into();
58        if rule == "*" {
59            self.all_disabled = true;
60        } else {
61            self.global_disabled.insert(rule);
62        }
63    }
64
65    /// Disable rules for a specific line.
66    pub fn disable_line(&mut self, line: u32, rules: Vec<String>) {
67        if rules.is_empty() || rules.iter().any(|r| r == "*") {
68            self.all_disabled_lines.insert(line);
69        } else {
70            self.line_disabled.entry(line).or_default().extend(rules);
71        }
72    }
73}
74
75/// Extract pragmas from source content.
76pub fn extract_pragmas(source: &str) -> PragmaState {
77    let mut state = PragmaState::new();
78    let lines: Vec<&str> = source.lines().collect();
79
80    for (idx, line) in lines.iter().enumerate() {
81        let line_num = (idx + 1) as u32;
82        let trimmed = line.trim();
83
84        // Skip non-comment lines
85        if !trimmed.starts_with('#') {
86            continue;
87        }
88
89        let comment = trimmed.trim_start_matches('#').trim();
90
91        // Check for disable-file (applies to entire file)
92        if let Some(rest) = comment.strip_prefix("dclint-disable-file") {
93            let rules = parse_rule_list(rest);
94            if rules.is_empty() {
95                state.all_disabled = true;
96            } else {
97                for rule in rules {
98                    state.disable_global(rule);
99                }
100            }
101            continue;
102        }
103
104        // Check for disable-next-line
105        if let Some(rest) = comment.strip_prefix("dclint-disable-next-line") {
106            let rules = parse_rule_list(rest);
107            let next_line = line_num + 1;
108
109            if rules.is_empty() {
110                state.all_disabled_lines.insert(next_line);
111            } else {
112                state.disable_line(next_line, rules);
113            }
114            continue;
115        }
116
117        // Check for global disable (at first content line, affects rest of file)
118        if comment.starts_with("dclint-disable") && !comment.starts_with("dclint-disable-") {
119            let rules = parse_rule_list(&comment["dclint-disable".len()..]);
120            if rules.is_empty() {
121                state.all_disabled = true;
122            } else {
123                for rule in rules {
124                    state.disable_global(rule);
125                }
126            }
127            continue;
128        }
129    }
130
131    state
132}
133
134/// Parse a comma-separated list of rule names.
135fn parse_rule_list(s: &str) -> Vec<String> {
136    let trimmed = s.trim();
137    if trimmed.is_empty() {
138        return vec![];
139    }
140
141    trimmed
142        .split(',')
143        .map(|r| r.trim().to_string())
144        .filter(|r| !r.is_empty())
145        .collect()
146}
147
148/// Extract global disable rules from the first comment line.
149/// Returns set of disabled rule names (empty string means all disabled).
150pub fn extract_global_disable_rules(source: &str) -> HashSet<String> {
151    let state = extract_pragmas(source);
152    let mut result = state.global_disabled;
153    if state.all_disabled {
154        result.insert("*".to_string());
155    }
156    result
157}
158
159/// Extract line-specific disable rules.
160/// Returns map of line number -> set of disabled rules.
161pub fn extract_line_disable_rules(source: &str) -> HashMap<u32, HashSet<String>> {
162    let state = extract_pragmas(source);
163    let mut result = state.line_disabled;
164
165    // Add all-disabled lines
166    for line in state.all_disabled_lines {
167        result.entry(line).or_default().insert("*".to_string());
168    }
169
170    result
171}
172
173/// Check if file starts with a disable-file comment.
174pub fn starts_with_disable_file_comment(source: &str) -> bool {
175    for line in source.lines() {
176        let trimmed = line.trim();
177        if trimmed.is_empty() {
178            continue;
179        }
180        if trimmed.starts_with('#') {
181            let comment = trimmed.trim_start_matches('#').trim();
182            return comment.starts_with("dclint-disable-file")
183                || (comment.starts_with("dclint-disable")
184                    && !comment.starts_with("dclint-disable-"));
185        }
186        // First non-empty, non-comment line
187        return false;
188    }
189    false
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn test_extract_global_disable() {
198        let source = "# dclint-disable\nservices:\n  web:\n    image: nginx\n";
199        let state = extract_pragmas(source);
200        assert!(state.all_disabled);
201    }
202
203    #[test]
204    fn test_extract_global_disable_specific_rules() {
205        let source = "# dclint-disable DCL001, DCL002\nservices:\n  web:\n    image: nginx\n";
206        let state = extract_pragmas(source);
207        assert!(!state.all_disabled);
208        assert!(state.global_disabled.contains("DCL001"));
209        assert!(state.global_disabled.contains("DCL002"));
210        assert!(!state.global_disabled.contains("DCL003"));
211    }
212
213    #[test]
214    fn test_extract_disable_next_line() {
215        let source = r#"
216services:
217  # dclint-disable-next-line DCL001
218  web:
219    build: .
220    image: nginx
221"#;
222        let state = extract_pragmas(source);
223        assert!(!state.all_disabled);
224
225        // The disable comment is on line 3, so DCL001 should be disabled on line 4
226        assert!(state.is_ignored(&RuleCode::new("DCL001"), 4));
227        assert!(!state.is_ignored(&RuleCode::new("DCL001"), 5));
228    }
229
230    #[test]
231    fn test_extract_disable_next_line_all() {
232        let source = r#"
233services:
234  # dclint-disable-next-line
235  web:
236    build: .
237    image: nginx
238"#;
239        let state = extract_pragmas(source);
240
241        // All rules disabled on line 4
242        assert!(state.all_disabled_lines.contains(&4));
243        assert!(state.is_ignored(&RuleCode::new("DCL001"), 4));
244        assert!(state.is_ignored(&RuleCode::new("DCL002"), 4));
245    }
246
247    #[test]
248    fn test_extract_disable_file() {
249        let source = "# dclint-disable-file\nservices:\n  web:\n    image: nginx\n";
250        let state = extract_pragmas(source);
251        assert!(state.all_disabled);
252    }
253
254    #[test]
255    fn test_is_ignored() {
256        let source = "# dclint-disable DCL001\nservices:\n  web:\n    image: nginx\n";
257        let state = extract_pragmas(source);
258
259        assert!(state.is_ignored(&RuleCode::new("DCL001"), 1));
260        assert!(state.is_ignored(&RuleCode::new("DCL001"), 5));
261        assert!(!state.is_ignored(&RuleCode::new("DCL002"), 1));
262    }
263
264    #[test]
265    fn test_starts_with_disable_file_comment() {
266        assert!(starts_with_disable_file_comment(
267            "# dclint-disable-file\nservices:"
268        ));
269        assert!(starts_with_disable_file_comment(
270            "# dclint-disable\nservices:"
271        ));
272        assert!(!starts_with_disable_file_comment("services:\n  web:"));
273        assert!(!starts_with_disable_file_comment(
274            "# Some other comment\nservices:"
275        ));
276    }
277
278    #[test]
279    fn test_parse_rule_list() {
280        assert_eq!(parse_rule_list(""), Vec::<String>::new());
281        assert_eq!(parse_rule_list("DCL001"), vec!["DCL001"]);
282        assert_eq!(parse_rule_list("DCL001, DCL002"), vec!["DCL001", "DCL002"]);
283        assert_eq!(
284            parse_rule_list("  DCL001 , DCL002  "),
285            vec!["DCL001", "DCL002"]
286        );
287    }
288}