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