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, ScalarStyle, ScalarType,
11    ScalarValue, 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) plain: bool,
214    pub(crate) strict_yaml_string: bool,
215    pub(crate) span: SourceSpan,
216}
217
218#[derive(Debug, Clone, PartialEq, Eq)]
219pub(crate) struct MergeSyntaxEntry {
220    pub(crate) key: MergeSyntaxScalar,
221    pub(crate) value: MergeSyntaxValue,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub(crate) enum MergeSyntaxValue {
226    Empty(SourceSpan),
227    Scalar(MergeSyntaxScalar),
228    Mapping {
229        entries: Vec<MergeSyntaxEntry>,
230        span: SourceSpan,
231    },
232    Sequence {
233        values: Vec<MergeSyntaxValue>,
234        span: SourceSpan,
235    },
236    Alias {
237        name: String,
238        span: SourceSpan,
239    },
240    Tagged {
241        tag: String,
242        value: Box<MergeSyntaxValue>,
243        span: SourceSpan,
244    },
245}
246
247/// The recoverable result of parsing one YAML source.
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct SyntaxParse {
250    document: SyntaxDocument,
251    diagnostics: Vec<Diagnostic>,
252}
253
254impl SyntaxParse {
255    /// Returns the loss-aware syntax document.
256    #[must_use]
257    pub const fn document(&self) -> &SyntaxDocument {
258        &self.document
259    }
260
261    /// Returns all syntax diagnostics in source order.
262    #[must_use]
263    pub fn diagnostics(&self) -> &[Diagnostic] {
264        &self.diagnostics
265    }
266
267    /// Reports whether parsing produced no error diagnostics.
268    #[must_use]
269    pub fn is_valid(&self) -> bool {
270        !self
271            .diagnostics
272            .iter()
273            .any(|diagnostic| diagnostic.severity() == Severity::Error)
274    }
275
276    /// Separates the document and diagnostics.
277    #[must_use]
278    pub fn into_parts(self) -> (SyntaxDocument, Vec<Diagnostic>) {
279        (self.document, self.diagnostics)
280    }
281}
282
283/// A source that exceeds the concrete syntax tree's byte-offset capacity.
284#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct SyntaxParseError {
286    source_id: SourceId,
287    byte_len: usize,
288}
289
290impl SyntaxParseError {
291    /// Returns the source identifier supplied to the parser.
292    #[must_use]
293    pub const fn source_id(self) -> SourceId {
294        self.source_id
295    }
296
297    /// Returns the rejected source length in bytes.
298    #[must_use]
299    pub const fn byte_len(self) -> usize {
300        self.byte_len
301    }
302}
303
304impl fmt::Display for SyntaxParseError {
305    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
306        write!(
307            formatter,
308            "{} contains {} bytes, exceeding the YAML syntax tree limit",
309            self.source_id, self.byte_len
310        )
311    }
312}
313
314impl Error for SyntaxParseError {}
315
316fn syntax_diagnostic(source_id: SourceId, source_len: usize, error: &yaml_edit::PositionedParseError) -> Diagnostic {
317    let (code, message) = match error.kind {
318        ParseErrorKind::UnclosedFlowSequence => (YAML_UNCLOSED_FLOW_SEQUENCE, "flow sequence is missing a closing `]`"),
319        ParseErrorKind::UnclosedFlowMapping => (YAML_UNCLOSED_FLOW_MAPPING, "flow mapping is missing a closing `}`"),
320        ParseErrorKind::UnterminatedString => (YAML_UNTERMINATED_STRING, "quoted scalar is missing its closing quote"),
321        ParseErrorKind::Other => (YAML_SYNTAX_ERROR, "invalid YAML syntax"),
322    };
323    let start = (error.range.start as usize).min(source_len);
324    let end = (error.range.end as usize).clamp(start, source_len);
325    let span = SourceSpan::from_valid_offsets(source_id, start, end);
326
327    Diagnostic::new(code, Severity::Error, message).with_label(DiagnosticLabel::primary(span, "syntax error"))
328}
329
330fn unparsed_input_diagnostic(source_id: SourceId, source_len: usize, document_end: usize) -> Diagnostic {
331    let span = SourceSpan::from_valid_offsets(source_id, document_end.min(source_len), source_len);
332
333    Diagnostic::new(
334        YAML_UNPARSED_INPUT,
335        Severity::Error,
336        "YAML parser did not include the complete input in the document root",
337    )
338    .with_label(DiagnosticLabel::primary(span, "input omitted from YAML document"))
339    .with_note("the original source remains available, but typed processing must not continue silently")
340}
341
342fn unparsed_input_offset(parse: &Parse<YamlFile>, source: &str) -> Option<usize> {
343    let document_end = parse
344        .tree()
345        .document()
346        .and_then(|document| {
347            document
348                .as_node()
349                .map(|node| u32::from(node.text_range().end()) as usize)
350        })
351        .unwrap_or_default();
352    source
353        .as_bytes()
354        .get(document_end..)
355        .is_some_and(|suffix| suffix.iter().any(|byte| !byte.is_ascii_whitespace()))
356        .then_some(document_end)
357}
358
359/// Produces a same-length input for private YAML backend limitations. It masks safe, non-colliding
360/// hyphens in anchor and alias names, leading option dashes, and commas in block-style plain
361/// scalars. Original source text remains authoritative for every exposed scalar and preservation
362/// operation.
363fn parser_compatible_source(source: &str) -> Option<String> {
364    let mut compatible = source.as_bytes().to_vec();
365    let mut changed = mask_blank_lines_after_mapping_keys(source, &mut compatible);
366    changed |= mask_non_colliding_anchor_hyphens(source, &mut compatible);
367    let mut offset = 0;
368    let mut block_scalar_indent = None;
369    let mut state = ParserCompatibilityState::default();
370
371    for line in source.split_inclusive('\n') {
372        let content = line.trim_end_matches(['\r', '\n']);
373        let indent = content.bytes().take_while(|byte| *byte == b' ').count();
374        let blank = content[indent..].trim().is_empty();
375
376        if let Some(header_indent) = block_scalar_indent {
377            if blank || indent > header_indent {
378                offset += line.len();
379                continue;
380            }
381            block_scalar_indent = None;
382        }
383
384        let (line_changed, starts_block_scalar) = mask_block_plain_commas(content, offset, &mut compatible, &mut state);
385        changed |= line_changed;
386        if starts_block_scalar {
387            block_scalar_indent = Some(indent);
388        }
389        offset += line.len();
390    }
391
392    if changed {
393        String::from_utf8(compatible).ok()
394    } else {
395        None
396    }
397}
398
399fn mask_blank_lines_after_mapping_keys(source: &str, compatible: &mut [u8]) -> bool {
400    let bytes = source.as_bytes();
401    let mut line_starts = vec![0];
402    line_starts.extend(
403        bytes
404            .iter()
405            .enumerate()
406            .filter_map(|(index, byte)| (*byte == b'\n').then_some(index + 1)),
407    );
408    let mut changed = false;
409
410    for line_index in 0..line_starts.len().saturating_sub(2) {
411        let start = line_starts[line_index];
412        let end = line_starts.get(line_index + 1).copied().unwrap_or(bytes.len());
413        let content_end = line_content_end(bytes, start, end);
414        let content = &source[start..content_end];
415        let trimmed = content.trim();
416        if trimmed.starts_with('#') || !trimmed.ends_with(':') {
417            continue;
418        }
419
420        let mut next_line = line_index + 1;
421        while next_line < line_starts.len() {
422            let next_start = line_starts[next_line];
423            let next_end = line_starts.get(next_line + 1).copied().unwrap_or(bytes.len());
424            let next_content_end = line_content_end(bytes, next_start, next_end);
425            if !source[next_start..next_content_end].trim().is_empty() {
426                break;
427            }
428            next_line += 1;
429        }
430        if next_line == line_index + 1 || next_line >= line_starts.len() {
431            continue;
432        }
433
434        let key_indent = content.bytes().take_while(|byte| *byte == b' ').count();
435        let value_start = line_starts[next_line];
436        let value_indent = source[value_start..].bytes().take_while(|byte| *byte == b' ').count();
437        if value_indent <= key_indent {
438            continue;
439        }
440
441        // Keep the final blank line's line ending and turn the preceding separators into trailing
442        // spaces. This removes the backend-only blank-line ambiguity without changing byte spans.
443        let final_blank_start = line_starts[next_line - 1];
444        compatible[content_end..final_blank_start].fill(b' ');
445        changed = true;
446    }
447    changed
448}
449
450fn line_content_end(bytes: &[u8], start: usize, end: usize) -> usize {
451    let mut content_end = end;
452    if content_end > start && bytes[content_end - 1] == b'\n' {
453        content_end -= 1;
454    }
455    if content_end > start && bytes[content_end - 1] == b'\r' {
456        content_end -= 1;
457    }
458    content_end
459}
460
461fn mask_non_colliding_anchor_hyphens(source: &str, compatible: &mut [u8]) -> bool {
462    let bytes = source.as_bytes();
463    let mut occurrences = Vec::new();
464    let mut originals_by_normalized = BTreeMap::<Vec<u8>, BTreeSet<Vec<u8>>>::new();
465    let mut index = 0;
466
467    while index < bytes.len() {
468        if !matches!(bytes[index], b'&' | b'*') {
469            index += 1;
470            continue;
471        }
472        let line_start = bytes[..index]
473            .iter()
474            .rposition(|byte| *byte == b'\n')
475            .map_or(0, |position| position + 1);
476        let preceding = bytes[line_start..index]
477            .iter()
478            .rfind(|byte| !byte.is_ascii_whitespace())
479            .copied();
480        if preceding.is_some_and(|byte| !matches!(byte, b':' | b'-' | b'[' | b'{' | b',' | b'?')) {
481            index += 1;
482            continue;
483        }
484        let start = index + 1;
485        let mut end = start;
486        while bytes.get(end).is_some_and(|byte| !anchor_name_delimiter(*byte)) {
487            end += 1;
488        }
489        if start == end {
490            index += 1;
491            continue;
492        }
493
494        let original = bytes[start..end].to_vec();
495        let normalized = original
496            .iter()
497            .map(|byte| if *byte == b'-' { b'_' } else { *byte })
498            .collect::<Vec<_>>();
499        originals_by_normalized
500            .entry(normalized.clone())
501            .or_default()
502            .insert(original.clone());
503        occurrences.push((start, end, original, normalized));
504        index = end;
505    }
506
507    let mut changed = false;
508    for (start, end, original, normalized) in occurrences {
509        if !original.contains(&b'-')
510            || originals_by_normalized
511                .get(&normalized)
512                .is_none_or(|originals| originals.len() != 1)
513        {
514            continue;
515        }
516        for byte in &mut compatible[start..end] {
517            if *byte == b'-' {
518                *byte = b'_';
519                changed = true;
520            }
521        }
522    }
523    changed
524}
525
526const fn anchor_name_delimiter(byte: u8) -> bool {
527    byte.is_ascii_whitespace() || matches!(byte, b'[' | b']' | b'{' | b'}' | b',')
528}
529
530#[derive(Debug, Default)]
531struct ParserCompatibilityState {
532    flow_depth: u32,
533    quote: Option<u8>,
534    escaped: bool,
535}
536
537fn mask_block_plain_commas(
538    content: &str,
539    offset: usize,
540    compatible: &mut [u8],
541    state: &mut ParserCompatibilityState,
542) -> (bool, bool) {
543    let bytes = content.as_bytes();
544    let mut index = 0;
545    let mut first_token = true;
546    let mut plain_started = false;
547    let mut eligible_plain_value = false;
548    let mut interpolation_depth = 0u32;
549    let mut changed = false;
550
551    while index < bytes.len() {
552        let byte = bytes[index];
553
554        if let Some(delimiter) = state.quote {
555            if delimiter == b'"' && state.escaped {
556                state.escaped = false;
557            } else if delimiter == b'"' && byte == b'\\' {
558                state.escaped = true;
559            } else if byte == delimiter {
560                if delimiter == b'\'' && bytes.get(index + 1) == Some(&b'\'') {
561                    index += 1;
562                } else {
563                    state.quote = None;
564                }
565            }
566            index += 1;
567            continue;
568        }
569
570        if byte == b'#' && (index == 0 || bytes[index - 1].is_ascii_whitespace()) {
571            break;
572        }
573
574        if eligible_plain_value && !plain_started && matches!(byte, b'!' | b'&') {
575            index += 1;
576            while bytes.get(index).is_some_and(|byte| !byte.is_ascii_whitespace()) {
577                index += 1;
578            }
579            first_token = false;
580            continue;
581        }
582
583        if matches!(byte, b'\'' | b'"') && !plain_started {
584            state.quote = Some(byte);
585            first_token = false;
586            index += 1;
587            continue;
588        }
589
590        if state.flow_depth > 0 {
591            match byte {
592                b'[' | b'{' => state.flow_depth += 1,
593                b']' | b'}' => state.flow_depth -= 1,
594                _ => {}
595            }
596            index += 1;
597            continue;
598        }
599
600        let token_boundary = index == 0 || bytes[index - 1].is_ascii_whitespace();
601        if matches!(byte, b'[' | b'{') && (!plain_started || (eligible_plain_value && token_boundary)) {
602            state.flow_depth = 1;
603            first_token = false;
604            index += 1;
605            continue;
606        }
607
608        if byte.is_ascii_whitespace() {
609            index += 1;
610            continue;
611        }
612
613        if first_token && byte == b'-' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
614            eligible_plain_value = true;
615            plain_started = false;
616            first_token = false;
617            index += 1;
618            continue;
619        }
620
621        if byte == b':' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
622            eligible_plain_value = true;
623            plain_started = false;
624            first_token = false;
625            index += 1;
626            continue;
627        }
628
629        if eligible_plain_value && !plain_started && matches!(byte, b'|' | b'>') {
630            return (changed, true);
631        }
632
633        // yaml-edit currently treats the first dash in an unquoted `- --option` item as another
634        // block-sequence indicator. Masking one dash in the private, same-length parser input keeps
635        // the item scalar while scalar extraction still reads the authored source.
636        if eligible_plain_value && !plain_started && byte == b'-' && bytes.get(index + 1) == Some(&b'-') {
637            compatible[offset + index] = b'_';
638            changed = true;
639        }
640
641        if eligible_plain_value && plain_started && byte == b',' {
642            compatible[offset + index] = b'_';
643            changed = true;
644        }
645
646        // yaml-edit treats `}` as a plain-scalar terminator, while Compose permits
647        // unquoted `${NAME}` expressions. Mask only balanced interpolation closers
648        // in the private same-length parser input; source extraction restores the
649        // authored brace and therefore preserves exact diagnostics and rendering.
650        if eligible_plain_value && byte == b'$' && bytes.get(index + 1) == Some(&b'{') {
651            interpolation_depth = interpolation_depth.saturating_add(1);
652        } else if eligible_plain_value && plain_started && byte == b'}' && interpolation_depth > 0 {
653            interpolation_depth -= 1;
654            compatible[offset + index] = b'_';
655            changed = true;
656        }
657
658        plain_started = true;
659        first_token = false;
660        index += 1;
661    }
662
663    state.escaped = false;
664    (changed, false)
665}
666
667pub(crate) fn scalar_raw_from_source(source: &str, scalar: &Scalar) -> String {
668    let range = scalar.byte_range();
669    source
670        .get(range.start as usize..range.end as usize)
671        .map_or_else(|| scalar.value(), str::to_owned)
672}
673
674pub(crate) fn scalar_string_from_source(source: &str, scalar: &Scalar) -> String {
675    let authored = scalar_raw_from_source(source, scalar);
676    if authored == scalar.value() {
677        scalar.as_string()
678    } else {
679        // The compatibility overlay only rewrites complete, single-line block plain scalars.
680        authored
681    }
682}
683
684fn collect_value_scalars(source_id: SourceId, source: &str, node: YamlNode, values: &mut Vec<ValueScalar>) {
685    match node {
686        YamlNode::Scalar(scalar) => collect_scalar(source_id, source, &scalar, values),
687        YamlNode::Mapping(mapping) => {
688            for value in mapping.entries().filter_map(|entry| entry.value_node()) {
689                collect_value_scalars(source_id, source, value, values);
690            }
691        }
692        YamlNode::Sequence(sequence) => {
693            for value in sequence.values() {
694                collect_value_scalars(source_id, source, value, values);
695            }
696        }
697        YamlNode::TaggedNode(tagged) => {
698            if let Some(node) = tagged
699                .as_node()
700                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
701            {
702                collect_value_scalars(source_id, source, node, values);
703            }
704        }
705        YamlNode::Alias(_) => {}
706    }
707}
708
709fn collect_editable_value_scalars(
710    source_id: SourceId,
711    source: &str,
712    node: YamlNode,
713    values: &mut Vec<EditableValueScalar>,
714) {
715    match node {
716        YamlNode::Scalar(scalar) => {
717            values.push(EditableValueScalar {
718                raw: scalar_raw_from_source(source, &scalar),
719                span: position_span(source_id, scalar.byte_range()),
720            });
721        }
722        YamlNode::Mapping(mapping) => {
723            for value in mapping.entries().filter_map(|entry| entry.value_node()) {
724                collect_editable_value_scalars(source_id, source, value, values);
725            }
726        }
727        YamlNode::Sequence(sequence) => {
728            for value in sequence.values() {
729                collect_editable_value_scalars(source_id, source, value, values);
730            }
731        }
732        YamlNode::TaggedNode(tagged) => {
733            if let Some(node) = tagged
734                .as_node()
735                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
736            {
737                collect_editable_value_scalars(source_id, source, node, values);
738            }
739        }
740        YamlNode::Alias(_) => {}
741    }
742}
743
744fn extract_merge_value(
745    source_id: SourceId,
746    source: &str,
747    node: YamlNode,
748    registry: &AnchorRegistry,
749    aliases: &mut Vec<String>,
750) -> MergeSyntaxValue {
751    match node {
752        YamlNode::Scalar(scalar) => MergeSyntaxValue::Scalar(extract_merge_scalar(source_id, source, &scalar)),
753        YamlNode::Mapping(mapping) => extract_merge_mapping(source_id, source, &mapping, registry, aliases),
754        YamlNode::Sequence(sequence) => {
755            let span = position_span(source_id, sequence.byte_range());
756            let values = sequence
757                .values()
758                .map(|value| extract_merge_value(source_id, source, value, registry, aliases))
759                .collect();
760            MergeSyntaxValue::Sequence { values, span }
761        }
762        YamlNode::Alias(alias) => {
763            let name = alias.name();
764            let span = yaml_node_span(source_id, &YamlNode::Alias(alias.clone()));
765            if aliases.contains(&name) || aliases.len() >= 64 {
766                return MergeSyntaxValue::Alias {
767                    name: original_alias_name(source, span).unwrap_or(name),
768                    span,
769                };
770            }
771            if let Some(target) = registry.resolve(&name).and_then(|node| {
772                YamlNode::from_syntax(node.clone()).or_else(|| node.children().find_map(YamlNode::from_syntax))
773            }) {
774                aliases.push(name);
775                let value = extract_merge_value(source_id, source, target, registry, aliases);
776                let _ = aliases.pop();
777                value
778            } else {
779                MergeSyntaxValue::Alias {
780                    name: original_alias_name(source, span).unwrap_or(name),
781                    span,
782                }
783            }
784        }
785        YamlNode::TaggedNode(tagged) => {
786            let span = yaml_node_span(source_id, &YamlNode::TaggedNode(tagged.clone()));
787            let tag = tagged.tag().unwrap_or_default();
788            let mut value = tagged
789                .as_node()
790                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
791                .map_or_else(
792                    || MergeSyntaxValue::Empty(span),
793                    |value| extract_merge_value(source_id, source, value, registry, aliases),
794                );
795            if matches!(tag.as_str(), "!!timestamp" | "!!regex") {
796                if let MergeSyntaxValue::Scalar(scalar) = &mut value {
797                    scalar.strict_yaml_string = false;
798                }
799            }
800            MergeSyntaxValue::Tagged {
801                tag,
802                value: Box::new(value),
803                span,
804            }
805        }
806    }
807}
808
809fn original_alias_name(source: &str, span: SourceSpan) -> Option<String> {
810    source.get(span.range())?.strip_prefix('*').map(ToOwned::to_owned)
811}
812
813fn extract_merge_mapping(
814    source_id: SourceId,
815    source: &str,
816    mapping: &Mapping,
817    registry: &AnchorRegistry,
818    aliases: &mut Vec<String>,
819) -> MergeSyntaxValue {
820    let span = position_span(source_id, mapping.byte_range());
821    let direct = flatten_merge_fields(source_id, source, raw_merge_fields(source_id, source, mapping));
822    let mut entries = Vec::new();
823    let mut direct_keys = Vec::new();
824
825    for field in direct {
826        if field.key.value == "<<" {
827            continue;
828        }
829        direct_keys.push(field.key.value.clone());
830        let value = field.value.map_or_else(
831            || {
832                MergeSyntaxValue::Empty(SourceSpan::from_valid_offsets(
833                    source_id,
834                    field.key.span.end(),
835                    field.key.span.end(),
836                ))
837            },
838            |value| extract_merge_value(source_id, source, resolve_alias(value, registry), registry, aliases),
839        );
840        entries.push(MergeSyntaxEntry { key: field.key, value });
841    }
842
843    for (key, value) in mapping.merged(registry).iter() {
844        let Some(key) = key
845            .as_scalar()
846            .map(|scalar| extract_merge_scalar(source_id, source, scalar))
847        else {
848            continue;
849        };
850        if direct_keys.contains(&key.value) {
851            continue;
852        }
853        entries.push(MergeSyntaxEntry {
854            key,
855            value: extract_merge_value(source_id, source, value, registry, aliases),
856        });
857    }
858
859    MergeSyntaxValue::Mapping { entries, span }
860}
861
862#[derive(Debug)]
863struct RawMergeField {
864    key: MergeSyntaxScalar,
865    value: Option<YamlNode>,
866}
867
868fn raw_merge_fields(source_id: SourceId, source: &str, mapping: &Mapping) -> Vec<RawMergeField> {
869    mapping
870        .entries()
871        .filter_map(|entry| {
872            let key = entry.key_node()?.as_scalar().cloned()?;
873            Some(RawMergeField {
874                key: extract_merge_scalar(source_id, source, &key),
875                value: entry.value_node(),
876            })
877        })
878        .collect()
879}
880
881fn flatten_merge_fields(source_id: SourceId, source: &str, fields: Vec<RawMergeField>) -> Vec<RawMergeField> {
882    let Some(target_column) = fields
883        .first()
884        .map(|field| source_column(source, field.key.span.start()))
885    else {
886        return fields;
887    };
888    recover_merge_fields(source_id, source, fields, target_column)
889}
890
891fn recover_merge_fields(
892    source_id: SourceId,
893    source: &str,
894    fields: Vec<RawMergeField>,
895    target_column: usize,
896) -> Vec<RawMergeField> {
897    let mut flattened = Vec::new();
898    for mut field in fields {
899        let field_column = source_column(source, field.key.span.start());
900        let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
901        let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
902            !is_flow_mapping(source, mapping)
903                && mapping
904                    .entries()
905                    .find_map(|entry| entry.key_node()?.as_scalar().map(Scalar::byte_range))
906                    .is_some_and(|position| source_column(source, position.start as usize) <= field_column)
907        });
908        if continuation {
909            field.value = None;
910        }
911        if field_column == target_column {
912            flattened.push(field);
913        }
914        if let Some(mapping) = nested_mapping.filter(|mapping| !is_flow_mapping(source, mapping)) {
915            let nested = raw_merge_fields(source_id, source, &mapping);
916            flattened.extend(recover_merge_fields(source_id, source, nested, target_column));
917        }
918    }
919    flattened
920}
921
922fn is_flow_mapping(source: &str, mapping: &Mapping) -> bool {
923    let position = mapping.byte_range();
924    source
925        .get(position.start as usize..position.end as usize)
926        .is_some_and(|text| text.trim_start().starts_with('{'))
927}
928
929fn source_column(source: &str, offset: usize) -> usize {
930    let prefix = &source[..offset.min(source.len())];
931    let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
932    source[line_start..offset.min(source.len())].chars().count()
933}
934
935fn resolve_alias(node: YamlNode, registry: &AnchorRegistry) -> YamlNode {
936    let YamlNode::Alias(alias) = &node else {
937        return node;
938    };
939    registry
940        .resolve(&alias.name())
941        .and_then(|target| {
942            YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
943        })
944        .unwrap_or(node)
945}
946
947fn extract_merge_scalar(source_id: SourceId, source: &str, scalar: &Scalar) -> MergeSyntaxScalar {
948    let scalar_type = ScalarValue::from_scalar(scalar).scalar_type();
949    let kind = match scalar_type {
950        ScalarType::Boolean => MergeScalarKind::Boolean,
951        ScalarType::Integer | ScalarType::Float => MergeScalarKind::Number,
952        ScalarType::Null => MergeScalarKind::Null,
953        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MergeScalarKind::String,
954    };
955    MergeSyntaxScalar {
956        raw: scalar_raw_from_source(source, scalar),
957        value: scalar_string_from_source(source, scalar),
958        kind,
959        plain: ScalarValue::from_scalar(scalar).style() == ScalarStyle::Plain
960            && !scalar_uses_block_style(source, scalar),
961        strict_yaml_string: scalar_type == ScalarType::String,
962        span: position_span(source_id, scalar.byte_range()),
963    }
964}
965
966fn scalar_uses_block_style(source: &str, scalar: &Scalar) -> bool {
967    let start = scalar.byte_range().start as usize;
968    source[start..].trim_start().starts_with(['|', '>'])
969        || source[..start]
970            .lines()
971            .rev()
972            .find(|line| !line.trim().is_empty())
973            .is_some_and(|header| header.contains(": |") || header.contains(": >"))
974}
975
976fn yaml_node_span(source_id: SourceId, node: &YamlNode) -> SourceSpan {
977    let Some(syntax) = node.as_node() else {
978        return SourceSpan::from_valid_offsets(source_id, 0, 0);
979    };
980    let range = syntax.text_range();
981    SourceSpan::from_valid_offsets(
982        source_id,
983        u32::from(range.start()) as usize,
984        u32::from(range.end()) as usize,
985    )
986}
987
988fn position_span(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
989    SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
990}
991
992fn collect_scalar(source_id: SourceId, source: &str, scalar: &Scalar, values: &mut Vec<ValueScalar>) {
993    let raw = scalar_raw_from_source(source, scalar);
994    let eligible_style = !raw.starts_with('\'') && !raw.starts_with('|') && !raw.starts_with('>');
995    if !eligible_style || !raw.contains('$') {
996        return;
997    }
998    let position = scalar.byte_range();
999    values.push(ValueScalar {
1000        value: scalar_string_from_source(source, scalar),
1001        span: SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize),
1002    });
1003}
1004
1005#[cfg(test)]
1006mod tests {
1007    use super::{MergeSyntaxScalar, MergeSyntaxValue, SyntaxDocument, parser_compatible_source, unparsed_input_offset};
1008    use crate::source::SourceId;
1009    use yaml_edit::YamlFile;
1010
1011    fn assert_send_and_sync<T: Send + Sync>() {}
1012
1013    #[test]
1014    fn syntax_documents_are_send_and_sync() {
1015        assert_send_and_sync::<SyntaxDocument>();
1016    }
1017
1018    #[test]
1019    fn parsing_never_reads_the_process_environment() -> Result<(), Box<dyn std::error::Error>> {
1020        let source = "services:\n  app:\n    image: ${COMPOSE_LENS_SECRET}\n";
1021        let parsed = SyntaxDocument::parse(SourceId::new(1), source)?;
1022
1023        assert_eq!(parsed.document().render_preserved(), source);
1024        assert!(parsed.is_valid());
1025        Ok(())
1026    }
1027
1028    #[test]
1029    fn complete_root_guard_detects_the_private_backends_raw_comma_omission() {
1030        let source = "services:\n  app:\n    volumes:\n      - ./data:/data:Z,ro\n  later:\n    image: later\n";
1031        let backend = YamlFile::parse(source);
1032
1033        assert!(backend.positioned_errors().is_empty());
1034        assert!(unparsed_input_offset(&backend, source).is_some());
1035    }
1036
1037    #[test]
1038    fn anchor_compatibility_does_not_merge_colliding_names() {
1039        let source = "first: &shared-name one\nsecond: &shared_name two\n";
1040
1041        assert_eq!(parser_compatible_source(source), None);
1042    }
1043
1044    #[test]
1045    fn anchor_compatibility_ignores_scalar_and_comment_content() {
1046        let source = "quoted: \"*not-an-alias\"\nplain: echo &not-an-anchor\n# *also-not-an-alias\n";
1047
1048        assert_eq!(parser_compatible_source(source), None);
1049    }
1050
1051    #[test]
1052    fn merge_scalars_retain_strict_yaml_string_identity() -> Result<(), Box<dyn std::error::Error>> {
1053        let parsed = SyntaxDocument::parse(
1054            SourceId::new(2),
1055            "plain: gpu\ntimestamp: !!timestamp 2023-12-25\nregex: !!regex 'gpu.*'\nquoted-timestamp: \"2023-12-25\"\nquoted-regex: \"gpu.*\"\n",
1056        )?;
1057        let root = parsed.document().merge_root().ok_or("merge root")?;
1058        let scalar = |name| -> Option<&MergeSyntaxScalar> {
1059            let MergeSyntaxValue::Mapping { entries, .. } = &root else {
1060                return None;
1061            };
1062            let value = &entries.iter().find(|entry| entry.key.value == name)?.value;
1063            match value {
1064                MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1065                MergeSyntaxValue::Tagged { value, .. } => match value.as_ref() {
1066                    MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1067                    _ => None,
1068                },
1069                _ => None,
1070            }
1071        };
1072        assert!(scalar("plain").is_some_and(|value| value.strict_yaml_string));
1073        for name in ["timestamp", "regex"] {
1074            assert!(
1075                scalar(name).is_some_and(|value| !value.strict_yaml_string),
1076                "{name}: {:?}",
1077                scalar(name)
1078            );
1079        }
1080        for name in ["quoted-timestamp", "quoted-regex"] {
1081            assert!(scalar(name).is_some_and(|value| value.strict_yaml_string));
1082        }
1083        Ok(())
1084    }
1085}