syncable_cli/analyzer/helmlint/
pragma.rs

1//! Pragma support for inline rule ignoring.
2//!
3//! Supports comment-based rule ignoring in Helm templates and YAML files:
4//! - `# helmlint-ignore HL1001,HL1002` - ignore specific rules for next line
5//! - `# helmlint-ignore-file` - ignore all rules for entire file
6//! - `# helmlint-ignore-file HL1001` - ignore specific rule for entire file
7//! - `{{/* helmlint-ignore HL1001 */}}` - template comment format
8
9use std::collections::{HashMap, HashSet};
10
11use crate::analyzer::helmlint::types::RuleCode;
12
13/// State for pragma processing.
14#[derive(Debug, Clone, Default)]
15pub struct PragmaState {
16    /// Rules ignored for the entire file.
17    pub file_ignores: HashSet<String>,
18    /// Rules ignored for specific lines (line -> set of rule codes).
19    pub line_ignores: HashMap<u32, HashSet<String>>,
20    /// Whether the entire file is ignored.
21    pub file_disabled: bool,
22}
23
24impl PragmaState {
25    /// Create a new empty pragma state.
26    pub fn new() -> Self {
27        Self::default()
28    }
29
30    /// Check if a rule is ignored for a specific line.
31    pub fn is_ignored(&self, code: &RuleCode, line: u32) -> bool {
32        if self.file_disabled {
33            return true;
34        }
35
36        if self.file_ignores.contains(code.as_str()) {
37            return true;
38        }
39
40        // Check if the rule is ignored for this specific line
41        if let Some(ignores) = self.line_ignores.get(&line) {
42            if ignores.contains(code.as_str()) {
43                return true;
44            }
45        }
46
47        // Check if previous line has an ignore pragma for this line
48        if line > 1 {
49            if let Some(ignores) = self.line_ignores.get(&(line - 1)) {
50                if ignores.contains(code.as_str()) {
51                    return true;
52                }
53            }
54        }
55
56        false
57    }
58
59    /// Add a file-level ignore for a rule.
60    pub fn add_file_ignore(&mut self, code: impl Into<String>) {
61        self.file_ignores.insert(code.into());
62    }
63
64    /// Add a line-level ignore for a rule.
65    pub fn add_line_ignore(&mut self, line: u32, code: impl Into<String>) {
66        self.line_ignores
67            .entry(line)
68            .or_default()
69            .insert(code.into());
70    }
71
72    /// Set the file as completely disabled.
73    pub fn disable_file(&mut self) {
74        self.file_disabled = true;
75    }
76}
77
78/// Extract pragmas from YAML content (values.yaml, Chart.yaml).
79pub fn extract_yaml_pragmas(content: &str) -> PragmaState {
80    let mut state = PragmaState::new();
81
82    for (line_num, line) in content.lines().enumerate() {
83        let line_number = (line_num + 1) as u32;
84        let trimmed = line.trim();
85
86        // Check for YAML comments
87        if let Some(comment) = trimmed.strip_prefix('#') {
88            process_comment(comment.trim(), line_number, &mut state);
89        }
90    }
91
92    state
93}
94
95/// Extract pragmas from template content.
96pub fn extract_template_pragmas(content: &str) -> PragmaState {
97    let mut state = PragmaState::new();
98
99    // Process YAML-style comments
100    for (line_num, line) in content.lines().enumerate() {
101        let line_number = (line_num + 1) as u32;
102        let trimmed = line.trim();
103
104        // Check for YAML comments (outside of templates)
105        if let Some(comment) = trimmed.strip_prefix('#') {
106            // Make sure it's not inside a template action
107            if !line.contains("{{") || line.find('#') < line.find("{{") {
108                process_comment(comment.trim(), line_number, &mut state);
109            }
110        }
111    }
112
113    // Process template comments {{/* ... */}}
114    let mut line_num: u32 = 1;
115    let mut i = 0;
116    let chars: Vec<char> = content.chars().collect();
117
118    while i < chars.len() {
119        if chars[i] == '\n' {
120            line_num += 1;
121            i += 1;
122            continue;
123        }
124
125        // Look for template comment start
126        if i + 4 < chars.len()
127            && chars[i] == '{'
128            && chars[i + 1] == '{'
129            && (chars[i + 2] == '/'
130                || (chars[i + 2] == '-' && i + 5 < chars.len() && chars[i + 3] == '/'))
131        {
132            let _comment_start = i;
133            let comment_line = line_num;
134
135            // Skip to comment content
136            i += 2;
137            if chars[i] == '-' {
138                i += 1;
139            }
140            i += 2; // skip /*
141
142            // Find comment end
143            let mut comment_content = String::new();
144            while i + 3 < chars.len() {
145                if chars[i] == '\n' {
146                    line_num += 1;
147                }
148                if chars[i] == '*' && chars[i + 1] == '/' {
149                    i += 2;
150                    // Skip optional trim marker and closing braces
151                    if i < chars.len() && chars[i] == '-' {
152                        i += 1;
153                    }
154                    if i + 1 < chars.len() && chars[i] == '}' && chars[i + 1] == '}' {
155                        i += 2;
156                    }
157                    break;
158                }
159                comment_content.push(chars[i]);
160                i += 1;
161            }
162
163            // Process the comment
164            process_comment(comment_content.trim(), comment_line, &mut state);
165            continue;
166        }
167
168        i += 1;
169    }
170
171    state
172}
173
174/// Process a comment for pragma directives.
175fn process_comment(comment: &str, line: u32, state: &mut PragmaState) {
176    let lower = comment.to_lowercase();
177
178    // Check for file-level disable
179    if lower.starts_with("helmlint-ignore-file") || lower.starts_with("helmlint-disable-file") {
180        let rest = comment
181            .strip_prefix("helmlint-ignore-file")
182            .or_else(|| comment.strip_prefix("helmlint-disable-file"))
183            .unwrap_or("")
184            .trim();
185
186        if rest.is_empty() {
187            state.disable_file();
188        } else {
189            // Parse specific rules to ignore for the file
190            for code in parse_rule_list(rest) {
191                state.add_file_ignore(code);
192            }
193        }
194        return;
195    }
196
197    // Check for line-level ignore
198    if lower.starts_with("helmlint-ignore") || lower.starts_with("helmlint-disable") {
199        let rest = comment
200            .strip_prefix("helmlint-ignore")
201            .or_else(|| comment.strip_prefix("helmlint-disable"))
202            .unwrap_or("")
203            .trim();
204
205        if rest.is_empty() {
206            // Ignore all rules for next line - we'll use a special marker
207            state.add_line_ignore(line, "*");
208        } else {
209            for code in parse_rule_list(rest) {
210                state.add_line_ignore(line, code);
211            }
212        }
213    }
214}
215
216/// Parse a comma-separated list of rule codes.
217fn parse_rule_list(input: &str) -> Vec<String> {
218    input
219        .split([',', ' '])
220        .map(|s| s.trim())
221        .filter(|s| !s.is_empty() && s.starts_with("HL"))
222        .map(|s| s.to_string())
223        .collect()
224}
225
226/// Check if content starts with a file-level disable comment.
227pub fn starts_with_disable_file_comment(content: &str) -> bool {
228    for line in content.lines().take(10) {
229        let trimmed = line.trim();
230        if trimmed.is_empty() {
231            continue;
232        }
233        if let Some(comment) = trimmed.strip_prefix('#') {
234            let comment_lower = comment.trim().to_lowercase();
235            if comment_lower.starts_with("helmlint-ignore-file")
236                || comment_lower.starts_with("helmlint-disable-file")
237            {
238                // Check if it's a full file disable (no specific rules)
239                let rest = comment
240                    .trim()
241                    .strip_prefix("helmlint-ignore-file")
242                    .or_else(|| comment.trim().strip_prefix("helmlint-disable-file"))
243                    .unwrap_or("")
244                    .trim();
245                if rest.is_empty() {
246                    return true;
247                }
248            }
249        }
250        // Only check the first non-empty, non-comment-only lines
251        if !trimmed.starts_with('#') {
252            break;
253        }
254    }
255    false
256}
257
258#[cfg(test)]
259mod tests {
260    use super::*;
261
262    #[test]
263    fn test_yaml_pragma_ignore() {
264        let content = r#"
265# helmlint-ignore HL1001
266name: test-chart
267version: 1.0.0
268"#;
269        let state = extract_yaml_pragmas(content);
270        assert!(state.is_ignored(&RuleCode::new("HL1001"), 3));
271        assert!(!state.is_ignored(&RuleCode::new("HL1002"), 3));
272    }
273
274    #[test]
275    fn test_yaml_pragma_file_ignore() {
276        let content = r#"
277# helmlint-ignore-file HL1001,HL1002
278name: test-chart
279"#;
280        let state = extract_yaml_pragmas(content);
281        assert!(state.is_ignored(&RuleCode::new("HL1001"), 3));
282        assert!(state.is_ignored(&RuleCode::new("HL1002"), 10));
283        assert!(!state.is_ignored(&RuleCode::new("HL1003"), 3));
284    }
285
286    #[test]
287    fn test_yaml_pragma_disable_file() {
288        let content = r#"
289# helmlint-ignore-file
290name: test-chart
291"#;
292        let state = extract_yaml_pragmas(content);
293        assert!(state.file_disabled);
294        assert!(state.is_ignored(&RuleCode::new("HL1001"), 3));
295        assert!(state.is_ignored(&RuleCode::new("HL9999"), 100));
296    }
297
298    #[test]
299    fn test_template_pragma() {
300        let content = r#"
301{{/* helmlint-ignore HL3001 */}}
302{{ .Values.name }}
303"#;
304        let state = extract_template_pragmas(content);
305        assert!(state.is_ignored(&RuleCode::new("HL3001"), 3));
306    }
307
308    #[test]
309    fn test_template_pragma_file_ignore() {
310        let content = r#"
311{{/* helmlint-ignore-file HL3001 */}}
312apiVersion: v1
313kind: ConfigMap
314"#;
315        let state = extract_template_pragmas(content);
316        assert!(state.is_ignored(&RuleCode::new("HL3001"), 3));
317        assert!(state.is_ignored(&RuleCode::new("HL3001"), 4));
318    }
319
320    #[test]
321    fn test_multiple_rules() {
322        let content = r#"
323# helmlint-ignore HL1001, HL1002, HL1003
324apiVersion: v2
325"#;
326        let state = extract_yaml_pragmas(content);
327        assert!(state.is_ignored(&RuleCode::new("HL1001"), 3));
328        assert!(state.is_ignored(&RuleCode::new("HL1002"), 3));
329        assert!(state.is_ignored(&RuleCode::new("HL1003"), 3));
330    }
331
332    #[test]
333    fn test_starts_with_disable_file() {
334        let content = r#"# helmlint-ignore-file
335apiVersion: v2
336"#;
337        assert!(starts_with_disable_file_comment(content));
338
339        let content_with_rules = r#"# helmlint-ignore-file HL1001
340apiVersion: v2
341"#;
342        assert!(!starts_with_disable_file_comment(content_with_rules));
343
344        let content_normal = r#"apiVersion: v2
345name: test
346"#;
347        assert!(!starts_with_disable_file_comment(content_normal));
348    }
349
350    #[test]
351    fn test_disable_alias() {
352        let content = r#"
353# helmlint-disable HL1001
354apiVersion: v2
355"#;
356        let state = extract_yaml_pragmas(content);
357        assert!(state.is_ignored(&RuleCode::new("HL1001"), 3));
358    }
359}