Skip to main content

compose_lens/syntax/
mod.rs

1//! Loss-aware YAML syntax documents.
2
3use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::source::{LineColumn, SourceId, SourceSpan, line_column};
5use std::collections::{BTreeMap, BTreeSet};
6use std::error::Error;
7use std::fmt;
8use std::sync::Arc;
9use yaml_edit::{
10    AnchorRegistry, AsYaml, Mapping, MappingMergedExt, Parse, ParseErrorKind, Scalar, ScalarType, ScalarValue,
11    YamlFile, YamlNode,
12};
13
14/// A generic YAML syntax error not covered by a more specific code.
15pub const YAML_SYNTAX_ERROR: DiagnosticCode = DiagnosticCode::new("compose.yaml.syntax");
16
17/// The private YAML backend did not include the complete source in the YAML document root.
18pub const YAML_UNPARSED_INPUT: DiagnosticCode = DiagnosticCode::new("compose.yaml.unparsed-input");
19
20/// A flow sequence is missing its closing bracket.
21pub const YAML_UNCLOSED_FLOW_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.yaml.unclosed-flow-sequence");
22
23/// A flow mapping is missing its closing brace.
24pub const YAML_UNCLOSED_FLOW_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.yaml.unclosed-flow-mapping");
25
26/// A quoted scalar is missing its closing quote.
27pub const YAML_UNTERMINATED_STRING: DiagnosticCode = DiagnosticCode::new("compose.yaml.unterminated-string");
28
29/// A loss-aware YAML document and its original source text.
30///
31/// The underlying concrete syntax tree is intentionally private so `ComposeLens` can maintain a
32/// stable API independently of its parser dependency. Preservation rendering emits the original
33/// source without normalization, interpolation, or environment access.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct SyntaxDocument {
36    source_id: SourceId,
37    source: Arc<str>,
38    parse: Parse<YamlFile>,
39}
40
41impl SyntaxDocument {
42    /// Parses YAML into a loss-aware document and structured diagnostics.
43    ///
44    /// Recoverable YAML errors are returned in [`SyntaxParse::diagnostics`] while the syntax
45    /// document remains available. Only sources too large for the concrete syntax tree produce a
46    /// fatal error.
47    ///
48    /// # Errors
49    ///
50    /// Returns [`SyntaxParseError`] when the source exceeds the concrete syntax tree's byte-offset
51    /// capacity. Malformed YAML is recoverable and is reported through structured diagnostics.
52    pub fn parse(source_id: SourceId, source: impl Into<Arc<str>>) -> Result<SyntaxParse, SyntaxParseError> {
53        let source = source.into();
54        if u32::try_from(source.len()).is_err() {
55            return Err(SyntaxParseError {
56                source_id,
57                byte_len: source.len(),
58            });
59        }
60
61        let parser_source = parser_compatible_source(&source);
62        let parse = YamlFile::parse(parser_source.as_deref().unwrap_or(&source));
63        let mut diagnostics: Vec<_> = parse
64            .positioned_errors()
65            .iter()
66            .map(|error| syntax_diagnostic(source_id, source.len(), error))
67            .collect();
68        if diagnostics.is_empty() {
69            if let Some(document_end) = unparsed_input_offset(&parse, &source) {
70                diagnostics.push(unparsed_input_diagnostic(source_id, source.len(), document_end));
71            }
72        }
73
74        Ok(SyntaxParse {
75            document: Self {
76                source_id,
77                source,
78                parse,
79            },
80            diagnostics,
81        })
82    }
83
84    /// Returns the source identifier supplied by the caller.
85    #[must_use]
86    pub const fn source_id(&self) -> SourceId {
87        self.source_id
88    }
89
90    /// Returns the original source text.
91    #[must_use]
92    pub fn source_text(&self) -> &str {
93        &self.source
94    }
95
96    /// Returns the span covering the complete source text.
97    #[must_use]
98    pub fn source_span(&self) -> SourceSpan {
99        SourceSpan::from_valid_offsets(self.source_id, 0, self.source.len())
100    }
101
102    /// Returns the source text covered by a span from this document.
103    #[must_use]
104    pub fn text(&self, span: SourceSpan) -> Option<&str> {
105        if span.source_id() != self.source_id || span.end() > self.source.len() {
106            return None;
107        }
108
109        self.source.get(span.range())
110    }
111
112    /// Converts a byte offset in this document to a one-based line and column.
113    #[must_use]
114    pub fn line_column(&self, byte_offset: usize) -> Option<LineColumn> {
115        line_column(&self.source, byte_offset)
116    }
117
118    /// Returns the number of YAML documents in the stream.
119    #[must_use]
120    pub fn document_count(&self) -> usize {
121        self.parse.tree().documents().count()
122    }
123
124    /// Returns the number of comment tokens retained by the concrete syntax tree.
125    #[must_use]
126    pub fn comment_count(&self) -> usize {
127        self.parse.tree().comments().count()
128    }
129
130    /// Returns the original source as preservation-oriented output.
131    #[must_use]
132    pub fn render_preserved(&self) -> String {
133        self.source.to_string()
134    }
135
136    pub(crate) fn yaml_file(&self) -> YamlFile {
137        self.parse.tree()
138    }
139
140    pub(crate) fn interpolatable_value_scalars(&self) -> Vec<ValueScalar> {
141        let mut values = Vec::new();
142        if let Some(document) = self.parse.tree().document() {
143            if let Some(mapping) = document.as_mapping() {
144                collect_value_scalars(self.source_id, &self.source, YamlNode::Mapping(mapping), &mut values);
145            } else if let Some(sequence) = document.as_sequence() {
146                collect_value_scalars(self.source_id, &self.source, YamlNode::Sequence(sequence), &mut values);
147            } else if let Some(scalar) = document.as_scalar() {
148                collect_value_scalars(self.source_id, &self.source, YamlNode::Scalar(scalar), &mut values);
149            }
150        }
151        values
152    }
153
154    pub(crate) fn editable_value_scalars(&self) -> Vec<EditableValueScalar> {
155        let mut values = Vec::new();
156        if let Some(document) = self.parse.tree().document() {
157            if let Some(mapping) = document.as_mapping() {
158                collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Mapping(mapping), &mut values);
159            } else if let Some(sequence) = document.as_sequence() {
160                collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Sequence(sequence), &mut values);
161            } else if let Some(scalar) = document.as_scalar() {
162                collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Scalar(scalar), &mut values);
163            }
164        }
165        values
166    }
167
168    pub(crate) fn merge_root(&self) -> Option<MergeSyntaxValue> {
169        let document = self.parse.tree().document()?;
170        let root = if let Some(mapping) = document.as_mapping() {
171            YamlNode::Mapping(mapping)
172        } else if let Some(sequence) = document.as_sequence() {
173            YamlNode::Sequence(sequence)
174        } else {
175            YamlNode::Scalar(document.as_scalar()?)
176        };
177        let registry = AnchorRegistry::from_document(&document);
178        Some(extract_merge_value(
179            self.source_id,
180            &self.source,
181            root,
182            &registry,
183            &mut Vec::new(),
184        ))
185    }
186}
187
188#[derive(Debug, Clone, PartialEq, Eq)]
189pub(crate) struct ValueScalar {
190    pub(crate) value: String,
191    pub(crate) span: SourceSpan,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub(crate) struct EditableValueScalar {
196    pub(crate) raw: String,
197    pub(crate) span: SourceSpan,
198}
199
200#[derive(Debug, Clone, Copy, PartialEq, Eq)]
201pub(crate) enum MergeScalarKind {
202    String,
203    Boolean,
204    Number,
205    Null,
206}
207
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub(crate) struct MergeSyntaxScalar {
210    pub(crate) raw: String,
211    pub(crate) value: String,
212    pub(crate) kind: MergeScalarKind,
213    pub(crate) span: SourceSpan,
214}
215
216#[derive(Debug, Clone, PartialEq, Eq)]
217pub(crate) struct MergeSyntaxEntry {
218    pub(crate) key: MergeSyntaxScalar,
219    pub(crate) value: MergeSyntaxValue,
220}
221
222#[derive(Debug, Clone, PartialEq, Eq)]
223pub(crate) enum MergeSyntaxValue {
224    Empty(SourceSpan),
225    Scalar(MergeSyntaxScalar),
226    Mapping {
227        entries: Vec<MergeSyntaxEntry>,
228        span: SourceSpan,
229    },
230    Sequence {
231        values: Vec<MergeSyntaxValue>,
232        span: SourceSpan,
233    },
234    Alias {
235        name: String,
236        span: SourceSpan,
237    },
238    Tagged {
239        tag: String,
240        value: Box<MergeSyntaxValue>,
241        span: SourceSpan,
242    },
243}
244
245/// The recoverable result of parsing one YAML source.
246#[derive(Debug, Clone, PartialEq, Eq)]
247pub struct SyntaxParse {
248    document: SyntaxDocument,
249    diagnostics: Vec<Diagnostic>,
250}
251
252impl SyntaxParse {
253    /// Returns the loss-aware syntax document.
254    #[must_use]
255    pub const fn document(&self) -> &SyntaxDocument {
256        &self.document
257    }
258
259    /// Returns all syntax diagnostics in source order.
260    #[must_use]
261    pub fn diagnostics(&self) -> &[Diagnostic] {
262        &self.diagnostics
263    }
264
265    /// Reports whether parsing produced no error diagnostics.
266    #[must_use]
267    pub fn is_valid(&self) -> bool {
268        !self
269            .diagnostics
270            .iter()
271            .any(|diagnostic| diagnostic.severity() == Severity::Error)
272    }
273
274    /// Separates the document and diagnostics.
275    #[must_use]
276    pub fn into_parts(self) -> (SyntaxDocument, Vec<Diagnostic>) {
277        (self.document, self.diagnostics)
278    }
279}
280
281/// A source that exceeds the concrete syntax tree's byte-offset capacity.
282#[derive(Debug, Clone, Copy, PartialEq, Eq)]
283pub struct SyntaxParseError {
284    source_id: SourceId,
285    byte_len: usize,
286}
287
288impl SyntaxParseError {
289    /// Returns the source identifier supplied to the parser.
290    #[must_use]
291    pub const fn source_id(self) -> SourceId {
292        self.source_id
293    }
294
295    /// Returns the rejected source length in bytes.
296    #[must_use]
297    pub const fn byte_len(self) -> usize {
298        self.byte_len
299    }
300}
301
302impl fmt::Display for SyntaxParseError {
303    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
304        write!(
305            formatter,
306            "{} contains {} bytes, exceeding the YAML syntax tree limit",
307            self.source_id, self.byte_len
308        )
309    }
310}
311
312impl Error for SyntaxParseError {}
313
314fn syntax_diagnostic(source_id: SourceId, source_len: usize, error: &yaml_edit::PositionedParseError) -> Diagnostic {
315    let (code, message) = match error.kind {
316        ParseErrorKind::UnclosedFlowSequence => (YAML_UNCLOSED_FLOW_SEQUENCE, "flow sequence is missing a closing `]`"),
317        ParseErrorKind::UnclosedFlowMapping => (YAML_UNCLOSED_FLOW_MAPPING, "flow mapping is missing a closing `}`"),
318        ParseErrorKind::UnterminatedString => (YAML_UNTERMINATED_STRING, "quoted scalar is missing its closing quote"),
319        ParseErrorKind::Other => (YAML_SYNTAX_ERROR, "invalid YAML syntax"),
320    };
321    let start = (error.range.start as usize).min(source_len);
322    let end = (error.range.end as usize).clamp(start, source_len);
323    let span = SourceSpan::from_valid_offsets(source_id, start, end);
324
325    Diagnostic::new(code, Severity::Error, message).with_label(DiagnosticLabel::primary(span, "syntax error"))
326}
327
328fn unparsed_input_diagnostic(source_id: SourceId, source_len: usize, document_end: usize) -> Diagnostic {
329    let span = SourceSpan::from_valid_offsets(source_id, document_end.min(source_len), source_len);
330
331    Diagnostic::new(
332        YAML_UNPARSED_INPUT,
333        Severity::Error,
334        "YAML parser did not include the complete input in the document root",
335    )
336    .with_label(DiagnosticLabel::primary(span, "input omitted from YAML document"))
337    .with_note("the original source remains available, but typed processing must not continue silently")
338}
339
340fn unparsed_input_offset(parse: &Parse<YamlFile>, source: &str) -> Option<usize> {
341    let document_end = parse
342        .tree()
343        .document()
344        .and_then(|document| {
345            document
346                .as_node()
347                .map(|node| u32::from(node.text_range().end()) as usize)
348        })
349        .unwrap_or_default();
350    source
351        .as_bytes()
352        .get(document_end..)
353        .is_some_and(|suffix| suffix.iter().any(|byte| !byte.is_ascii_whitespace()))
354        .then_some(document_end)
355}
356
357/// Produces a same-length input for private YAML backend limitations. It masks safe, non-colliding
358/// hyphens in anchor and alias names, leading option dashes, and commas in block-style plain
359/// scalars. Original source text remains authoritative for every exposed scalar and preservation
360/// operation.
361fn parser_compatible_source(source: &str) -> Option<String> {
362    let mut compatible = source.as_bytes().to_vec();
363    let mut changed = mask_blank_lines_after_mapping_keys(source, &mut compatible);
364    changed |= mask_non_colliding_anchor_hyphens(source, &mut compatible);
365    let mut offset = 0;
366    let mut block_scalar_indent = None;
367    let mut state = ParserCompatibilityState::default();
368
369    for line in source.split_inclusive('\n') {
370        let content = line.trim_end_matches(['\r', '\n']);
371        let indent = content.bytes().take_while(|byte| *byte == b' ').count();
372        let blank = content[indent..].trim().is_empty();
373
374        if let Some(header_indent) = block_scalar_indent {
375            if blank || indent > header_indent {
376                offset += line.len();
377                continue;
378            }
379            block_scalar_indent = None;
380        }
381
382        let (line_changed, starts_block_scalar) = mask_block_plain_commas(content, offset, &mut compatible, &mut state);
383        changed |= line_changed;
384        if starts_block_scalar {
385            block_scalar_indent = Some(indent);
386        }
387        offset += line.len();
388    }
389
390    if changed {
391        String::from_utf8(compatible).ok()
392    } else {
393        None
394    }
395}
396
397fn mask_blank_lines_after_mapping_keys(source: &str, compatible: &mut [u8]) -> bool {
398    let bytes = source.as_bytes();
399    let mut line_starts = vec![0];
400    line_starts.extend(
401        bytes
402            .iter()
403            .enumerate()
404            .filter_map(|(index, byte)| (*byte == b'\n').then_some(index + 1)),
405    );
406    let mut changed = false;
407
408    for line_index in 0..line_starts.len().saturating_sub(2) {
409        let start = line_starts[line_index];
410        let end = line_starts.get(line_index + 1).copied().unwrap_or(bytes.len());
411        let content_end = line_content_end(bytes, start, end);
412        let content = &source[start..content_end];
413        let trimmed = content.trim();
414        if trimmed.starts_with('#') || !trimmed.ends_with(':') {
415            continue;
416        }
417
418        let mut next_line = line_index + 1;
419        while next_line < line_starts.len() {
420            let next_start = line_starts[next_line];
421            let next_end = line_starts.get(next_line + 1).copied().unwrap_or(bytes.len());
422            let next_content_end = line_content_end(bytes, next_start, next_end);
423            if !source[next_start..next_content_end].trim().is_empty() {
424                break;
425            }
426            next_line += 1;
427        }
428        if next_line == line_index + 1 || next_line >= line_starts.len() {
429            continue;
430        }
431
432        let key_indent = content.bytes().take_while(|byte| *byte == b' ').count();
433        let value_start = line_starts[next_line];
434        let value_indent = source[value_start..].bytes().take_while(|byte| *byte == b' ').count();
435        if value_indent <= key_indent {
436            continue;
437        }
438
439        // Keep the final blank line's line ending and turn the preceding separators into trailing
440        // spaces. This removes the backend-only blank-line ambiguity without changing byte spans.
441        let final_blank_start = line_starts[next_line - 1];
442        compatible[content_end..final_blank_start].fill(b' ');
443        changed = true;
444    }
445    changed
446}
447
448fn line_content_end(bytes: &[u8], start: usize, end: usize) -> usize {
449    let mut content_end = end;
450    if content_end > start && bytes[content_end - 1] == b'\n' {
451        content_end -= 1;
452    }
453    if content_end > start && bytes[content_end - 1] == b'\r' {
454        content_end -= 1;
455    }
456    content_end
457}
458
459fn mask_non_colliding_anchor_hyphens(source: &str, compatible: &mut [u8]) -> bool {
460    let bytes = source.as_bytes();
461    let mut occurrences = Vec::new();
462    let mut originals_by_normalized = BTreeMap::<Vec<u8>, BTreeSet<Vec<u8>>>::new();
463    let mut index = 0;
464
465    while index < bytes.len() {
466        if !matches!(bytes[index], b'&' | b'*') {
467            index += 1;
468            continue;
469        }
470        let line_start = bytes[..index]
471            .iter()
472            .rposition(|byte| *byte == b'\n')
473            .map_or(0, |position| position + 1);
474        let preceding = bytes[line_start..index]
475            .iter()
476            .rfind(|byte| !byte.is_ascii_whitespace())
477            .copied();
478        if preceding.is_some_and(|byte| !matches!(byte, b':' | b'-' | b'[' | b'{' | b',' | b'?')) {
479            index += 1;
480            continue;
481        }
482        let start = index + 1;
483        let mut end = start;
484        while bytes.get(end).is_some_and(|byte| !anchor_name_delimiter(*byte)) {
485            end += 1;
486        }
487        if start == end {
488            index += 1;
489            continue;
490        }
491
492        let original = bytes[start..end].to_vec();
493        let normalized = original
494            .iter()
495            .map(|byte| if *byte == b'-' { b'_' } else { *byte })
496            .collect::<Vec<_>>();
497        originals_by_normalized
498            .entry(normalized.clone())
499            .or_default()
500            .insert(original.clone());
501        occurrences.push((start, end, original, normalized));
502        index = end;
503    }
504
505    let mut changed = false;
506    for (start, end, original, normalized) in occurrences {
507        if !original.contains(&b'-')
508            || originals_by_normalized
509                .get(&normalized)
510                .is_none_or(|originals| originals.len() != 1)
511        {
512            continue;
513        }
514        for byte in &mut compatible[start..end] {
515            if *byte == b'-' {
516                *byte = b'_';
517                changed = true;
518            }
519        }
520    }
521    changed
522}
523
524const fn anchor_name_delimiter(byte: u8) -> bool {
525    byte.is_ascii_whitespace() || matches!(byte, b'[' | b']' | b'{' | b'}' | b',')
526}
527
528#[derive(Debug, Default)]
529struct ParserCompatibilityState {
530    flow_depth: u32,
531    quote: Option<u8>,
532    escaped: bool,
533}
534
535fn mask_block_plain_commas(
536    content: &str,
537    offset: usize,
538    compatible: &mut [u8],
539    state: &mut ParserCompatibilityState,
540) -> (bool, bool) {
541    let bytes = content.as_bytes();
542    let mut index = 0;
543    let mut first_token = true;
544    let mut plain_started = false;
545    let mut eligible_plain_value = false;
546    let mut changed = false;
547
548    while index < bytes.len() {
549        let byte = bytes[index];
550
551        if let Some(delimiter) = state.quote {
552            if delimiter == b'"' && state.escaped {
553                state.escaped = false;
554            } else if delimiter == b'"' && byte == b'\\' {
555                state.escaped = true;
556            } else if byte == delimiter {
557                if delimiter == b'\'' && bytes.get(index + 1) == Some(&b'\'') {
558                    index += 1;
559                } else {
560                    state.quote = None;
561                }
562            }
563            index += 1;
564            continue;
565        }
566
567        if byte == b'#' && (index == 0 || bytes[index - 1].is_ascii_whitespace()) {
568            break;
569        }
570
571        if eligible_plain_value && !plain_started && matches!(byte, b'!' | b'&') {
572            index += 1;
573            while bytes.get(index).is_some_and(|byte| !byte.is_ascii_whitespace()) {
574                index += 1;
575            }
576            first_token = false;
577            continue;
578        }
579
580        if matches!(byte, b'\'' | b'"') && !plain_started {
581            state.quote = Some(byte);
582            first_token = false;
583            index += 1;
584            continue;
585        }
586
587        if state.flow_depth > 0 {
588            match byte {
589                b'[' | b'{' => state.flow_depth += 1,
590                b']' | b'}' => state.flow_depth -= 1,
591                _ => {}
592            }
593            index += 1;
594            continue;
595        }
596
597        let token_boundary = index == 0 || bytes[index - 1].is_ascii_whitespace();
598        if matches!(byte, b'[' | b'{') && (!plain_started || (eligible_plain_value && token_boundary)) {
599            state.flow_depth = 1;
600            first_token = false;
601            index += 1;
602            continue;
603        }
604
605        if byte.is_ascii_whitespace() {
606            index += 1;
607            continue;
608        }
609
610        if first_token && byte == b'-' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
611            eligible_plain_value = true;
612            plain_started = false;
613            first_token = false;
614            index += 1;
615            continue;
616        }
617
618        if byte == b':' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
619            eligible_plain_value = true;
620            plain_started = false;
621            first_token = false;
622            index += 1;
623            continue;
624        }
625
626        if eligible_plain_value && !plain_started && matches!(byte, b'|' | b'>') {
627            return (changed, true);
628        }
629
630        // yaml-edit currently treats the first dash in an unquoted `- --option` item as another
631        // block-sequence indicator. Masking one dash in the private, same-length parser input keeps
632        // the item scalar while scalar extraction still reads the authored source.
633        if eligible_plain_value && !plain_started && byte == b'-' && bytes.get(index + 1) == Some(&b'-') {
634            compatible[offset + index] = b'_';
635            changed = true;
636        }
637
638        if eligible_plain_value && plain_started && byte == b',' {
639            compatible[offset + index] = b'_';
640            changed = true;
641        }
642
643        plain_started = true;
644        first_token = false;
645        index += 1;
646    }
647
648    state.escaped = false;
649    (changed, false)
650}
651
652pub(crate) fn scalar_raw_from_source(source: &str, scalar: &Scalar) -> String {
653    let range = scalar.byte_range();
654    source
655        .get(range.start as usize..range.end as usize)
656        .map_or_else(|| scalar.value(), str::to_owned)
657}
658
659pub(crate) fn scalar_string_from_source(source: &str, scalar: &Scalar) -> String {
660    let authored = scalar_raw_from_source(source, scalar);
661    if authored == scalar.value() {
662        scalar.as_string()
663    } else {
664        // The compatibility overlay only rewrites complete, single-line block plain scalars.
665        authored
666    }
667}
668
669fn collect_value_scalars(source_id: SourceId, source: &str, node: YamlNode, values: &mut Vec<ValueScalar>) {
670    match node {
671        YamlNode::Scalar(scalar) => collect_scalar(source_id, source, &scalar, values),
672        YamlNode::Mapping(mapping) => {
673            for value in mapping.entries().filter_map(|entry| entry.value_node()) {
674                collect_value_scalars(source_id, source, value, values);
675            }
676        }
677        YamlNode::Sequence(sequence) => {
678            for value in sequence.values() {
679                collect_value_scalars(source_id, source, value, values);
680            }
681        }
682        YamlNode::TaggedNode(tagged) => {
683            if let Some(node) = tagged
684                .as_node()
685                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
686            {
687                collect_value_scalars(source_id, source, node, values);
688            }
689        }
690        YamlNode::Alias(_) => {}
691    }
692}
693
694fn collect_editable_value_scalars(
695    source_id: SourceId,
696    source: &str,
697    node: YamlNode,
698    values: &mut Vec<EditableValueScalar>,
699) {
700    match node {
701        YamlNode::Scalar(scalar) => {
702            values.push(EditableValueScalar {
703                raw: scalar_raw_from_source(source, &scalar),
704                span: position_span(source_id, scalar.byte_range()),
705            });
706        }
707        YamlNode::Mapping(mapping) => {
708            for value in mapping.entries().filter_map(|entry| entry.value_node()) {
709                collect_editable_value_scalars(source_id, source, value, values);
710            }
711        }
712        YamlNode::Sequence(sequence) => {
713            for value in sequence.values() {
714                collect_editable_value_scalars(source_id, source, value, values);
715            }
716        }
717        YamlNode::TaggedNode(tagged) => {
718            if let Some(node) = tagged
719                .as_node()
720                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
721            {
722                collect_editable_value_scalars(source_id, source, node, values);
723            }
724        }
725        YamlNode::Alias(_) => {}
726    }
727}
728
729fn extract_merge_value(
730    source_id: SourceId,
731    source: &str,
732    node: YamlNode,
733    registry: &AnchorRegistry,
734    aliases: &mut Vec<String>,
735) -> MergeSyntaxValue {
736    match node {
737        YamlNode::Scalar(scalar) => MergeSyntaxValue::Scalar(extract_merge_scalar(source_id, source, &scalar)),
738        YamlNode::Mapping(mapping) => extract_merge_mapping(source_id, source, &mapping, registry, aliases),
739        YamlNode::Sequence(sequence) => {
740            let span = position_span(source_id, sequence.byte_range());
741            let values = sequence
742                .values()
743                .map(|value| extract_merge_value(source_id, source, value, registry, aliases))
744                .collect();
745            MergeSyntaxValue::Sequence { values, span }
746        }
747        YamlNode::Alias(alias) => {
748            let name = alias.name();
749            let span = yaml_node_span(source_id, &YamlNode::Alias(alias.clone()));
750            if aliases.contains(&name) || aliases.len() >= 64 {
751                return MergeSyntaxValue::Alias {
752                    name: original_alias_name(source, span).unwrap_or(name),
753                    span,
754                };
755            }
756            if let Some(target) = registry.resolve(&name).and_then(|node| {
757                YamlNode::from_syntax(node.clone()).or_else(|| node.children().find_map(YamlNode::from_syntax))
758            }) {
759                aliases.push(name);
760                let value = extract_merge_value(source_id, source, target, registry, aliases);
761                let _ = aliases.pop();
762                value
763            } else {
764                MergeSyntaxValue::Alias {
765                    name: original_alias_name(source, span).unwrap_or(name),
766                    span,
767                }
768            }
769        }
770        YamlNode::TaggedNode(tagged) => {
771            let span = yaml_node_span(source_id, &YamlNode::TaggedNode(tagged.clone()));
772            let value = tagged
773                .as_node()
774                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
775                .map_or_else(
776                    || MergeSyntaxValue::Empty(span),
777                    |value| extract_merge_value(source_id, source, value, registry, aliases),
778                );
779            MergeSyntaxValue::Tagged {
780                tag: tagged.tag().unwrap_or_default(),
781                value: Box::new(value),
782                span,
783            }
784        }
785    }
786}
787
788fn original_alias_name(source: &str, span: SourceSpan) -> Option<String> {
789    source.get(span.range())?.strip_prefix('*').map(ToOwned::to_owned)
790}
791
792fn extract_merge_mapping(
793    source_id: SourceId,
794    source: &str,
795    mapping: &Mapping,
796    registry: &AnchorRegistry,
797    aliases: &mut Vec<String>,
798) -> MergeSyntaxValue {
799    let span = position_span(source_id, mapping.byte_range());
800    let direct = flatten_merge_fields(source_id, source, raw_merge_fields(source_id, source, mapping));
801    let mut entries = Vec::new();
802    let mut direct_keys = Vec::new();
803
804    for field in direct {
805        if field.key.value == "<<" {
806            continue;
807        }
808        direct_keys.push(field.key.value.clone());
809        let value = field.value.map_or_else(
810            || {
811                MergeSyntaxValue::Empty(SourceSpan::from_valid_offsets(
812                    source_id,
813                    field.key.span.end(),
814                    field.key.span.end(),
815                ))
816            },
817            |value| extract_merge_value(source_id, source, resolve_alias(value, registry), registry, aliases),
818        );
819        entries.push(MergeSyntaxEntry { key: field.key, value });
820    }
821
822    for (key, value) in mapping.merged(registry).iter() {
823        let Some(key) = key
824            .as_scalar()
825            .map(|scalar| extract_merge_scalar(source_id, source, scalar))
826        else {
827            continue;
828        };
829        if direct_keys.contains(&key.value) {
830            continue;
831        }
832        entries.push(MergeSyntaxEntry {
833            key,
834            value: extract_merge_value(source_id, source, value, registry, aliases),
835        });
836    }
837
838    MergeSyntaxValue::Mapping { entries, span }
839}
840
841#[derive(Debug)]
842struct RawMergeField {
843    key: MergeSyntaxScalar,
844    value: Option<YamlNode>,
845}
846
847fn raw_merge_fields(source_id: SourceId, source: &str, mapping: &Mapping) -> Vec<RawMergeField> {
848    mapping
849        .entries()
850        .filter_map(|entry| {
851            let key = entry.key_node()?.as_scalar().cloned()?;
852            Some(RawMergeField {
853                key: extract_merge_scalar(source_id, source, &key),
854                value: entry.value_node(),
855            })
856        })
857        .collect()
858}
859
860fn flatten_merge_fields(source_id: SourceId, source: &str, fields: Vec<RawMergeField>) -> Vec<RawMergeField> {
861    let Some(target_column) = fields
862        .first()
863        .map(|field| source_column(source, field.key.span.start()))
864    else {
865        return fields;
866    };
867    recover_merge_fields(source_id, source, fields, target_column)
868}
869
870fn recover_merge_fields(
871    source_id: SourceId,
872    source: &str,
873    fields: Vec<RawMergeField>,
874    target_column: usize,
875) -> Vec<RawMergeField> {
876    let mut flattened = Vec::new();
877    for mut field in fields {
878        let field_column = source_column(source, field.key.span.start());
879        let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
880        let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
881            !is_flow_mapping(source, mapping)
882                && mapping
883                    .entries()
884                    .find_map(|entry| entry.key_node()?.as_scalar().map(Scalar::byte_range))
885                    .is_some_and(|position| source_column(source, position.start as usize) <= field_column)
886        });
887        if continuation {
888            field.value = None;
889        }
890        if field_column == target_column {
891            flattened.push(field);
892        }
893        if let Some(mapping) = nested_mapping.filter(|mapping| !is_flow_mapping(source, mapping)) {
894            let nested = raw_merge_fields(source_id, source, &mapping);
895            flattened.extend(recover_merge_fields(source_id, source, nested, target_column));
896        }
897    }
898    flattened
899}
900
901fn is_flow_mapping(source: &str, mapping: &Mapping) -> bool {
902    let position = mapping.byte_range();
903    source
904        .get(position.start as usize..position.end as usize)
905        .is_some_and(|text| text.trim_start().starts_with('{'))
906}
907
908fn source_column(source: &str, offset: usize) -> usize {
909    let prefix = &source[..offset.min(source.len())];
910    let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
911    source[line_start..offset.min(source.len())].chars().count()
912}
913
914fn resolve_alias(node: YamlNode, registry: &AnchorRegistry) -> YamlNode {
915    let YamlNode::Alias(alias) = &node else {
916        return node;
917    };
918    registry
919        .resolve(&alias.name())
920        .and_then(|target| {
921            YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
922        })
923        .unwrap_or(node)
924}
925
926fn extract_merge_scalar(source_id: SourceId, source: &str, scalar: &Scalar) -> MergeSyntaxScalar {
927    let kind = match ScalarValue::from_scalar(scalar).scalar_type() {
928        ScalarType::Boolean => MergeScalarKind::Boolean,
929        ScalarType::Integer | ScalarType::Float => MergeScalarKind::Number,
930        ScalarType::Null => MergeScalarKind::Null,
931        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MergeScalarKind::String,
932    };
933    MergeSyntaxScalar {
934        raw: scalar_raw_from_source(source, scalar),
935        value: scalar_string_from_source(source, scalar),
936        kind,
937        span: position_span(source_id, scalar.byte_range()),
938    }
939}
940
941fn yaml_node_span(source_id: SourceId, node: &YamlNode) -> SourceSpan {
942    let Some(syntax) = node.as_node() else {
943        return SourceSpan::from_valid_offsets(source_id, 0, 0);
944    };
945    let range = syntax.text_range();
946    SourceSpan::from_valid_offsets(
947        source_id,
948        u32::from(range.start()) as usize,
949        u32::from(range.end()) as usize,
950    )
951}
952
953fn position_span(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
954    SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
955}
956
957fn collect_scalar(source_id: SourceId, source: &str, scalar: &Scalar, values: &mut Vec<ValueScalar>) {
958    let raw = scalar_raw_from_source(source, scalar);
959    let eligible_style = !raw.starts_with('\'') && !raw.starts_with('|') && !raw.starts_with('>');
960    if !eligible_style || !raw.contains('$') {
961        return;
962    }
963    let position = scalar.byte_range();
964    values.push(ValueScalar {
965        value: scalar_string_from_source(source, scalar),
966        span: SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize),
967    });
968}
969
970#[cfg(test)]
971mod tests {
972    use super::{SyntaxDocument, parser_compatible_source, unparsed_input_offset};
973    use crate::source::SourceId;
974    use yaml_edit::YamlFile;
975
976    fn assert_send_and_sync<T: Send + Sync>() {}
977
978    #[test]
979    fn syntax_documents_are_send_and_sync() {
980        assert_send_and_sync::<SyntaxDocument>();
981    }
982
983    #[test]
984    fn parsing_never_reads_the_process_environment() -> Result<(), Box<dyn std::error::Error>> {
985        let source = "services:\n  app:\n    image: ${COMPOSE_LENS_SECRET}\n";
986        let parsed = SyntaxDocument::parse(SourceId::new(1), source)?;
987
988        assert_eq!(parsed.document().render_preserved(), source);
989        assert!(parsed.is_valid());
990        Ok(())
991    }
992
993    #[test]
994    fn complete_root_guard_detects_the_private_backends_raw_comma_omission() {
995        let source = "services:\n  app:\n    volumes:\n      - ./data:/data:Z,ro\n  later:\n    image: later\n";
996        let backend = YamlFile::parse(source);
997
998        assert!(backend.positioned_errors().is_empty());
999        assert!(unparsed_input_offset(&backend, source).is_some());
1000    }
1001
1002    #[test]
1003    fn anchor_compatibility_does_not_merge_colliding_names() {
1004        let source = "first: &shared-name one\nsecond: &shared_name two\n";
1005
1006        assert_eq!(parser_compatible_source(source), None);
1007    }
1008
1009    #[test]
1010    fn anchor_compatibility_ignores_scalar_and_comment_content() {
1011        let source = "quoted: \"*not-an-alias\"\nplain: echo &not-an-anchor\n# *also-not-an-alias\n";
1012
1013        assert_eq!(parser_compatible_source(source), None);
1014    }
1015}