syncable_cli/analyzer/hadolint/
pragma.rs

1//! Pragma parsing for inline rule ignores.
2//!
3//! Hadolint supports inline pragmas to ignore rules:
4//! - `# hadolint ignore=DL3008,DL3009` - Ignore for next instruction
5//! - `# hadolint global ignore=DL3008` - Ignore for entire file
6//! - `# hadolint shell=/bin/bash` - Set shell for ShellCheck
7
8use crate::analyzer::hadolint::types::RuleCode;
9use std::collections::{HashMap, HashSet};
10
11/// Parsed pragma state for a Dockerfile.
12#[derive(Debug, Clone, Default)]
13pub struct PragmaState {
14    /// Per-line ignored rules: line -> set of ignored codes.
15    pub ignored: HashMap<u32, HashSet<RuleCode>>,
16    /// Globally ignored rules.
17    pub global_ignored: HashSet<RuleCode>,
18    /// Shell override (if specified).
19    pub shell: Option<String>,
20}
21
22impl PragmaState {
23    /// Create a new empty pragma state.
24    pub fn new() -> Self {
25        Self::default()
26    }
27
28    /// Check if a rule should be ignored on a specific line.
29    pub fn is_ignored(&self, code: &RuleCode, line: u32) -> bool {
30        // Check global ignores
31        if self.global_ignored.contains(code) {
32            return true;
33        }
34
35        // Check line-specific ignores (check previous line, as pragma applies to next line)
36        if let Some(ignored) = self.ignored.get(&line) {
37            if ignored.contains(code) {
38                return true;
39            }
40        }
41
42        // Also check if the pragma was on the line before
43        if line > 0 {
44            if let Some(ignored) = self.ignored.get(&(line - 1)) {
45                if ignored.contains(code) {
46                    return true;
47                }
48            }
49        }
50
51        false
52    }
53}
54
55/// Parse pragma from a comment string.
56/// Returns the pragma type and any associated data.
57pub fn parse_pragma(comment: &str) -> Option<Pragma> {
58    let comment = comment.trim();
59
60    // Look for hadolint pragma
61    let pragma_start = comment.find("hadolint")?;
62    let pragma_content = &comment[pragma_start + "hadolint".len()..].trim();
63
64    // Parse global ignore
65    if pragma_content.starts_with("global") {
66        let rest = &pragma_content["global".len()..].trim();
67        if let Some(codes) = parse_ignore_list(rest) {
68            return Some(Pragma::GlobalIgnore(codes));
69        }
70    }
71
72    // Parse ignore
73    if let Some(codes) = parse_ignore_list(pragma_content) {
74        return Some(Pragma::Ignore(codes));
75    }
76
77    // Parse shell
78    if pragma_content.starts_with("shell=") {
79        let shell = &pragma_content["shell=".len()..].trim();
80        return Some(Pragma::Shell(shell.to_string()));
81    }
82
83    None
84}
85
86/// Parse an ignore list from a pragma string.
87fn parse_ignore_list(s: &str) -> Option<Vec<RuleCode>> {
88    let s = s.trim();
89
90    // Look for ignore= pattern
91    if !s.starts_with("ignore=") && !s.starts_with("ignore =") {
92        return None;
93    }
94
95    // Find the = sign and get the codes
96    let eq_pos = s.find('=')?;
97    let codes_str = &s[eq_pos + 1..].trim();
98
99    // Split by comma and parse codes
100    let codes: Vec<RuleCode> = codes_str
101        .split(',')
102        .map(|s| s.trim())
103        .filter(|s| !s.is_empty())
104        .map(|s| RuleCode::new(s))
105        .collect();
106
107    if codes.is_empty() {
108        None
109    } else {
110        Some(codes)
111    }
112}
113
114/// Parsed pragma types.
115#[derive(Debug, Clone)]
116pub enum Pragma {
117    /// Ignore rules for the next instruction.
118    Ignore(Vec<RuleCode>),
119    /// Ignore rules globally for the entire file.
120    GlobalIgnore(Vec<RuleCode>),
121    /// Set shell for ShellCheck analysis.
122    Shell(String),
123}
124
125/// Extract pragma state from Dockerfile instructions.
126pub fn extract_pragmas(instructions: &[crate::analyzer::hadolint::parser::InstructionPos]) -> PragmaState {
127    let mut state = PragmaState::new();
128
129    for instr in instructions {
130        if let crate::analyzer::hadolint::parser::instruction::Instruction::Comment(comment) = &instr.instruction {
131            if let Some(pragma) = parse_pragma(comment) {
132                match pragma {
133                    Pragma::Ignore(codes) => {
134                        // Ignore applies to the next line
135                        let entry = state.ignored.entry(instr.line_number).or_default();
136                        for code in codes {
137                            entry.insert(code);
138                        }
139                    }
140                    Pragma::GlobalIgnore(codes) => {
141                        for code in codes {
142                            state.global_ignored.insert(code);
143                        }
144                    }
145                    Pragma::Shell(shell) => {
146                        state.shell = Some(shell);
147                    }
148                }
149            }
150        }
151    }
152
153    state
154}
155
156#[cfg(test)]
157mod tests {
158    use super::*;
159
160    #[test]
161    fn test_parse_ignore() {
162        let pragma = parse_pragma("# hadolint ignore=DL3008,DL3009").unwrap();
163        match pragma {
164            Pragma::Ignore(codes) => {
165                assert_eq!(codes.len(), 2);
166                assert_eq!(codes[0].as_str(), "DL3008");
167                assert_eq!(codes[1].as_str(), "DL3009");
168            }
169            _ => panic!("Expected Ignore pragma"),
170        }
171    }
172
173    #[test]
174    fn test_parse_global_ignore() {
175        let pragma = parse_pragma("# hadolint global ignore=DL3008").unwrap();
176        match pragma {
177            Pragma::GlobalIgnore(codes) => {
178                assert_eq!(codes.len(), 1);
179                assert_eq!(codes[0].as_str(), "DL3008");
180            }
181            _ => panic!("Expected GlobalIgnore pragma"),
182        }
183    }
184
185    #[test]
186    fn test_parse_shell() {
187        let pragma = parse_pragma("# hadolint shell=/bin/bash").unwrap();
188        match pragma {
189            Pragma::Shell(shell) => {
190                assert_eq!(shell, "/bin/bash");
191            }
192            _ => panic!("Expected Shell pragma"),
193        }
194    }
195
196    #[test]
197    fn test_no_pragma() {
198        assert!(parse_pragma("# This is a regular comment").is_none());
199    }
200
201    #[test]
202    fn test_pragma_state_is_ignored() {
203        let mut state = PragmaState::new();
204
205        // Add line-specific ignore
206        let mut codes = HashSet::new();
207        codes.insert(RuleCode::new("DL3008"));
208        state.ignored.insert(5, codes);
209
210        // Add global ignore
211        state.global_ignored.insert(RuleCode::new("DL3009"));
212
213        // Test line-specific (pragma on line 5 affects line 6)
214        assert!(state.is_ignored(&RuleCode::new("DL3008"), 6));
215        assert!(!state.is_ignored(&RuleCode::new("DL3008"), 10));
216
217        // Test global
218        assert!(state.is_ignored(&RuleCode::new("DL3009"), 1));
219        assert!(state.is_ignored(&RuleCode::new("DL3009"), 100));
220
221        // Test non-ignored
222        assert!(!state.is_ignored(&RuleCode::new("DL3010"), 1));
223    }
224}