syncable_cli/analyzer/hadolint/parser/
dockerfile.rs

1//! Dockerfile parser using nom.
2//!
3//! Parses Dockerfile content into an AST of `InstructionPos` elements.
4
5use nom::{
6    branch::alt,
7    bytes::complete::{tag, tag_no_case, take_till, take_while},
8    character::complete::{char, space0, space1},
9    combinator::opt,
10    multi::separated_list0,
11    sequence::{pair, preceded, tuple},
12    IResult,
13};
14
15use super::instruction::*;
16
17/// Parse error information.
18#[derive(Debug, Clone)]
19pub struct ParseError {
20    /// Error message.
21    pub message: String,
22    /// Line number where the error occurred (1-indexed).
23    pub line: u32,
24    /// Column number (1-indexed, if available).
25    pub column: Option<u32>,
26}
27
28impl std::fmt::Display for ParseError {
29    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
30        match self.column {
31            Some(col) => write!(f, "line {}:{}: {}", self.line, col, self.message),
32            None => write!(f, "line {}: {}", self.line, self.message),
33        }
34    }
35}
36
37impl std::error::Error for ParseError {}
38
39/// Parse a Dockerfile string into a list of positioned instructions.
40pub fn parse_dockerfile(input: &str) -> Result<Vec<InstructionPos>, ParseError> {
41    let mut instructions = Vec::new();
42    let mut line_number = 1u32;
43
44    // Process line by line, handling line continuations
45    let lines: Vec<&str> = input.lines().collect();
46    let mut i = 0;
47
48    while i < lines.len() {
49        let start_line = line_number;
50        let mut combined_line = String::new();
51        let mut source_text = String::new();
52
53        // Collect lines with continuations
54        loop {
55            let line = lines.get(i).unwrap_or(&"");
56            source_text.push_str(line);
57            source_text.push('\n');
58
59            let trimmed = line.trim_end();
60            if trimmed.ends_with('\\') {
61                // Line continuation - remove backslash and continue
62                combined_line.push_str(&trimmed[..trimmed.len() - 1]);
63                combined_line.push(' ');
64                i += 1;
65                line_number += 1;
66                if i >= lines.len() {
67                    break;
68                }
69            } else {
70                combined_line.push_str(trimmed);
71                i += 1;
72                line_number += 1;
73                break;
74            }
75        }
76
77        let trimmed = combined_line.trim();
78
79        // Skip empty lines
80        if trimmed.is_empty() {
81            continue;
82        }
83
84        // Parse the instruction
85        match parse_instruction(trimmed) {
86            Ok((_, instruction)) => {
87                instructions.push(InstructionPos::new(
88                    instruction,
89                    start_line,
90                    source_text.trim_end().to_string(),
91                ));
92            }
93            Err(_) => {
94                // Try to parse as comment
95                if trimmed.starts_with('#') {
96                    let comment = trimmed[1..].trim().to_string();
97                    instructions.push(InstructionPos::new(
98                        Instruction::Comment(comment),
99                        start_line,
100                        source_text.trim_end().to_string(),
101                    ));
102                }
103                // Skip unparseable lines (parser directives, empty lines after continuation, etc.)
104            }
105        }
106    }
107
108    Ok(instructions)
109}
110
111/// Parse a single instruction.
112fn parse_instruction(input: &str) -> IResult<&str, Instruction> {
113    alt((
114        parse_from,
115        parse_run,
116        parse_copy,
117        parse_add,
118        parse_env,
119        parse_label,
120        parse_expose,
121        parse_arg,
122        parse_entrypoint,
123        parse_cmd,
124        parse_shell,
125        parse_user,
126        parse_workdir,
127        parse_volume,
128        parse_maintainer,
129        parse_healthcheck,
130        parse_onbuild,
131        parse_stopsignal,
132        parse_comment,
133    ))(input)
134}
135
136/// Parse FROM instruction.
137fn parse_from(input: &str) -> IResult<&str, Instruction> {
138    let (input, _) = tag_no_case("FROM")(input)?;
139    let (input, _) = space1(input)?;
140
141    // Parse optional --platform flag
142    let (input, platform) = opt(preceded(
143        pair(tag("--platform="), space0),
144        take_till(|c: char| c.is_whitespace()),
145    ))(input)?;
146    let (input, _) = space0(input)?;
147
148    // Parse platform with space separator
149    let (input, platform) = if platform.is_none() {
150        opt(preceded(
151            pair(tag("--platform"), space0),
152            preceded(char('='), take_till(|c: char| c.is_whitespace())),
153        ))(input)?
154    } else {
155        (input, platform)
156    };
157    let (input, _) = space0(input)?;
158
159    // Parse image reference
160    let (input, image_ref) = take_till(|c: char| c.is_whitespace())(input)?;
161    let (input, _) = space0(input)?;
162
163    // Parse optional AS alias
164    let (input, alias) = opt(preceded(
165        pair(tag_no_case("AS"), space1),
166        take_while(|c: char| c.is_alphanumeric() || c == '_' || c == '-'),
167    ))(input)?;
168
169    // Parse image reference into components
170    let base_image = parse_image_reference(image_ref, platform.map(|s| s.to_string()), alias.map(|s| ImageAlias::new(s)));
171
172    Ok((input, Instruction::From(base_image)))
173}
174
175/// Parse image reference into BaseImage.
176fn parse_image_reference(
177    image_ref: &str,
178    platform: Option<String>,
179    alias: Option<ImageAlias>,
180) -> BaseImage {
181    // Handle digest
182    if let Some(at_pos) = image_ref.find('@') {
183        let (image_part, digest) = image_ref.split_at(at_pos);
184        let digest = &digest[1..]; // Remove @
185
186        let (image, tag) = parse_image_tag(image_part);
187        return BaseImage {
188            image,
189            tag,
190            digest: Some(digest.to_string()),
191            alias,
192            platform,
193        };
194    }
195
196    // Handle tag
197    let (image, tag) = parse_image_tag(image_ref);
198
199    BaseImage {
200        image,
201        tag,
202        digest: None,
203        alias,
204        platform,
205    }
206}
207
208/// Parse image:tag into Image and optional tag.
209fn parse_image_tag(image_ref: &str) -> (Image, Option<String>) {
210    // Find the last colon that's not part of a port or registry
211    // Registry format: host:port/name or host/name
212    // Tag format: name:tag
213
214    let parts: Vec<&str> = image_ref.split('/').collect();
215
216    if parts.len() == 1 {
217        // Simple name or name:tag
218        if let Some(colon_pos) = image_ref.rfind(':') {
219            let name = &image_ref[..colon_pos];
220            let tag = &image_ref[colon_pos + 1..];
221            (Image::new(name), Some(tag.to_string()))
222        } else {
223            (Image::new(image_ref), None)
224        }
225    } else {
226        // Has path separators - might have registry
227        let last_part = parts.last().unwrap();
228
229        // Check if last part has a tag
230        if let Some(colon_pos) = last_part.rfind(':') {
231            // Check if it looks like a tag (not a port)
232            let potential_tag = &last_part[colon_pos + 1..];
233            if !potential_tag.chars().all(|c| c.is_ascii_digit()) || potential_tag.len() > 5 {
234                // It's a tag, not a port
235                let full_name = image_ref[..image_ref.len() - potential_tag.len() - 1].to_string();
236                let (registry, name) = split_registry(&full_name);
237                return (
238                    match registry {
239                        Some(r) => Image::with_registry(r, name),
240                        None => Image::new(name),
241                    },
242                    Some(potential_tag.to_string()),
243                );
244            }
245        }
246
247        // No tag
248        let (registry, name) = split_registry(image_ref);
249        (
250            match registry {
251                Some(r) => Image::with_registry(r, name),
252                None => Image::new(name),
253            },
254            None,
255        )
256    }
257}
258
259/// Split registry from image name.
260fn split_registry(name: &str) -> (Option<String>, String) {
261    // Registry indicators: contains '.', ':', or is 'localhost'
262    if let Some(slash_pos) = name.find('/') {
263        let potential_registry = &name[..slash_pos];
264        if potential_registry.contains('.')
265            || potential_registry.contains(':')
266            || potential_registry == "localhost"
267        {
268            return (
269                Some(potential_registry.to_string()),
270                name[slash_pos + 1..].to_string(),
271            );
272        }
273    }
274    (None, name.to_string())
275}
276
277/// Parse RUN instruction.
278fn parse_run(input: &str) -> IResult<&str, Instruction> {
279    let (input, _) = tag_no_case("RUN")(input)?;
280    let (input, _) = space0(input)?;
281
282    // Parse flags (--mount, --network, --security)
283    let (input, flags) = parse_run_flags(input)?;
284    let (input, _) = space0(input)?;
285
286    // Parse arguments (exec form or shell form)
287    let (input, arguments) = parse_arguments(input)?;
288
289    Ok((input, Instruction::Run(RunArgs { arguments, flags })))
290}
291
292/// Parse RUN flags.
293fn parse_run_flags(input: &str) -> IResult<&str, RunFlags> {
294    let mut flags = RunFlags::default();
295    let mut remaining = input;
296
297    loop {
298        let (input, _) = space0(remaining)?;
299
300        // Check for --mount
301        if let Ok((input, mount)) = parse_mount_flag(input) {
302            flags.mount.insert(mount);
303            remaining = input;
304            continue;
305        }
306
307        // Check for --network
308        if let Ok((input, network)) = parse_flag_value(input, "--network") {
309            flags.network = Some(network.to_string());
310            remaining = input;
311            continue;
312        }
313
314        // Check for --security
315        if let Ok((input, security)) = parse_flag_value(input, "--security") {
316            flags.security = Some(security.to_string());
317            remaining = input;
318            continue;
319        }
320
321        break;
322    }
323
324    Ok((remaining, flags))
325}
326
327/// Parse --flag=value.
328fn parse_flag_value<'a>(input: &'a str, flag: &str) -> IResult<&'a str, &'a str> {
329    let (input, _) = tag(flag)(input)?;
330    let (input, _) = char('=')(input)?;
331    take_till(|c: char| c.is_whitespace())(input)
332}
333
334/// Parse --mount flag.
335fn parse_mount_flag(input: &str) -> IResult<&str, RunMount> {
336    let (input, _) = tag("--mount=")(input)?;
337    let (input, mount_str) = take_till(|c: char| c.is_whitespace())(input)?;
338
339    // Parse mount options
340    let mount = parse_mount_options(mount_str);
341    Ok((input, mount))
342}
343
344/// Parse mount options string.
345fn parse_mount_options(s: &str) -> RunMount {
346    let opts: std::collections::HashMap<&str, &str> = s
347        .split(',')
348        .filter_map(|part| {
349            let mut parts = part.splitn(2, '=');
350            let key = parts.next()?;
351            let value = parts.next().unwrap_or("");
352            Some((key, value))
353        })
354        .collect();
355
356    let mount_type = opts.get("type").copied().unwrap_or("bind");
357
358    match mount_type {
359        "cache" => RunMount::Cache(CacheOpts {
360            target: opts.get("target").map(|s| s.to_string()),
361            id: opts.get("id").map(|s| s.to_string()),
362            sharing: opts.get("sharing").map(|s| s.to_string()),
363            from: opts.get("from").map(|s| s.to_string()),
364            source: opts.get("source").map(|s| s.to_string()),
365            mode: opts.get("mode").map(|s| s.to_string()),
366            uid: opts.get("uid").and_then(|s| s.parse().ok()),
367            gid: opts.get("gid").and_then(|s| s.parse().ok()),
368            read_only: opts.get("ro").is_some() || opts.get("readonly").is_some(),
369        }),
370        "tmpfs" => RunMount::Tmpfs(TmpOpts {
371            target: opts.get("target").map(|s| s.to_string()),
372            size: opts.get("size").map(|s| s.to_string()),
373        }),
374        "secret" => RunMount::Secret(SecretOpts {
375            id: opts.get("id").map(|s| s.to_string()),
376            target: opts.get("target").map(|s| s.to_string()),
377            required: opts.get("required").map(|s| *s == "true").unwrap_or(false),
378            mode: opts.get("mode").map(|s| s.to_string()),
379            uid: opts.get("uid").and_then(|s| s.parse().ok()),
380            gid: opts.get("gid").and_then(|s| s.parse().ok()),
381        }),
382        "ssh" => RunMount::Ssh(SshOpts {
383            id: opts.get("id").map(|s| s.to_string()),
384            target: opts.get("target").map(|s| s.to_string()),
385            required: opts.get("required").map(|s| *s == "true").unwrap_or(false),
386            mode: opts.get("mode").map(|s| s.to_string()),
387            uid: opts.get("uid").and_then(|s| s.parse().ok()),
388            gid: opts.get("gid").and_then(|s| s.parse().ok()),
389        }),
390        _ => RunMount::Bind(BindOpts {
391            target: opts.get("target").map(|s| s.to_string()),
392            source: opts.get("source").map(|s| s.to_string()),
393            from: opts.get("from").map(|s| s.to_string()),
394            read_only: opts.get("ro").is_some() || opts.get("readonly").is_some(),
395        }),
396    }
397}
398
399/// Parse arguments (exec form or shell form).
400fn parse_arguments(input: &str) -> IResult<&str, Arguments> {
401    // Try exec form first
402    if let Ok((remaining, list)) = parse_json_array(input) {
403        return Ok((remaining, Arguments::List(list)));
404    }
405
406    // Fall back to shell form
407    Ok(("", Arguments::Text(input.trim().to_string())))
408}
409
410/// Parse JSON array for exec form.
411fn parse_json_array(input: &str) -> IResult<&str, Vec<String>> {
412    let (input, _) = char('[')(input)?;
413    let (input, _) = space0(input)?;
414    let (input, items) = separated_list0(
415        tuple((space0, char(','), space0)),
416        parse_json_string,
417    )(input)?;
418    let (input, _) = space0(input)?;
419    let (input, _) = char(']')(input)?;
420    Ok((input, items))
421}
422
423/// Parse a JSON string.
424fn parse_json_string(input: &str) -> IResult<&str, String> {
425    let (input, _) = char('"')(input)?;
426    let mut result = String::new();
427    let mut chars = input.chars().peekable();
428    let mut consumed = 0;
429
430    while let Some(c) = chars.next() {
431        consumed += c.len_utf8();
432        if c == '"' {
433            return Ok((&input[consumed..], result));
434        } else if c == '\\' {
435            if let Some(next) = chars.next() {
436                consumed += next.len_utf8();
437                match next {
438                    'n' => result.push('\n'),
439                    't' => result.push('\t'),
440                    'r' => result.push('\r'),
441                    '\\' => result.push('\\'),
442                    '"' => result.push('"'),
443                    _ => {
444                        result.push('\\');
445                        result.push(next);
446                    }
447                }
448            }
449        } else {
450            result.push(c);
451        }
452    }
453
454    Err(nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Char)))
455}
456
457/// Parse COPY instruction.
458fn parse_copy(input: &str) -> IResult<&str, Instruction> {
459    let (input, _) = tag_no_case("COPY")(input)?;
460    let (input, _) = space0(input)?;
461
462    // Parse flags
463    let (input, flags) = parse_copy_flags(input)?;
464    let (input, _) = space0(input)?;
465
466    // Parse sources and destination
467    let (input, args) = parse_copy_args(input)?;
468
469    Ok((input, Instruction::Copy(args, flags)))
470}
471
472/// Parse COPY flags.
473fn parse_copy_flags(input: &str) -> IResult<&str, CopyFlags> {
474    let mut flags = CopyFlags::default();
475    let mut remaining = input;
476
477    loop {
478        let (input, _) = space0(remaining)?;
479
480        if let Ok((input, from)) = parse_flag_value(input, "--from") {
481            flags.from = Some(from.to_string());
482            remaining = input;
483            continue;
484        }
485        if let Ok((input, chown)) = parse_flag_value(input, "--chown") {
486            flags.chown = Some(chown.to_string());
487            remaining = input;
488            continue;
489        }
490        if let Ok((input, chmod)) = parse_flag_value(input, "--chmod") {
491            flags.chmod = Some(chmod.to_string());
492            remaining = input;
493            continue;
494        }
495        if let Ok((input, _)) = tag::<&str, &str, nom::error::Error<&str>>("--link")(input) {
496            flags.link = true;
497            remaining = input;
498            continue;
499        }
500
501        break;
502    }
503
504    Ok((remaining, flags))
505}
506
507/// Parse COPY arguments.
508fn parse_copy_args(input: &str) -> IResult<&str, CopyArgs> {
509    // Try exec form first
510    if let Ok((remaining, items)) = parse_json_array(input) {
511        if items.len() >= 2 {
512            let dest = items.last().unwrap().clone();
513            let sources = items[..items.len() - 1].to_vec();
514            return Ok((remaining, CopyArgs::new(sources, dest)));
515        }
516    }
517
518    // Shell form: space-separated paths
519    let parts: Vec<&str> = input.split_whitespace().collect();
520    if parts.len() >= 2 {
521        let dest = parts.last().unwrap().to_string();
522        let sources: Vec<String> = parts[..parts.len() - 1].iter().map(|s| s.to_string()).collect();
523        Ok(("", CopyArgs::new(sources, dest)))
524    } else if parts.len() == 1 {
525        // Single argument - treat as both source and dest
526        Ok(("", CopyArgs::new(vec![parts[0].to_string()], parts[0])))
527    } else {
528        Err(nom::Err::Error(nom::error::Error::new(input, nom::error::ErrorKind::Space)))
529    }
530}
531
532/// Parse ADD instruction.
533fn parse_add(input: &str) -> IResult<&str, Instruction> {
534    let (input, _) = tag_no_case("ADD")(input)?;
535    let (input, _) = space0(input)?;
536
537    // Parse flags
538    let (input, flags) = parse_add_flags(input)?;
539    let (input, _) = space0(input)?;
540
541    // Parse sources and destination (same as COPY)
542    let (input, copy_args) = parse_copy_args(input)?;
543    let args = AddArgs::new(copy_args.sources, copy_args.dest);
544
545    Ok((input, Instruction::Add(args, flags)))
546}
547
548/// Parse ADD flags.
549fn parse_add_flags(input: &str) -> IResult<&str, AddFlags> {
550    let mut flags = AddFlags::default();
551    let mut remaining = input;
552
553    loop {
554        let (input, _) = space0(remaining)?;
555
556        if let Ok((input, chown)) = parse_flag_value(input, "--chown") {
557            flags.chown = Some(chown.to_string());
558            remaining = input;
559            continue;
560        }
561        if let Ok((input, chmod)) = parse_flag_value(input, "--chmod") {
562            flags.chmod = Some(chmod.to_string());
563            remaining = input;
564            continue;
565        }
566        if let Ok((input, checksum)) = parse_flag_value(input, "--checksum") {
567            flags.checksum = Some(checksum.to_string());
568            remaining = input;
569            continue;
570        }
571        if let Ok((input, _)) = tag::<&str, &str, nom::error::Error<&str>>("--link")(input) {
572            flags.link = true;
573            remaining = input;
574            continue;
575        }
576
577        break;
578    }
579
580    Ok((remaining, flags))
581}
582
583/// Parse ENV instruction.
584fn parse_env(input: &str) -> IResult<&str, Instruction> {
585    let (input, _) = tag_no_case("ENV")(input)?;
586    let (input, _) = space1(input)?;
587
588    // ENV can be KEY=VALUE or KEY VALUE
589    let pairs = parse_key_value_pairs(input);
590    Ok(("", Instruction::Env(pairs)))
591}
592
593/// Parse LABEL instruction.
594fn parse_label(input: &str) -> IResult<&str, Instruction> {
595    let (input, _) = tag_no_case("LABEL")(input)?;
596    let (input, _) = space1(input)?;
597
598    let pairs = parse_key_value_pairs(input);
599    Ok(("", Instruction::Label(pairs)))
600}
601
602/// Parse key=value pairs.
603fn parse_key_value_pairs(input: &str) -> Vec<(String, String)> {
604    let mut pairs = Vec::new();
605    let mut remaining = input.trim();
606
607    while !remaining.is_empty() {
608        // Find key
609        let key_end = remaining.find(|c: char| c == '=' || c.is_whitespace()).unwrap_or(remaining.len());
610        if key_end == 0 {
611            remaining = remaining.trim_start();
612            continue;
613        }
614
615        let key = &remaining[..key_end];
616        remaining = &remaining[key_end..];
617
618        // Check for = sign
619        if remaining.starts_with('=') {
620            remaining = &remaining[1..];
621            // Parse value
622            let value = if remaining.starts_with('"') {
623                // Quoted value
624                let end = find_closing_quote(remaining);
625                let val = &remaining[1..end];
626                remaining = &remaining[end + 1..];
627                val.to_string()
628            } else {
629                // Unquoted value
630                let end = remaining.find(|c: char| c.is_whitespace()).unwrap_or(remaining.len());
631                let val = &remaining[..end];
632                remaining = &remaining[end..];
633                val.to_string()
634            };
635            pairs.push((key.to_string(), value));
636        } else {
637            // Legacy format: KEY VALUE (no =)
638            remaining = remaining.trim_start();
639            if !remaining.is_empty() {
640                let value = if remaining.starts_with('"') {
641                    let end = find_closing_quote(remaining);
642                    let val = &remaining[1..end];
643                    remaining = &remaining[end + 1..];
644                    val.to_string()
645                } else {
646                    remaining.to_string()
647                };
648                pairs.push((key.to_string(), value.trim().to_string()));
649                break;
650            }
651        }
652
653        remaining = remaining.trim_start();
654    }
655
656    pairs
657}
658
659/// Find closing quote position.
660fn find_closing_quote(s: &str) -> usize {
661    let mut escaped = false;
662    for (i, c) in s.char_indices().skip(1) {
663        if escaped {
664            escaped = false;
665        } else if c == '\\' {
666            escaped = true;
667        } else if c == '"' {
668            return i;
669        }
670    }
671    s.len() - 1
672}
673
674/// Parse EXPOSE instruction.
675fn parse_expose(input: &str) -> IResult<&str, Instruction> {
676    let (input, _) = tag_no_case("EXPOSE")(input)?;
677    let (input, _) = space1(input)?;
678
679    let mut ports = Vec::new();
680    for part in input.split_whitespace() {
681        if let Some(port) = parse_port_spec(part) {
682            ports.push(port);
683        }
684    }
685
686    Ok(("", Instruction::Expose(ports)))
687}
688
689/// Parse a port specification like "80", "80/tcp", "53/udp".
690fn parse_port_spec(s: &str) -> Option<Port> {
691    let parts: Vec<&str> = s.split('/').collect();
692    let port_num: u16 = parts[0].parse().ok()?;
693    let protocol = parts.get(1).map(|p| {
694        if p.eq_ignore_ascii_case("udp") {
695            PortProtocol::Udp
696        } else {
697            PortProtocol::Tcp
698        }
699    }).unwrap_or(PortProtocol::Tcp);
700
701    Some(Port { number: port_num, protocol })
702}
703
704/// Parse ARG instruction.
705fn parse_arg(input: &str) -> IResult<&str, Instruction> {
706    let (input, _) = tag_no_case("ARG")(input)?;
707    let (input, _) = space1(input)?;
708
709    let content = input.trim();
710    if let Some(eq_pos) = content.find('=') {
711        let name = content[..eq_pos].to_string();
712        let default = content[eq_pos + 1..].to_string();
713        Ok(("", Instruction::Arg(name, Some(default))))
714    } else {
715        Ok(("", Instruction::Arg(content.to_string(), None)))
716    }
717}
718
719/// Parse ENTRYPOINT instruction.
720fn parse_entrypoint(input: &str) -> IResult<&str, Instruction> {
721    let (input, _) = tag_no_case("ENTRYPOINT")(input)?;
722    let (input, _) = space0(input)?;
723
724    let (input, arguments) = parse_arguments(input)?;
725    Ok((input, Instruction::Entrypoint(arguments)))
726}
727
728/// Parse CMD instruction.
729fn parse_cmd(input: &str) -> IResult<&str, Instruction> {
730    let (input, _) = tag_no_case("CMD")(input)?;
731    let (input, _) = space0(input)?;
732
733    let (input, arguments) = parse_arguments(input)?;
734    Ok((input, Instruction::Cmd(arguments)))
735}
736
737/// Parse SHELL instruction.
738fn parse_shell(input: &str) -> IResult<&str, Instruction> {
739    let (input, _) = tag_no_case("SHELL")(input)?;
740    let (input, _) = space0(input)?;
741
742    let (input, arguments) = parse_arguments(input)?;
743    Ok((input, Instruction::Shell(arguments)))
744}
745
746/// Parse USER instruction.
747fn parse_user(input: &str) -> IResult<&str, Instruction> {
748    let (input, _) = tag_no_case("USER")(input)?;
749    let (input, _) = space1(input)?;
750
751    Ok(("", Instruction::User(input.trim().to_string())))
752}
753
754/// Parse WORKDIR instruction.
755fn parse_workdir(input: &str) -> IResult<&str, Instruction> {
756    let (input, _) = tag_no_case("WORKDIR")(input)?;
757    let (input, _) = space1(input)?;
758
759    Ok(("", Instruction::Workdir(input.trim().to_string())))
760}
761
762/// Parse VOLUME instruction.
763fn parse_volume(input: &str) -> IResult<&str, Instruction> {
764    let (input, _) = tag_no_case("VOLUME")(input)?;
765    let (input, _) = space1(input)?;
766
767    // VOLUME can be JSON array or space-separated
768    // For simplicity, store as single string
769    Ok(("", Instruction::Volume(input.trim().to_string())))
770}
771
772/// Parse MAINTAINER instruction (deprecated).
773fn parse_maintainer(input: &str) -> IResult<&str, Instruction> {
774    let (input, _) = tag_no_case("MAINTAINER")(input)?;
775    let (input, _) = space1(input)?;
776
777    Ok(("", Instruction::Maintainer(input.trim().to_string())))
778}
779
780/// Parse HEALTHCHECK instruction.
781fn parse_healthcheck(input: &str) -> IResult<&str, Instruction> {
782    let (input, _) = tag_no_case("HEALTHCHECK")(input)?;
783    let (input, _) = space1(input)?;
784
785    let content = input.trim();
786
787    // Check for NONE
788    if content.eq_ignore_ascii_case("NONE") {
789        return Ok(("", Instruction::Healthcheck(HealthCheck::None)));
790    }
791
792    // Parse options
793    let mut interval = None;
794    let mut timeout = None;
795    let mut start_period = None;
796    let mut retries = None;
797    let mut remaining = content;
798
799    loop {
800        remaining = remaining.trim_start();
801        if remaining.starts_with("--interval=") {
802            let value_start = 11;
803            let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len());
804            interval = Some(remaining[value_start..value_end].to_string());
805            remaining = &remaining[value_end..];
806        } else if remaining.starts_with("--timeout=") {
807            let value_start = 10;
808            let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len());
809            timeout = Some(remaining[value_start..value_end].to_string());
810            remaining = &remaining[value_end..];
811        } else if remaining.starts_with("--start-period=") {
812            let value_start = 15;
813            let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len());
814            start_period = Some(remaining[value_start..value_end].to_string());
815            remaining = &remaining[value_end..];
816        } else if remaining.starts_with("--retries=") {
817            let value_start = 10;
818            let value_end = remaining[value_start..].find(' ').map(|i| value_start + i).unwrap_or(remaining.len());
819            retries = remaining[value_start..value_end].parse().ok();
820            remaining = &remaining[value_end..];
821        } else {
822            break;
823        }
824    }
825
826    // Parse CMD
827    remaining = remaining.trim_start();
828    if remaining.to_uppercase().starts_with("CMD") {
829        remaining = &remaining[3..].trim_start();
830    }
831
832    let (_, arguments) = parse_arguments(remaining)?;
833
834    Ok(("", Instruction::Healthcheck(HealthCheck::Cmd {
835        cmd: arguments,
836        interval,
837        timeout,
838        start_period,
839        retries,
840    })))
841}
842
843/// Parse ONBUILD instruction.
844fn parse_onbuild(input: &str) -> IResult<&str, Instruction> {
845    let (input, _) = tag_no_case("ONBUILD")(input)?;
846    let (input, _) = space1(input)?;
847
848    let (remaining, inner) = parse_instruction(input)?;
849    Ok((remaining, Instruction::OnBuild(Box::new(inner))))
850}
851
852/// Parse STOPSIGNAL instruction.
853fn parse_stopsignal(input: &str) -> IResult<&str, Instruction> {
854    let (input, _) = tag_no_case("STOPSIGNAL")(input)?;
855    let (input, _) = space1(input)?;
856
857    Ok(("", Instruction::Stopsignal(input.trim().to_string())))
858}
859
860/// Parse comment.
861fn parse_comment(input: &str) -> IResult<&str, Instruction> {
862    let (input, _) = char('#')(input)?;
863    Ok(("", Instruction::Comment(input.trim().to_string())))
864}
865
866#[cfg(test)]
867mod tests {
868    use super::*;
869
870    #[test]
871    fn test_parse_from_simple() {
872        let result = parse_dockerfile("FROM ubuntu").unwrap();
873        assert_eq!(result.len(), 1);
874        match &result[0].instruction {
875            Instruction::From(base) => {
876                assert_eq!(base.image.name, "ubuntu");
877                assert!(base.tag.is_none());
878            }
879            _ => panic!("Expected FROM instruction"),
880        }
881    }
882
883    #[test]
884    fn test_parse_from_with_tag() {
885        let result = parse_dockerfile("FROM ubuntu:20.04").unwrap();
886        match &result[0].instruction {
887            Instruction::From(base) => {
888                assert_eq!(base.image.name, "ubuntu");
889                assert_eq!(base.tag, Some("20.04".to_string()));
890            }
891            _ => panic!("Expected FROM instruction"),
892        }
893    }
894
895    #[test]
896    fn test_parse_from_with_alias() {
897        let result = parse_dockerfile("FROM ubuntu:20.04 AS builder").unwrap();
898        match &result[0].instruction {
899            Instruction::From(base) => {
900                assert_eq!(base.image.name, "ubuntu");
901                assert_eq!(base.alias.as_ref().map(|a| a.as_str()), Some("builder"));
902            }
903            _ => panic!("Expected FROM instruction"),
904        }
905    }
906
907    #[test]
908    fn test_parse_run_shell() {
909        let result = parse_dockerfile("RUN apt-get update && apt-get install -y nginx").unwrap();
910        match &result[0].instruction {
911            Instruction::Run(args) => {
912                assert!(args.arguments.is_shell_form());
913                assert!(args.arguments.as_text().unwrap().contains("apt-get"));
914            }
915            _ => panic!("Expected RUN instruction"),
916        }
917    }
918
919    #[test]
920    fn test_parse_run_exec() {
921        let result = parse_dockerfile(r#"RUN ["apt-get", "update"]"#).unwrap();
922        match &result[0].instruction {
923            Instruction::Run(args) => {
924                assert!(args.arguments.is_exec_form());
925                let list = args.arguments.as_list().unwrap();
926                assert_eq!(list[0], "apt-get");
927                assert_eq!(list[1], "update");
928            }
929            _ => panic!("Expected RUN instruction"),
930        }
931    }
932
933    #[test]
934    fn test_parse_copy() {
935        let result = parse_dockerfile("COPY src/ /app/").unwrap();
936        match &result[0].instruction {
937            Instruction::Copy(args, _) => {
938                assert_eq!(args.sources, vec!["src/"]);
939                assert_eq!(args.dest, "/app/");
940            }
941            _ => panic!("Expected COPY instruction"),
942        }
943    }
944
945    #[test]
946    fn test_parse_copy_with_from() {
947        let result = parse_dockerfile("COPY --from=builder /app/dist /app/").unwrap();
948        match &result[0].instruction {
949            Instruction::Copy(args, flags) => {
950                assert_eq!(flags.from, Some("builder".to_string()));
951                assert_eq!(args.sources, vec!["/app/dist"]);
952                assert_eq!(args.dest, "/app/");
953            }
954            _ => panic!("Expected COPY instruction"),
955        }
956    }
957
958    #[test]
959    fn test_parse_env() {
960        let result = parse_dockerfile("ENV NODE_ENV=production").unwrap();
961        match &result[0].instruction {
962            Instruction::Env(pairs) => {
963                assert_eq!(pairs.len(), 1);
964                assert_eq!(pairs[0].0, "NODE_ENV");
965                assert_eq!(pairs[0].1, "production");
966            }
967            _ => panic!("Expected ENV instruction"),
968        }
969    }
970
971    #[test]
972    fn test_parse_expose() {
973        let result = parse_dockerfile("EXPOSE 80 443/tcp 53/udp").unwrap();
974        match &result[0].instruction {
975            Instruction::Expose(ports) => {
976                assert_eq!(ports.len(), 3);
977                assert_eq!(ports[0].number, 80);
978                assert_eq!(ports[1].number, 443);
979                assert_eq!(ports[2].number, 53);
980                assert_eq!(ports[2].protocol, PortProtocol::Udp);
981            }
982            _ => panic!("Expected EXPOSE instruction"),
983        }
984    }
985
986    #[test]
987    fn test_parse_workdir() {
988        let result = parse_dockerfile("WORKDIR /app").unwrap();
989        match &result[0].instruction {
990            Instruction::Workdir(path) => {
991                assert_eq!(path, "/app");
992            }
993            _ => panic!("Expected WORKDIR instruction"),
994        }
995    }
996
997    #[test]
998    fn test_parse_user() {
999        let result = parse_dockerfile("USER node").unwrap();
1000        match &result[0].instruction {
1001            Instruction::User(user) => {
1002                assert_eq!(user, "node");
1003            }
1004            _ => panic!("Expected USER instruction"),
1005        }
1006    }
1007
1008    #[test]
1009    fn test_parse_comment() {
1010        let result = parse_dockerfile("# This is a comment").unwrap();
1011        match &result[0].instruction {
1012            Instruction::Comment(text) => {
1013                assert_eq!(text, "This is a comment");
1014            }
1015            _ => panic!("Expected Comment"),
1016        }
1017    }
1018
1019    #[test]
1020    fn test_parse_full_dockerfile() {
1021        let dockerfile = r#"
1022FROM node:18-alpine AS builder
1023WORKDIR /app
1024COPY package*.json ./
1025RUN npm ci
1026COPY . .
1027RUN npm run build
1028
1029FROM node:18-alpine
1030WORKDIR /app
1031COPY --from=builder /app/dist ./dist
1032EXPOSE 3000
1033CMD ["node", "dist/index.js"]
1034"#;
1035
1036        let result = parse_dockerfile(dockerfile).unwrap();
1037        // Should have multiple instructions
1038        assert!(result.len() >= 10);
1039    }
1040
1041    #[test]
1042    fn test_line_continuation() {
1043        let dockerfile = r#"RUN apt-get update && \
1044    apt-get install -y nginx"#;
1045
1046        let result = parse_dockerfile(dockerfile).unwrap();
1047        assert_eq!(result.len(), 1);
1048        match &result[0].instruction {
1049            Instruction::Run(args) => {
1050                let text = args.arguments.as_text().unwrap();
1051                assert!(text.contains("apt-get update"));
1052                assert!(text.contains("apt-get install"));
1053            }
1054            _ => panic!("Expected RUN instruction"),
1055        }
1056    }
1057
1058    #[test]
1059    fn test_image_with_registry() {
1060        let result = parse_dockerfile("FROM gcr.io/my-project/my-image:latest").unwrap();
1061        match &result[0].instruction {
1062            Instruction::From(base) => {
1063                assert_eq!(base.image.registry, Some("gcr.io".to_string()));
1064                assert_eq!(base.image.name, "my-project/my-image");
1065                assert_eq!(base.tag, Some("latest".to_string()));
1066            }
1067            _ => panic!("Expected FROM instruction"),
1068        }
1069    }
1070}