1use 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#[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 trimmed.ends_with('\\') {
61 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 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 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 }
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(image_ref, platform.map(|s| s.to_string()), alias.map(|s| ImageAlias::new(s)));
171
172 Ok((input, Instruction::From(base_image)))
173}
174
175fn parse_image_reference(
177 image_ref: &str,
178 platform: Option<String>,
179 alias: Option<ImageAlias>,
180) -> BaseImage {
181 if let Some(at_pos) = image_ref.find('@') {
183 let (image_part, digest) = image_ref.split_at(at_pos);
184 let digest = &digest[1..]; 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 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
208fn parse_image_tag(image_ref: &str) -> (Image, Option<String>) {
210 let parts: Vec<&str> = image_ref.split('/').collect();
215
216 if parts.len() == 1 {
217 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 let last_part = parts.last().unwrap();
228
229 if let Some(colon_pos) = last_part.rfind(':') {
231 let potential_tag = &last_part[colon_pos + 1..];
233 if !potential_tag.chars().all(|c| c.is_ascii_digit()) || potential_tag.len() > 5 {
234 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 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
259fn split_registry(name: &str) -> (Option<String>, String) {
261 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
277fn parse_run(input: &str) -> IResult<&str, Instruction> {
279 let (input, _) = tag_no_case("RUN")(input)?;
280 let (input, _) = space0(input)?;
281
282 let (input, flags) = parse_run_flags(input)?;
284 let (input, _) = space0(input)?;
285
286 let (input, arguments) = parse_arguments(input)?;
288
289 Ok((input, Instruction::Run(RunArgs { arguments, flags })))
290}
291
292fn 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 if let Ok((input, mount)) = parse_mount_flag(input) {
302 flags.mount.insert(mount);
303 remaining = input;
304 continue;
305 }
306
307 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 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
327fn 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
334fn 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 let mount = parse_mount_options(mount_str);
341 Ok((input, mount))
342}
343
344fn 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
399fn parse_arguments(input: &str) -> IResult<&str, Arguments> {
401 if let Ok((remaining, list)) = parse_json_array(input) {
403 return Ok((remaining, Arguments::List(list)));
404 }
405
406 Ok(("", Arguments::Text(input.trim().to_string())))
408}
409
410fn 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
423fn 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
457fn parse_copy(input: &str) -> IResult<&str, Instruction> {
459 let (input, _) = tag_no_case("COPY")(input)?;
460 let (input, _) = space0(input)?;
461
462 let (input, flags) = parse_copy_flags(input)?;
464 let (input, _) = space0(input)?;
465
466 let (input, args) = parse_copy_args(input)?;
468
469 Ok((input, Instruction::Copy(args, flags)))
470}
471
472fn 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
507fn parse_copy_args(input: &str) -> IResult<&str, CopyArgs> {
509 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 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 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
532fn parse_add(input: &str) -> IResult<&str, Instruction> {
534 let (input, _) = tag_no_case("ADD")(input)?;
535 let (input, _) = space0(input)?;
536
537 let (input, flags) = parse_add_flags(input)?;
539 let (input, _) = space0(input)?;
540
541 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
548fn 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
583fn parse_env(input: &str) -> IResult<&str, Instruction> {
585 let (input, _) = tag_no_case("ENV")(input)?;
586 let (input, _) = space1(input)?;
587
588 let pairs = parse_key_value_pairs(input);
590 Ok(("", Instruction::Env(pairs)))
591}
592
593fn 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
602fn 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 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 if remaining.starts_with('=') {
620 remaining = &remaining[1..];
621 let value = if remaining.starts_with('"') {
623 let end = find_closing_quote(remaining);
625 let val = &remaining[1..end];
626 remaining = &remaining[end + 1..];
627 val.to_string()
628 } else {
629 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 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
659fn 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
674fn 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
689fn 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
704fn 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
719fn 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
728fn 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
737fn 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
746fn 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
754fn 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
762fn parse_volume(input: &str) -> IResult<&str, Instruction> {
764 let (input, _) = tag_no_case("VOLUME")(input)?;
765 let (input, _) = space1(input)?;
766
767 Ok(("", Instruction::Volume(input.trim().to_string())))
770}
771
772fn 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
780fn 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 if content.eq_ignore_ascii_case("NONE") {
789 return Ok(("", Instruction::Healthcheck(HealthCheck::None)));
790 }
791
792 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 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
843fn 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
852fn 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
860fn 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 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}