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