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