Skip to main content

dockerfile_roast/
parser.rs

1//! Span-aware Dockerfile parser.
2//!
3//! The parser deliberately keeps the long-standing `Instruction` fields used
4//! by the linter while attaching structured flags, heredocs, words, variables,
5//! directives, source spans, forms, and recoverable diagnostics.
6
7use std::collections::HashSet;
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
10pub struct SourcePosition {
11    /// Zero-based UTF-8 byte offset in the original Dockerfile.
12    pub offset: usize,
13    /// One-based physical line number.
14    pub line: usize,
15    /// One-based byte column.
16    pub column: usize,
17}
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq)]
20pub struct SourceSpan {
21    /// Inclusive start position.
22    pub start: SourcePosition,
23    /// Exclusive end position.
24    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    /// Everything following the variable name inside `${...}`, such as
74    /// `:-fallback`, `#prefix`, or `/from/to`.
75    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    /// Compatibility field: one-based line where the instruction starts.
111    pub line: usize,
112    /// Uppercase instruction keyword.
113    pub instruction: String,
114    /// Compatibility field: normalized arguments. RUN heredoc bodies are
115    /// appended so existing shell-oriented rules inspect the executed script.
116    pub arguments: String,
117    /// Exact original source covered by this instruction, including heredocs.
118    pub raw: String,
119    pub span: SourceSpan,
120    pub keyword_span: SourceSpan,
121    pub arguments_span: SourceSpan,
122    /// Continuation-normalized arguments with significant whitespace retained.
123    pub logical_arguments: String,
124    /// Arguments after leading BuildKit flags.
125    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
141/// Compatibility entry point used by existing rules.
142pub fn parse(content: &str) -> Vec<Instruction> {
143    parse_document(content).instructions
144}
145
146/// Parse a complete Dockerfile and retain both its AST and diagnostics.
147pub 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}