1use std::collections::HashSet;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct SourcePosition {
11 pub offset: usize,
13 pub line: usize,
15 pub column: usize,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct SourceSpan {
21 pub start: SourcePosition,
23 pub end: SourcePosition,
25}
26
27impl SourceSpan {
28 pub fn text<'a>(&self, source: &'a str) -> &'a str {
29 &source[self.start.offset..self.end.offset]
30 }
31}
32
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum DiagnosticSeverity {
35 Warning,
36 Error,
37}
38
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct ParseDiagnostic {
41 pub code: &'static str,
42 pub severity: DiagnosticSeverity,
43 pub message: String,
44 pub span: SourceSpan,
45}
46
47#[derive(Debug, Clone, PartialEq, Eq)]
48pub struct ParserDirective {
49 pub name: String,
50 pub value: String,
51 pub span: SourceSpan,
52}
53
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct InstructionFlag {
56 pub name: String,
57 pub value: Option<String>,
58 pub raw: String,
59 pub span: SourceSpan,
60}
61
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum QuoteStyle {
64 Unquoted,
65 Single,
66 Double,
67 Mixed,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
71pub struct VariableReference {
72 pub name: String,
73 pub modifier: Option<String>,
76 pub braced: bool,
77 pub span: SourceSpan,
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
81pub struct Word {
82 pub raw: String,
83 pub value: String,
84 pub quote: QuoteStyle,
85 pub span: SourceSpan,
86 pub variables: Vec<VariableReference>,
87}
88
89#[derive(Debug, Clone, PartialEq, Eq)]
90pub struct Heredoc {
91 pub delimiter: String,
92 pub file_descriptor: Option<u32>,
93 pub strip_tabs: bool,
94 pub expand: bool,
95 pub content: String,
96 pub marker_span: SourceSpan,
97 pub content_span: SourceSpan,
98 pub terminator_span: Option<SourceSpan>,
99}
100
101#[derive(Debug, Clone, PartialEq, Eq)]
102pub enum InstructionForm {
103 Shell,
104 Json(Vec<String>),
105 InvalidJson,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq)]
109pub struct Instruction {
110 pub line: usize,
112 pub instruction: String,
114 pub arguments: String,
117 pub raw: String,
119 pub span: SourceSpan,
120 pub keyword_span: SourceSpan,
121 pub arguments_span: SourceSpan,
122 pub logical_arguments: String,
124 pub command: String,
126 pub flags: Vec<InstructionFlag>,
127 pub words: Vec<Word>,
128 pub variables: Vec<VariableReference>,
129 pub heredocs: Vec<Heredoc>,
130 pub form: InstructionForm,
131}
132
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub struct Dockerfile {
135 pub escape: char,
136 pub directives: Vec<ParserDirective>,
137 pub instructions: Vec<Instruction>,
138 pub diagnostics: Vec<ParseDiagnostic>,
139}
140
141pub fn parse(content: &str) -> Vec<Instruction> {
143 parse_document(content).instructions
144}
145
146pub fn parse_document(content: &str) -> Dockerfile {
148 let lines = physical_lines(content);
149 let (directives, escape, mut diagnostics) = parse_directives(&lines);
150 let mut instructions = Vec::new();
151 let mut line_index = 0usize;
152
153 while line_index < lines.len() {
154 let line = &lines[line_index];
155 let trimmed = line.text.trim_start_matches([' ', '\t']);
156 if trimmed.is_empty() || trimmed.starts_with('#') {
157 line_index += 1;
158 continue;
159 }
160
161 let leading = line.text.len() - trimmed.len();
162 let keyword_start = line.start + leading;
163 let keyword_len = trimmed
164 .bytes()
165 .take_while(|byte| byte.is_ascii_alphabetic())
166 .count();
167 let token_len = trimmed
168 .bytes()
169 .take_while(|byte| !byte.is_ascii_whitespace())
170 .count();
171
172 let effective_keyword_len = if keyword_len == 0 {
173 token_len
174 } else {
175 keyword_len
176 };
177 let keyword_source = &trimmed[..effective_keyword_len.min(trimmed.len())];
178 let keyword = keyword_source.to_ascii_uppercase();
179 let keyword_end = keyword_start + effective_keyword_len;
180 let keyword_span = span(&lines, keyword_start, keyword_end);
181
182 if keyword_len == 0 || keyword_len != token_len {
183 diagnostics.push(ParseDiagnostic {
184 code: "P004",
185 severity: DiagnosticSeverity::Error,
186 message: format!("invalid Dockerfile instruction {:?}", keyword_source),
187 span: keyword_span,
188 });
189 } else if !is_known_instruction(&keyword) {
190 let custom_syntax = directives
191 .iter()
192 .find(|directive| directive.name == "syntax")
193 .is_some_and(|directive| !directive.value.starts_with("docker/dockerfile:"));
194 diagnostics.push(ParseDiagnostic {
195 code: "P005",
196 severity: if custom_syntax {
197 DiagnosticSeverity::Warning
198 } else {
199 DiagnosticSeverity::Error
200 },
201 message: format!("unknown Dockerfile instruction {keyword}"),
202 span: keyword_span,
203 });
204 }
205
206 let mut logical = LogicalBuffer::default();
207 let mut normalized_parts = Vec::new();
208 let mut current = line_index;
209 let mut header_end;
210
211 loop {
212 let physical = &lines[current];
213 let is_first = current == line_index;
214 let semantic = if is_first {
215 let relative = keyword_start - physical.start;
216 &physical.text[relative..]
217 } else {
218 physical.text
219 };
220
221 if !is_first {
222 let trimmed_continuation = semantic.trim_start_matches([' ', '\t']);
223 if trimmed_continuation.is_empty() || trimmed_continuation.starts_with('#') {
224 if trimmed_continuation.is_empty() {
225 diagnostics.push(ParseDiagnostic {
226 code: "P009",
227 severity: DiagnosticSeverity::Warning,
228 message: "empty continuation line is deprecated".to_string(),
229 span: span(&lines, physical.start, physical.semantic_end),
230 });
231 }
232 header_end = physical.semantic_end;
233 if current + 1 == lines.len() {
234 diagnostics.push(ParseDiagnostic {
235 code: "P003",
236 severity: DiagnosticSeverity::Error,
237 message: "unterminated line continuation".to_string(),
238 span: span(&lines, keyword_start, header_end),
239 });
240 break;
241 }
242 current += 1;
243 continue;
244 }
245 }
246
247 let cut = continuation_cut(semantic, escape);
248 let semantic_start = if is_first {
249 keyword_start
250 } else {
251 physical.start
252 };
253 let kept = cut.unwrap_or(semantic.len());
254 logical.push(&semantic[..kept], semantic_start);
255
256 let normalized = if is_first {
257 semantic[effective_keyword_len.min(kept)..kept].trim()
258 } else {
259 semantic[..kept].trim()
260 };
261 if !normalized.is_empty() {
262 normalized_parts.push(normalized.to_string());
263 }
264
265 header_end = physical.semantic_end;
266 if cut.is_none() {
267 break;
268 }
269 if current + 1 == lines.len() {
270 diagnostics.push(ParseDiagnostic {
271 code: "P003",
272 severity: DiagnosticSeverity::Error,
273 message: "unterminated line continuation".to_string(),
274 span: span(&lines, keyword_start, header_end),
275 });
276 break;
277 }
278 current += 1;
279 }
280
281 let logical_keyword_end = effective_keyword_len.min(logical.text.len());
282 let logical_arguments_start = logical.text[logical_keyword_end..]
283 .find(|character: char| !character.is_whitespace())
284 .map(|relative| logical_keyword_end + relative)
285 .unwrap_or(logical.text.len());
286 let logical_arguments = logical.text[logical_arguments_start..].to_string();
287 let arguments_start = first_argument_offset(line, keyword_end);
288 let arguments_span = span(&lines, arguments_start, header_end);
289 let variables = variable_references(
290 &logical.text[logical_arguments_start..],
291 &logical.source_offsets,
292 logical_arguments_start,
293 &lines,
294 escape,
295 );
296 let (lexed_words, unterminated_quote) = lex_words(
297 &logical.text[logical_arguments_start..],
298 logical_arguments_start,
299 &logical.source_offsets,
300 &lines,
301 escape,
302 );
303
304 if let Some((quote, quote_span)) = unterminated_quote {
305 if !matches!(keyword.as_str(), "RUN" | "CMD" | "ENTRYPOINT") {
306 diagnostics.push(ParseDiagnostic {
307 code: "P008",
308 severity: DiagnosticSeverity::Error,
309 message: format!("unterminated {quote} quote"),
310 span: quote_span,
311 });
312 }
313 }
314
315 let mut flags = Vec::new();
316 let mut command_word = 0usize;
317 for lexed in &lexed_words {
318 let Some(flag) = parse_flag(&lexed.word) else {
319 break;
320 };
321 flags.push(flag);
322 command_word += 1;
323 }
324 let command = lexed_words
325 .get(command_word)
326 .map(|word| logical.text[word.logical_start..].trim().to_string())
327 .unwrap_or_default();
328 let words = lexed_words
329 .iter()
330 .map(|lexed| lexed.word.clone())
331 .collect::<Vec<_>>();
332 let form = parse_form(
333 &keyword,
334 &command,
335 &lexed_words,
336 command_word,
337 &lines,
338 &mut diagnostics,
339 );
340
341 let mut heredocs = Vec::new();
342 let mut body_cursor = current + 1;
343 let can_contain_heredoc = can_contain_heredoc(&keyword, &command, &form);
344 let heredoc_seeds = if can_contain_heredoc {
345 find_heredocs(&logical, &lines, escape)
346 } else {
347 Vec::new()
348 };
349 let mut instruction_end = header_end;
350
351 for seed in heredoc_seeds {
352 let content_start = lines
353 .get(body_cursor)
354 .map_or(header_end, |physical| physical.start);
355 let mut content = String::new();
356 let mut terminator_span = None;
357
358 while body_cursor < lines.len() {
359 let physical = &lines[body_cursor];
360 let possible_terminator = if seed.strip_tabs {
361 physical.text.trim_start_matches('\t')
362 } else {
363 physical.text
364 };
365 if possible_terminator == seed.delimiter {
366 terminator_span = Some(span(&lines, physical.start, physical.semantic_end));
367 instruction_end = physical.semantic_end;
368 body_cursor += 1;
369 break;
370 }
371
372 let body_line = if seed.strip_tabs {
373 physical.text.trim_start_matches('\t')
374 } else {
375 physical.text
376 };
377 content.push_str(body_line);
378 if physical.full_end > physical.semantic_end {
379 content.push('\n');
380 }
381 instruction_end = physical.semantic_end;
382 body_cursor += 1;
383 }
384
385 let content_end =
386 terminator_span.map_or(instruction_end, |terminator| terminator.start.offset);
387 let content_span = span(&lines, content_start, content_end.max(content_start));
388 if terminator_span.is_none() {
389 diagnostics.push(ParseDiagnostic {
390 code: "P002",
391 severity: DiagnosticSeverity::Error,
392 message: format!("unterminated heredoc {:?}", seed.delimiter),
393 span: seed.marker_span,
394 });
395 }
396
397 heredocs.push(Heredoc {
398 delimiter: seed.delimiter,
399 file_descriptor: seed.file_descriptor,
400 strip_tabs: seed.strip_tabs,
401 expand: seed.expand,
402 content,
403 marker_span: seed.marker_span,
404 content_span,
405 terminator_span,
406 });
407 }
408
409 let mut arguments = normalized_parts.join(" ");
410 if keyword == "RUN" {
411 for heredoc in &heredocs {
412 if !heredoc.content.is_empty() {
413 arguments.push('\n');
414 arguments.push_str(&heredoc.content);
415 }
416 }
417 }
418 if arguments.trim().is_empty() {
419 diagnostics.push(ParseDiagnostic {
420 code: "P006",
421 severity: DiagnosticSeverity::Error,
422 message: format!("{keyword} requires arguments"),
423 span: keyword_span,
424 });
425 }
426
427 let instruction_span = span(&lines, keyword_start, instruction_end);
428 instructions.push(Instruction {
429 line: line.number,
430 instruction: keyword,
431 arguments,
432 raw: content[keyword_start..instruction_end].to_string(),
433 span: instruction_span,
434 keyword_span,
435 arguments_span,
436 logical_arguments,
437 command,
438 flags,
439 words,
440 variables,
441 heredocs,
442 form,
443 });
444
445 line_index = if body_cursor > current + 1 {
446 body_cursor
447 } else {
448 current + 1
449 };
450 }
451
452 if instructions.is_empty()
453 && diagnostics
454 .iter()
455 .all(|diagnostic| diagnostic.code != "P004")
456 {
457 diagnostics.push(ParseDiagnostic {
458 code: "P001",
459 severity: DiagnosticSeverity::Error,
460 message: "Dockerfile contains no instructions".to_string(),
461 span: span(&lines, 0, 0),
462 });
463 }
464
465 Dockerfile {
466 escape,
467 directives,
468 instructions,
469 diagnostics,
470 }
471}
472
473#[derive(Debug)]
474struct PhysicalLine<'a> {
475 number: usize,
476 start: usize,
477 semantic_end: usize,
478 full_end: usize,
479 text: &'a str,
480}
481
482fn physical_lines(content: &str) -> Vec<PhysicalLine<'_>> {
483 let mut result = Vec::new();
484 let mut start = 0usize;
485
486 for (index, physical) in content.split_inclusive('\n').enumerate() {
487 let full_end = start + physical.len();
488 let without_newline = physical.strip_suffix('\n').unwrap_or(physical);
489 let semantic = without_newline
490 .strip_suffix('\r')
491 .unwrap_or(without_newline);
492 let semantic_end = start + semantic.len();
493 result.push(PhysicalLine {
494 number: index + 1,
495 start,
496 semantic_end,
497 full_end,
498 text: semantic,
499 });
500 start = full_end;
501 }
502
503 result
504}
505
506fn parse_directives(
507 lines: &[PhysicalLine<'_>],
508) -> (Vec<ParserDirective>, char, Vec<ParseDiagnostic>) {
509 let mut directives = Vec::new();
510 let mut diagnostics = Vec::new();
511 let mut seen = HashSet::new();
512 let mut escape = '\\';
513
514 for line in lines {
515 let trimmed = line.text.trim_start_matches([' ', '\t']);
516 if trimmed.is_empty() || !trimmed.starts_with('#') {
517 break;
518 }
519
520 let body = trimmed[1..].trim();
521 let Some((raw_name, raw_value)) = body.split_once('=') else {
522 break;
523 };
524 let name = raw_name.trim().to_ascii_lowercase();
525 if !matches!(name.as_str(), "syntax" | "escape" | "check") {
526 break;
527 }
528
529 let value = raw_value.trim().to_string();
530 let leading = line.text.len() - trimmed.len();
531 let directive_span = span(lines, line.start + leading, line.semantic_end);
532 if !seen.insert(name.clone()) {
533 diagnostics.push(ParseDiagnostic {
534 code: "P010",
535 severity: DiagnosticSeverity::Error,
536 message: format!("parser directive {name:?} may only be used once"),
537 span: directive_span,
538 });
539 }
540 if value.is_empty() {
541 diagnostics.push(ParseDiagnostic {
542 code: "P011",
543 severity: DiagnosticSeverity::Error,
544 message: format!("parser directive {name:?} requires a value"),
545 span: directive_span,
546 });
547 }
548 if name == "escape" {
549 match value.as_str() {
550 "\\" => escape = '\\',
551 "`" => escape = '`',
552 _ => diagnostics.push(ParseDiagnostic {
553 code: "P012",
554 severity: DiagnosticSeverity::Error,
555 message: format!("invalid escape token {value:?}; expected ` or \\"),
556 span: directive_span,
557 }),
558 }
559 }
560 directives.push(ParserDirective {
561 name,
562 value,
563 span: directive_span,
564 });
565 }
566
567 (directives, escape, diagnostics)
568}
569
570fn first_argument_offset(line: &PhysicalLine<'_>, keyword_end: usize) -> usize {
571 let relative = keyword_end.saturating_sub(line.start);
572 let tail = &line.text[relative.min(line.text.len())..];
573 tail.find(|character: char| !character.is_whitespace())
574 .map_or(keyword_end, |offset| keyword_end + offset)
575}
576
577fn continuation_cut(line: &str, escape: char) -> Option<usize> {
578 let end = line.trim_end_matches([' ', '\t']).len();
579 let prefix = &line[..end];
580 if !prefix.ends_with(escape) {
581 return None;
582 }
583 let count = prefix
584 .chars()
585 .rev()
586 .take_while(|character| *character == escape)
587 .count();
588 (count % 2 == 1).then(|| end - escape.len_utf8())
589}
590
591fn is_known_instruction(keyword: &str) -> bool {
592 matches!(
593 keyword,
594 "ADD"
595 | "ARG"
596 | "CMD"
597 | "COPY"
598 | "ENTRYPOINT"
599 | "ENV"
600 | "EXPOSE"
601 | "FROM"
602 | "HEALTHCHECK"
603 | "LABEL"
604 | "MAINTAINER"
605 | "ONBUILD"
606 | "RUN"
607 | "SHELL"
608 | "STOPSIGNAL"
609 | "USER"
610 | "VOLUME"
611 | "WORKDIR"
612 )
613}
614
615#[derive(Default)]
616struct LogicalBuffer {
617 text: String,
618 source_offsets: Vec<usize>,
619}
620
621impl LogicalBuffer {
622 fn push(&mut self, value: &str, source_start: usize) {
623 self.text.push_str(value);
624 self.source_offsets
625 .extend((0..value.len()).map(|offset| source_start + offset));
626 }
627}
628
629#[derive(Clone)]
630struct LexedWord {
631 word: Word,
632 logical_start: usize,
633}
634
635fn lex_words(
636 text: &str,
637 logical_base: usize,
638 source_offsets: &[usize],
639 lines: &[PhysicalLine<'_>],
640 escape: char,
641) -> (Vec<LexedWord>, Option<(&'static str, SourceSpan)>) {
642 let mut result = Vec::new();
643 let mut index = 0usize;
644 let mut unterminated = None;
645
646 while index < text.len() {
647 while index < text.len() {
648 let character = text[index..]
649 .chars()
650 .next()
651 .expect("valid character boundary");
652 if !character.is_whitespace() {
653 break;
654 }
655 index += character.len_utf8();
656 }
657 if index == text.len() {
658 break;
659 }
660
661 let start = index;
662 let mut value = String::new();
663 let mut quote: Option<char> = None;
664 let mut saw_single = false;
665 let mut saw_double = false;
666 let mut saw_unquoted = false;
667
668 while index < text.len() {
669 let character = text[index..]
670 .chars()
671 .next()
672 .expect("valid character boundary");
673 match quote {
674 Some('\'') => {
675 if character == '\'' {
676 quote = None;
677 saw_single = true;
678 index += character.len_utf8();
679 } else {
680 value.push(character);
681 index += character.len_utf8();
682 }
683 }
684 Some('"') => {
685 if character == '"' {
686 quote = None;
687 saw_double = true;
688 index += character.len_utf8();
689 } else if character == escape && index + escape.len_utf8() < text.len() {
690 index += escape.len_utf8();
691 let escaped = text[index..].chars().next().expect("escaped character");
692 value.push(escaped);
693 index += escaped.len_utf8();
694 } else {
695 value.push(character);
696 index += character.len_utf8();
697 }
698 }
699 _ => {
700 if character.is_whitespace() {
701 break;
702 }
703 if character == '\'' || character == '"' {
704 quote = Some(character);
705 index += character.len_utf8();
706 } else if character == escape && index + escape.len_utf8() < text.len() {
707 saw_unquoted = true;
708 index += escape.len_utf8();
709 let escaped = text[index..].chars().next().expect("escaped character");
710 value.push(escaped);
711 index += escaped.len_utf8();
712 } else {
713 saw_unquoted = true;
714 value.push(character);
715 index += character.len_utf8();
716 }
717 }
718 }
719 }
720
721 let end = index;
722 if let Some(open_quote) = quote {
723 let quote_name = if open_quote == '\'' {
724 "single"
725 } else {
726 "double"
727 };
728 unterminated = Some((
729 quote_name,
730 logical_span(
731 source_offsets,
732 logical_base + start,
733 logical_base + end,
734 lines,
735 ),
736 ));
737 }
738 let quote_style = match (saw_unquoted, saw_single, saw_double) {
739 (true, false, false) => QuoteStyle::Unquoted,
740 (false, true, false) => QuoteStyle::Single,
741 (false, false, true) => QuoteStyle::Double,
742 _ => QuoteStyle::Mixed,
743 };
744 let word_span = logical_span(
745 source_offsets,
746 logical_base + start,
747 logical_base + end,
748 lines,
749 );
750 let variables = variable_references(
751 &text[start..end],
752 source_offsets,
753 logical_base + start,
754 lines,
755 escape,
756 );
757 result.push(LexedWord {
758 word: Word {
759 raw: text[start..end].to_string(),
760 value,
761 quote: quote_style,
762 span: word_span,
763 variables,
764 },
765 logical_start: logical_base + start,
766 });
767 }
768
769 (result, unterminated)
770}
771
772fn variable_references(
773 text: &str,
774 source_offsets: &[usize],
775 logical_base: usize,
776 lines: &[PhysicalLine<'_>],
777 escape: char,
778) -> Vec<VariableReference> {
779 let bytes = text.as_bytes();
780 let mut result = Vec::new();
781 let mut index = 0usize;
782 let mut quote = None;
783
784 while index < bytes.len() {
785 let byte = bytes[index];
786 if quote == Some(b'\'') {
787 if byte == b'\'' {
788 quote = None;
789 }
790 index += 1;
791 continue;
792 }
793 if byte == b'\'' {
794 quote = Some(b'\'');
795 index += 1;
796 continue;
797 }
798 if byte == b'"' {
799 quote = if quote == Some(b'"') {
800 None
801 } else {
802 Some(b'"')
803 };
804 index += 1;
805 continue;
806 }
807 if byte == escape as u8 && escape.is_ascii() {
808 index = (index + escape.len_utf8() + 1).min(bytes.len());
809 continue;
810 }
811 if byte != b'$' {
812 index += 1;
813 continue;
814 }
815
816 let start = index;
817 if bytes.get(index + 1) == Some(&b'{') {
818 let mut cursor = index + 2;
819 let mut depth = 1usize;
820 while cursor < bytes.len() && depth > 0 {
821 match bytes[cursor] {
822 b'{' => depth += 1,
823 b'}' => depth -= 1,
824 b'\\' => cursor += 1,
825 _ => {}
826 }
827 cursor += 1;
828 }
829 if depth != 0 {
830 index += 1;
831 continue;
832 }
833 let inside = &text[index + 2..cursor - 1];
834 let name_end = inside
835 .find(|character: char| !character.is_ascii_alphanumeric() && character != '_')
836 .unwrap_or(inside.len());
837 if name_end == 0 {
838 index = cursor;
839 continue;
840 }
841 result.push(VariableReference {
842 name: inside[..name_end].to_string(),
843 modifier: (name_end < inside.len()).then(|| inside[name_end..].to_string()),
844 braced: true,
845 span: logical_span(
846 source_offsets,
847 logical_base + start,
848 logical_base + cursor,
849 lines,
850 ),
851 });
852 index = cursor;
853 } else {
854 let mut cursor = index + 1;
855 if bytes
856 .get(cursor)
857 .is_none_or(|byte| !byte.is_ascii_alphabetic() && *byte != b'_')
858 {
859 index += 1;
860 continue;
861 }
862 cursor += 1;
863 while bytes
864 .get(cursor)
865 .is_some_and(|byte| byte.is_ascii_alphanumeric() || *byte == b'_')
866 {
867 cursor += 1;
868 }
869 result.push(VariableReference {
870 name: text[index + 1..cursor].to_string(),
871 modifier: None,
872 braced: false,
873 span: logical_span(
874 source_offsets,
875 logical_base + start,
876 logical_base + cursor,
877 lines,
878 ),
879 });
880 index = cursor;
881 }
882 }
883
884 result
885}
886
887fn parse_flag(word: &Word) -> Option<InstructionFlag> {
888 let body = word.value.strip_prefix("--")?;
889 if body.is_empty() {
890 return None;
891 }
892 let (name, value) = body.split_once('=').map_or((body, None), |(name, value)| {
893 (name, Some(value.to_string()))
894 });
895 if name.is_empty() {
896 return None;
897 }
898 Some(InstructionFlag {
899 name: name.to_ascii_lowercase(),
900 value,
901 raw: word.raw.clone(),
902 span: word.span,
903 })
904}
905
906fn parse_form(
907 keyword: &str,
908 command: &str,
909 words: &[LexedWord],
910 command_word: usize,
911 lines: &[PhysicalLine<'_>],
912 diagnostics: &mut Vec<ParseDiagnostic>,
913) -> InstructionForm {
914 let supports_json = matches!(
915 keyword,
916 "ADD" | "CMD" | "COPY" | "ENTRYPOINT" | "RUN" | "SHELL" | "VOLUME"
917 );
918 if !supports_json || !command.starts_with('[') {
919 if keyword == "SHELL" && !command.is_empty() {
920 let target = words
921 .get(command_word)
922 .map_or_else(|| span(lines, 0, 0), |word| word.word.span);
923 diagnostics.push(ParseDiagnostic {
924 code: "P007",
925 severity: DiagnosticSeverity::Error,
926 message: "SHELL requires JSON array syntax".to_string(),
927 span: target,
928 });
929 }
930 return InstructionForm::Shell;
931 }
932
933 match serde_json::from_str::<Vec<String>>(command) {
934 Ok(values) => InstructionForm::Json(values),
935 Err(error) => {
936 let target = words
937 .get(command_word)
938 .map_or_else(|| span(lines, 0, 0), |word| word.word.span);
939 diagnostics.push(ParseDiagnostic {
940 code: "P007",
941 severity: DiagnosticSeverity::Error,
942 message: format!("invalid JSON instruction form: {error}"),
943 span: target,
944 });
945 InstructionForm::InvalidJson
946 }
947 }
948}
949
950fn can_contain_heredoc(keyword: &str, command: &str, form: &InstructionForm) -> bool {
951 if !matches!(form, InstructionForm::Shell) {
952 return false;
953 }
954 if matches!(keyword, "ADD" | "COPY" | "RUN") {
955 return true;
956 }
957 if keyword != "ONBUILD" {
958 return false;
959 }
960 let nested = command
961 .split_whitespace()
962 .next()
963 .unwrap_or_default()
964 .to_ascii_uppercase();
965 matches!(nested.as_str(), "ADD" | "COPY" | "RUN")
966}
967
968#[derive(Debug)]
969struct HeredocSeed {
970 delimiter: String,
971 file_descriptor: Option<u32>,
972 strip_tabs: bool,
973 expand: bool,
974 marker_span: SourceSpan,
975}
976
977fn find_heredocs(
978 logical: &LogicalBuffer,
979 lines: &[PhysicalLine<'_>],
980 escape: char,
981) -> Vec<HeredocSeed> {
982 let text = logical.text.as_bytes();
983 let mut result = Vec::new();
984 let mut index = 0usize;
985 let mut quote = None;
986
987 while index + 1 < text.len() {
988 let byte = text[index];
989 if let Some(active_quote) = quote {
990 if byte == active_quote {
991 quote = None;
992 } else if byte == escape as u8 && active_quote == b'"' {
993 index += 1;
994 }
995 index += 1;
996 continue;
997 }
998 if byte == b'\'' || byte == b'"' {
999 quote = Some(byte);
1000 index += 1;
1001 continue;
1002 }
1003 if byte == escape as u8 {
1004 index += 2;
1005 continue;
1006 }
1007 if byte != b'<' || text[index + 1] != b'<' || text.get(index + 2) == Some(&b'<') {
1008 index += 1;
1009 continue;
1010 }
1011
1012 let mut marker_start = index;
1013 while marker_start > 0 && text[marker_start - 1].is_ascii_digit() {
1014 marker_start -= 1;
1015 }
1016 let file_descriptor = (marker_start < index)
1017 .then(|| logical.text[marker_start..index].parse::<u32>().ok())
1018 .flatten();
1019 let mut cursor = index + 2;
1020 let strip_tabs = text.get(cursor) == Some(&b'-');
1021 if strip_tabs {
1022 cursor += 1;
1023 }
1024 while text
1025 .get(cursor)
1026 .is_some_and(|byte| byte.is_ascii_whitespace())
1027 {
1028 cursor += 1;
1029 }
1030 let word_start = cursor;
1031 let mut delimiter = String::new();
1032 let mut delimiter_quote: Option<char> = None;
1033 let mut quoted = false;
1034
1035 while cursor < text.len() {
1036 let current = logical.text[cursor..]
1037 .chars()
1038 .next()
1039 .expect("valid character boundary");
1040 if let Some(active_quote) = delimiter_quote {
1041 if current == active_quote {
1042 delimiter_quote = None;
1043 quoted = true;
1044 cursor += current.len_utf8();
1045 } else if current == escape
1046 && active_quote == '"'
1047 && cursor + escape.len_utf8() < text.len()
1048 {
1049 cursor += escape.len_utf8();
1050 let escaped = logical.text[cursor..]
1051 .chars()
1052 .next()
1053 .expect("escaped delimiter character");
1054 delimiter.push(escaped);
1055 quoted = true;
1056 cursor += escaped.len_utf8();
1057 } else {
1058 delimiter.push(current);
1059 cursor += current.len_utf8();
1060 }
1061 continue;
1062 }
1063 if current == '\'' || current == '"' {
1064 delimiter_quote = Some(current);
1065 quoted = true;
1066 cursor += current.len_utf8();
1067 } else if current == escape && cursor + escape.len_utf8() < text.len() {
1068 cursor += escape.len_utf8();
1069 let escaped = logical.text[cursor..]
1070 .chars()
1071 .next()
1072 .expect("escaped delimiter character");
1073 delimiter.push(escaped);
1074 quoted = true;
1075 cursor += escaped.len_utf8();
1076 } else if current.is_whitespace() || ";&|<>".contains(current) {
1077 break;
1078 } else {
1079 delimiter.push(current);
1080 cursor += current.len_utf8();
1081 }
1082 }
1083
1084 if delimiter.is_empty() || cursor == word_start {
1085 index += 2;
1086 continue;
1087 }
1088 result.push(HeredocSeed {
1089 delimiter,
1090 file_descriptor,
1091 strip_tabs,
1092 expand: !quoted,
1093 marker_span: logical_span(&logical.source_offsets, marker_start, cursor, lines),
1094 });
1095 index = cursor;
1096 }
1097
1098 result
1099}
1100
1101fn logical_span(
1102 source_offsets: &[usize],
1103 start: usize,
1104 end: usize,
1105 lines: &[PhysicalLine<'_>],
1106) -> SourceSpan {
1107 let source_start = source_offsets
1108 .get(start)
1109 .copied()
1110 .or_else(|| source_offsets.last().map(|offset| offset + 1))
1111 .unwrap_or(0);
1112 let source_end = if end > start {
1113 source_offsets
1114 .get(end - 1)
1115 .map_or(source_start, |offset| offset + 1)
1116 } else {
1117 source_start
1118 };
1119 span(lines, source_start, source_end)
1120}
1121
1122fn span(lines: &[PhysicalLine<'_>], start: usize, end: usize) -> SourceSpan {
1123 SourceSpan {
1124 start: position(lines, start),
1125 end: position(lines, end),
1126 }
1127}
1128
1129fn position(lines: &[PhysicalLine<'_>], offset: usize) -> SourcePosition {
1130 let Some(line) = lines
1131 .iter()
1132 .rev()
1133 .find(|line| line.start <= offset)
1134 .or_else(|| lines.first())
1135 else {
1136 return SourcePosition {
1137 offset: 0,
1138 line: 1,
1139 column: 1,
1140 };
1141 };
1142 SourcePosition {
1143 offset,
1144 line: line.number,
1145 column: offset.saturating_sub(line.start) + 1,
1146 }
1147}