1use 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#[derive(Debug, Clone)]
19pub struct ParseError {
20 pub message: String,
22 pub line: u32,
24 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
39pub fn parse_dockerfile(input: &str) -> Result<Vec<InstructionPos>, ParseError> {
41 let mut instructions = Vec::new();
42 let mut line_number = 1u32;
43
44 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 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 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 if trimmed.is_empty() {
81 continue;
82 }
83
84 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 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 }
105 }
106 }
107
108 Ok(instructions)
109}
110
111fn 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
136fn parse_from(input: &str) -> IResult<&str, Instruction> {
138 let (input, _) = tag_no_case("FROM")(input)?;
139 let (input, _) = space1(input)?;
140
141 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 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 let (input, image_ref) = take_till(|c: char| c.is_whitespace())(input)?;
161 let (input, _) = space0(input)?;
162
163 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 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
179fn parse_image_reference(
181 image_ref: &str,
182 platform: Option<String>,
183 alias: Option<ImageAlias>,
184) -> BaseImage {
185 if let Some(at_pos) = image_ref.find('@') {
187 let (image_part, digest) = image_ref.split_at(at_pos);
188 let digest = &digest[1..]; 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 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
212fn parse_image_tag(image_ref: &str) -> (Image, Option<String>) {
214 let parts: Vec<&str> = image_ref.split('/').collect();
219
220 if parts.len() == 1 {
221 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 let last_part = parts.last().unwrap();
232
233 if let Some(colon_pos) = last_part.rfind(':') {
235 let potential_tag = &last_part[colon_pos + 1..];
237 if !potential_tag.chars().all(|c| c.is_ascii_digit()) || potential_tag.len() > 5 {
238 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 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
263fn split_registry(name: &str) -> (Option<String>, String) {
265 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
281fn parse_run(input: &str) -> IResult<&str, Instruction> {
283 let (input, _) = tag_no_case("RUN")(input)?;
284 let (input, _) = space0(input)?;
285
286 let (input, flags) = parse_run_flags(input)?;
288 let (input, _) = space0(input)?;
289
290 let (input, arguments) = parse_arguments(input)?;
292
293 Ok((input, Instruction::Run(RunArgs { arguments, flags })))
294}
295
296fn 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 if let Ok((input, mount)) = parse_mount_flag(input) {
306 flags.mount.insert(mount);
307 remaining = input;
308 continue;
309 }
310
311 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 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
331fn 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
338fn 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 let mount = parse_mount_options(mount_str);
345 Ok((input, mount))
346}
347
348fn 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
403fn parse_arguments(input: &str) -> IResult<&str, Arguments> {
405 if let Ok((remaining, list)) = parse_json_array(input) {
407 return Ok((remaining, Arguments::List(list)));
408 }
409
410 Ok(("", Arguments::Text(input.trim().to_string())))
412}
413
414fn 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
425fn 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
462fn parse_copy(input: &str) -> IResult<&str, Instruction> {
464 let (input, _) = tag_no_case("COPY")(input)?;
465 let (input, _) = space0(input)?;
466
467 let (input, flags) = parse_copy_flags(input)?;
469 let (input, _) = space0(input)?;
470
471 let (input, args) = parse_copy_args(input)?;
473
474 Ok((input, Instruction::Copy(args, flags)))
475}
476
477fn 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
512fn parse_copy_args(input: &str) -> IResult<&str, CopyArgs> {
514 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 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 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
543fn parse_add(input: &str) -> IResult<&str, Instruction> {
545 let (input, _) = tag_no_case("ADD")(input)?;
546 let (input, _) = space0(input)?;
547
548 let (input, flags) = parse_add_flags(input)?;
550 let (input, _) = space0(input)?;
551
552 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
559fn 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
594fn parse_env(input: &str) -> IResult<&str, Instruction> {
596 let (input, _) = tag_no_case("ENV")(input)?;
597 let (input, _) = space1(input)?;
598
599 let pairs = parse_key_value_pairs(input);
601 Ok(("", Instruction::Env(pairs)))
602}
603
604fn 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
613fn 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 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 if remaining.starts_with('=') {
633 remaining = &remaining[1..];
634 let value = if remaining.starts_with('"') {
636 let end = find_closing_quote(remaining);
638 let val = &remaining[1..end];
639 remaining = &remaining[end + 1..];
640 val.to_string()
641 } else {
642 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 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 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
674fn 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
689fn 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
704fn 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
725fn 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
740fn 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
749fn 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
758fn 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
767fn 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
775fn 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
783fn parse_volume(input: &str) -> IResult<&str, Instruction> {
785 let (input, _) = tag_no_case("VOLUME")(input)?;
786 let (input, _) = space1(input)?;
787
788 Ok(("", Instruction::Volume(input.trim().to_string())))
791}
792
793fn 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
801fn 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 if content.eq_ignore_ascii_case("NONE") {
810 return Ok(("", Instruction::Healthcheck(HealthCheck::None)));
811 }
812
813 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 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
880fn 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
889fn 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
897fn 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 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}