Skip to main content

dockerfile_roast/
parser.rs

1/// Dockerfile parser — produces a list of instructions with line numbers.
2
3#[derive(Debug, Clone, PartialEq)]
4pub struct Instruction {
5    pub line: usize,
6    pub instruction: String,
7    pub arguments: String,
8    pub raw: String,
9}
10
11pub fn parse(content: &str) -> Vec<Instruction> {
12    let mut instructions = Vec::new();
13    let mut pending_line: Option<usize> = None;
14    let mut pending_parts: Vec<String> = Vec::new();
15    let mut pending_instr: Option<String> = None;
16
17    for (idx, raw_line) in content.lines().enumerate() {
18        let line_no = idx + 1;
19        let trimmed = raw_line.trim();
20
21        // If we're inside a continuation, collect
22        if let Some(ref instr) = pending_instr.clone() {
23            // Docker strips comment lines and empty lines inside continuations
24            // (the continuation keeps going), so they must not terminate the
25            // instruction even though they don't end with a backslash.
26            if trimmed.is_empty() || trimmed.starts_with('#') {
27                continue;
28            }
29            // Strip trailing backslash
30            let continued = if trimmed.ends_with('\\') {
31                &trimmed[..trimmed.len() - 1]
32            } else {
33                trimmed
34            };
35            pending_parts.push(continued.trim().to_string());
36
37            if !trimmed.ends_with('\\') {
38                let args = pending_parts.join(" ").trim().to_string();
39                let first_line = pending_line.unwrap();
40                instructions.push(Instruction {
41                    line: first_line,
42                    instruction: instr.to_uppercase(),
43                    arguments: args,
44                    raw: raw_line.to_string(),
45                });
46                pending_instr = None;
47                pending_line = None;
48                pending_parts.clear();
49            }
50            continue;
51        }
52
53        // Skip empty lines and comments
54        if trimmed.is_empty() || trimmed.starts_with('#') {
55            continue;
56        }
57
58        // Split instruction keyword from arguments
59        let (kw, rest) = match trimmed.split_once(|c: char| c.is_whitespace()) {
60            Some((k, r)) => (k.to_uppercase(), r.trim().to_string()),
61            None => (trimmed.to_uppercase(), String::new()),
62        };
63
64        if rest.ends_with('\\') {
65            // Multi-line instruction
66            pending_instr = Some(kw);
67            pending_line = Some(line_no);
68            pending_parts.push(rest[..rest.len() - 1].trim().to_string());
69        } else {
70            instructions.push(Instruction {
71                line: line_no,
72                instruction: kw,
73                arguments: rest,
74                raw: trimmed.to_string(),
75            });
76        }
77    }
78
79    // Handle unterminated continuation at EOF
80    if let Some(instr) = pending_instr {
81        let args = pending_parts.join(" ").trim().to_string();
82        instructions.push(Instruction {
83            line: pending_line.unwrap(),
84            instruction: instr,
85            arguments: args,
86            raw: String::new(),
87        });
88    }
89
90    instructions
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn test_basic_parse() {
99        let df = "FROM ubuntu:latest\nRUN apt-get install curl\n";
100        let instrs = parse(df);
101        assert_eq!(instrs.len(), 2);
102        assert_eq!(instrs[0].instruction, "FROM");
103        assert_eq!(instrs[0].arguments, "ubuntu:latest");
104        assert_eq!(instrs[1].instruction, "RUN");
105    }
106
107    #[test]
108    fn test_skip_comments() {
109        let df = "# comment\nFROM alpine\n";
110        let instrs = parse(df);
111        assert_eq!(instrs.len(), 1);
112    }
113
114    #[test]
115    fn test_multiline() {
116        let df = "RUN apt-get install \\\n    curl \\\n    wget\n";
117        let instrs = parse(df);
118        assert_eq!(instrs.len(), 1);
119        assert_eq!(instrs[0].instruction, "RUN");
120    }
121
122    #[test]
123    fn test_comment_inside_continuation() {
124        let df = "RUN apt-get install \\\n    curl \\\n    # comment line\n    wget && \\\n    apt-get clean\n";
125        let instrs = parse(df);
126        assert_eq!(instrs.len(), 1);
127        assert_eq!(instrs[0].arguments, "apt-get install curl wget && apt-get clean");
128    }
129
130    #[test]
131    fn test_empty_line_inside_continuation() {
132        let df = "RUN apt-get install \\\n\n    curl\n";
133        let instrs = parse(df);
134        assert_eq!(instrs.len(), 1);
135        assert_eq!(instrs[0].arguments, "apt-get install curl");
136    }
137}