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 changed = false;
549
550    while index < bytes.len() {
551        let byte = bytes[index];
552
553        if let Some(delimiter) = state.quote {
554            if delimiter == b'"' && state.escaped {
555                state.escaped = false;
556            } else if delimiter == b'"' && byte == b'\\' {
557                state.escaped = true;
558            } else if byte == delimiter {
559                if delimiter == b'\'' && bytes.get(index + 1) == Some(&b'\'') {
560                    index += 1;
561                } else {
562                    state.quote = None;
563                }
564            }
565            index += 1;
566            continue;
567        }
568
569        if byte == b'#' && (index == 0 || bytes[index - 1].is_ascii_whitespace()) {
570            break;
571        }
572
573        if eligible_plain_value && !plain_started && matches!(byte, b'!' | b'&') {
574            index += 1;
575            while bytes.get(index).is_some_and(|byte| !byte.is_ascii_whitespace()) {
576                index += 1;
577            }
578            first_token = false;
579            continue;
580        }
581
582        if matches!(byte, b'\'' | b'"') && !plain_started {
583            state.quote = Some(byte);
584            first_token = false;
585            index += 1;
586            continue;
587        }
588
589        if state.flow_depth > 0 {
590            match byte {
591                b'[' | b'{' => state.flow_depth += 1,
592                b']' | b'}' => state.flow_depth -= 1,
593                _ => {}
594            }
595            index += 1;
596            continue;
597        }
598
599        let token_boundary = index == 0 || bytes[index - 1].is_ascii_whitespace();
600        if matches!(byte, b'[' | b'{') && (!plain_started || (eligible_plain_value && token_boundary)) {
601            state.flow_depth = 1;
602            first_token = false;
603            index += 1;
604            continue;
605        }
606
607        if byte.is_ascii_whitespace() {
608            index += 1;
609            continue;
610        }
611
612        if first_token && byte == b'-' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
613            eligible_plain_value = true;
614            plain_started = false;
615            first_token = false;
616            index += 1;
617            continue;
618        }
619
620        if byte == b':' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
621            eligible_plain_value = true;
622            plain_started = false;
623            first_token = false;
624            index += 1;
625            continue;
626        }
627
628        if eligible_plain_value && !plain_started && matches!(byte, b'|' | b'>') {
629            return (changed, true);
630        }
631
632        // yaml-edit currently treats the first dash in an unquoted `- --option` item as another
633        // block-sequence indicator. Masking one dash in the private, same-length parser input keeps
634        // the item scalar while scalar extraction still reads the authored source.
635        if eligible_plain_value && !plain_started && byte == b'-' && bytes.get(index + 1) == Some(&b'-') {
636            compatible[offset + index] = b'_';
637            changed = true;
638        }
639
640        if eligible_plain_value && plain_started && byte == b',' {
641            compatible[offset + index] = b'_';
642            changed = true;
643        }
644
645        plain_started = true;
646        first_token = false;
647        index += 1;
648    }
649
650    state.escaped = false;
651    (changed, false)
652}
653
654pub(crate) fn scalar_raw_from_source(source: &str, scalar: &Scalar) -> String {
655    let range = scalar.byte_range();
656    source
657        .get(range.start as usize..range.end as usize)
658        .map_or_else(|| scalar.value(), str::to_owned)
659}
660
661pub(crate) fn scalar_string_from_source(source: &str, scalar: &Scalar) -> String {
662    let authored = scalar_raw_from_source(source, scalar);
663    if authored == scalar.value() {
664        scalar.as_string()
665    } else {
666        // The compatibility overlay only rewrites complete, single-line block plain scalars.
667        authored
668    }
669}
670
671fn collect_value_scalars(source_id: SourceId, source: &str, node: YamlNode, values: &mut Vec<ValueScalar>) {
672    match node {
673        YamlNode::Scalar(scalar) => collect_scalar(source_id, source, &scalar, values),
674        YamlNode::Mapping(mapping) => {
675            for value in mapping.entries().filter_map(|entry| entry.value_node()) {
676                collect_value_scalars(source_id, source, value, values);
677            }
678        }
679        YamlNode::Sequence(sequence) => {
680            for value in sequence.values() {
681                collect_value_scalars(source_id, source, value, values);
682            }
683        }
684        YamlNode::TaggedNode(tagged) => {
685            if let Some(node) = tagged
686                .as_node()
687                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
688            {
689                collect_value_scalars(source_id, source, node, values);
690            }
691        }
692        YamlNode::Alias(_) => {}
693    }
694}
695
696fn collect_editable_value_scalars(
697    source_id: SourceId,
698    source: &str,
699    node: YamlNode,
700    values: &mut Vec<EditableValueScalar>,
701) {
702    match node {
703        YamlNode::Scalar(scalar) => {
704            values.push(EditableValueScalar {
705                raw: scalar_raw_from_source(source, &scalar),
706                span: position_span(source_id, scalar.byte_range()),
707            });
708        }
709        YamlNode::Mapping(mapping) => {
710            for value in mapping.entries().filter_map(|entry| entry.value_node()) {
711                collect_editable_value_scalars(source_id, source, value, values);
712            }
713        }
714        YamlNode::Sequence(sequence) => {
715            for value in sequence.values() {
716                collect_editable_value_scalars(source_id, source, value, values);
717            }
718        }
719        YamlNode::TaggedNode(tagged) => {
720            if let Some(node) = tagged
721                .as_node()
722                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
723            {
724                collect_editable_value_scalars(source_id, source, node, values);
725            }
726        }
727        YamlNode::Alias(_) => {}
728    }
729}
730
731fn extract_merge_value(
732    source_id: SourceId,
733    source: &str,
734    node: YamlNode,
735    registry: &AnchorRegistry,
736    aliases: &mut Vec<String>,
737) -> MergeSyntaxValue {
738    match node {
739        YamlNode::Scalar(scalar) => MergeSyntaxValue::Scalar(extract_merge_scalar(source_id, source, &scalar)),
740        YamlNode::Mapping(mapping) => extract_merge_mapping(source_id, source, &mapping, registry, aliases),
741        YamlNode::Sequence(sequence) => {
742            let span = position_span(source_id, sequence.byte_range());
743            let values = sequence
744                .values()
745                .map(|value| extract_merge_value(source_id, source, value, registry, aliases))
746                .collect();
747            MergeSyntaxValue::Sequence { values, span }
748        }
749        YamlNode::Alias(alias) => {
750            let name = alias.name();
751            let span = yaml_node_span(source_id, &YamlNode::Alias(alias.clone()));
752            if aliases.contains(&name) || aliases.len() >= 64 {
753                return MergeSyntaxValue::Alias {
754                    name: original_alias_name(source, span).unwrap_or(name),
755                    span,
756                };
757            }
758            if let Some(target) = registry.resolve(&name).and_then(|node| {
759                YamlNode::from_syntax(node.clone()).or_else(|| node.children().find_map(YamlNode::from_syntax))
760            }) {
761                aliases.push(name);
762                let value = extract_merge_value(source_id, source, target, registry, aliases);
763                let _ = aliases.pop();
764                value
765            } else {
766                MergeSyntaxValue::Alias {
767                    name: original_alias_name(source, span).unwrap_or(name),
768                    span,
769                }
770            }
771        }
772        YamlNode::TaggedNode(tagged) => {
773            let span = yaml_node_span(source_id, &YamlNode::TaggedNode(tagged.clone()));
774            let tag = tagged.tag().unwrap_or_default();
775            let mut value = tagged
776                .as_node()
777                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
778                .map_or_else(
779                    || MergeSyntaxValue::Empty(span),
780                    |value| extract_merge_value(source_id, source, value, registry, aliases),
781                );
782            if matches!(tag.as_str(), "!!timestamp" | "!!regex") {
783                if let MergeSyntaxValue::Scalar(scalar) = &mut value {
784                    scalar.strict_yaml_string = false;
785                }
786            }
787            MergeSyntaxValue::Tagged {
788                tag,
789                value: Box::new(value),
790                span,
791            }
792        }
793    }
794}
795
796fn original_alias_name(source: &str, span: SourceSpan) -> Option<String> {
797    source.get(span.range())?.strip_prefix('*').map(ToOwned::to_owned)
798}
799
800fn extract_merge_mapping(
801    source_id: SourceId,
802    source: &str,
803    mapping: &Mapping,
804    registry: &AnchorRegistry,
805    aliases: &mut Vec<String>,
806) -> MergeSyntaxValue {
807    let span = position_span(source_id, mapping.byte_range());
808    let direct = flatten_merge_fields(source_id, source, raw_merge_fields(source_id, source, mapping));
809    let mut entries = Vec::new();
810    let mut direct_keys = Vec::new();
811
812    for field in direct {
813        if field.key.value == "<<" {
814            continue;
815        }
816        direct_keys.push(field.key.value.clone());
817        let value = field.value.map_or_else(
818            || {
819                MergeSyntaxValue::Empty(SourceSpan::from_valid_offsets(
820                    source_id,
821                    field.key.span.end(),
822                    field.key.span.end(),
823                ))
824            },
825            |value| extract_merge_value(source_id, source, resolve_alias(value, registry), registry, aliases),
826        );
827        entries.push(MergeSyntaxEntry { key: field.key, value });
828    }
829
830    for (key, value) in mapping.merged(registry).iter() {
831        let Some(key) = key
832            .as_scalar()
833            .map(|scalar| extract_merge_scalar(source_id, source, scalar))
834        else {
835            continue;
836        };
837        if direct_keys.contains(&key.value) {
838            continue;
839        }
840        entries.push(MergeSyntaxEntry {
841            key,
842            value: extract_merge_value(source_id, source, value, registry, aliases),
843        });
844    }
845
846    MergeSyntaxValue::Mapping { entries, span }
847}
848
849#[derive(Debug)]
850struct RawMergeField {
851    key: MergeSyntaxScalar,
852    value: Option<YamlNode>,
853}
854
855fn raw_merge_fields(source_id: SourceId, source: &str, mapping: &Mapping) -> Vec<RawMergeField> {
856    mapping
857        .entries()
858        .filter_map(|entry| {
859            let key = entry.key_node()?.as_scalar().cloned()?;
860            Some(RawMergeField {
861                key: extract_merge_scalar(source_id, source, &key),
862                value: entry.value_node(),
863            })
864        })
865        .collect()
866}
867
868fn flatten_merge_fields(source_id: SourceId, source: &str, fields: Vec<RawMergeField>) -> Vec<RawMergeField> {
869    let Some(target_column) = fields
870        .first()
871        .map(|field| source_column(source, field.key.span.start()))
872    else {
873        return fields;
874    };
875    recover_merge_fields(source_id, source, fields, target_column)
876}
877
878fn recover_merge_fields(
879    source_id: SourceId,
880    source: &str,
881    fields: Vec<RawMergeField>,
882    target_column: usize,
883) -> Vec<RawMergeField> {
884    let mut flattened = Vec::new();
885    for mut field in fields {
886        let field_column = source_column(source, field.key.span.start());
887        let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
888        let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
889            !is_flow_mapping(source, mapping)
890                && mapping
891                    .entries()
892                    .find_map(|entry| entry.key_node()?.as_scalar().map(Scalar::byte_range))
893                    .is_some_and(|position| source_column(source, position.start as usize) <= field_column)
894        });
895        if continuation {
896            field.value = None;
897        }
898        if field_column == target_column {
899            flattened.push(field);
900        }
901        if let Some(mapping) = nested_mapping.filter(|mapping| !is_flow_mapping(source, mapping)) {
902            let nested = raw_merge_fields(source_id, source, &mapping);
903            flattened.extend(recover_merge_fields(source_id, source, nested, target_column));
904        }
905    }
906    flattened
907}
908
909fn is_flow_mapping(source: &str, mapping: &Mapping) -> bool {
910    let position = mapping.byte_range();
911    source
912        .get(position.start as usize..position.end as usize)
913        .is_some_and(|text| text.trim_start().starts_with('{'))
914}
915
916fn source_column(source: &str, offset: usize) -> usize {
917    let prefix = &source[..offset.min(source.len())];
918    let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
919    source[line_start..offset.min(source.len())].chars().count()
920}
921
922fn resolve_alias(node: YamlNode, registry: &AnchorRegistry) -> YamlNode {
923    let YamlNode::Alias(alias) = &node else {
924        return node;
925    };
926    registry
927        .resolve(&alias.name())
928        .and_then(|target| {
929            YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
930        })
931        .unwrap_or(node)
932}
933
934fn extract_merge_scalar(source_id: SourceId, source: &str, scalar: &Scalar) -> MergeSyntaxScalar {
935    let scalar_type = ScalarValue::from_scalar(scalar).scalar_type();
936    let kind = match scalar_type {
937        ScalarType::Boolean => MergeScalarKind::Boolean,
938        ScalarType::Integer | ScalarType::Float => MergeScalarKind::Number,
939        ScalarType::Null => MergeScalarKind::Null,
940        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MergeScalarKind::String,
941    };
942    MergeSyntaxScalar {
943        raw: scalar_raw_from_source(source, scalar),
944        value: scalar_string_from_source(source, scalar),
945        kind,
946        plain: ScalarValue::from_scalar(scalar).style() == ScalarStyle::Plain
947            && !scalar_uses_block_style(source, scalar),
948        strict_yaml_string: scalar_type == ScalarType::String,
949        span: position_span(source_id, scalar.byte_range()),
950    }
951}
952
953fn scalar_uses_block_style(source: &str, scalar: &Scalar) -> bool {
954    let start = scalar.byte_range().start as usize;
955    source[start..].trim_start().starts_with(['|', '>'])
956        || source[..start]
957            .lines()
958            .rev()
959            .find(|line| !line.trim().is_empty())
960            .is_some_and(|header| header.contains(": |") || header.contains(": >"))
961}
962
963fn yaml_node_span(source_id: SourceId, node: &YamlNode) -> SourceSpan {
964    let Some(syntax) = node.as_node() else {
965        return SourceSpan::from_valid_offsets(source_id, 0, 0);
966    };
967    let range = syntax.text_range();
968    SourceSpan::from_valid_offsets(
969        source_id,
970        u32::from(range.start()) as usize,
971        u32::from(range.end()) as usize,
972    )
973}
974
975fn position_span(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
976    SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
977}
978
979fn collect_scalar(source_id: SourceId, source: &str, scalar: &Scalar, values: &mut Vec<ValueScalar>) {
980    let raw = scalar_raw_from_source(source, scalar);
981    let eligible_style = !raw.starts_with('\'') && !raw.starts_with('|') && !raw.starts_with('>');
982    if !eligible_style || !raw.contains('$') {
983        return;
984    }
985    let position = scalar.byte_range();
986    values.push(ValueScalar {
987        value: scalar_string_from_source(source, scalar),
988        span: SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize),
989    });
990}
991
992#[cfg(test)]
993mod tests {
994    use super::{MergeSyntaxScalar, MergeSyntaxValue, SyntaxDocument, parser_compatible_source, unparsed_input_offset};
995    use crate::source::SourceId;
996    use yaml_edit::YamlFile;
997
998    fn assert_send_and_sync<T: Send + Sync>() {}
999
1000    #[test]
1001    fn syntax_documents_are_send_and_sync() {
1002        assert_send_and_sync::<SyntaxDocument>();
1003    }
1004
1005    #[test]
1006    fn parsing_never_reads_the_process_environment() -> Result<(), Box<dyn std::error::Error>> {
1007        let source = "services:\n  app:\n    image: ${COMPOSE_LENS_SECRET}\n";
1008        let parsed = SyntaxDocument::parse(SourceId::new(1), source)?;
1009
1010        assert_eq!(parsed.document().render_preserved(), source);
1011        assert!(parsed.is_valid());
1012        Ok(())
1013    }
1014
1015    #[test]
1016    fn complete_root_guard_detects_the_private_backends_raw_comma_omission() {
1017        let source = "services:\n  app:\n    volumes:\n      - ./data:/data:Z,ro\n  later:\n    image: later\n";
1018        let backend = YamlFile::parse(source);
1019
1020        assert!(backend.positioned_errors().is_empty());
1021        assert!(unparsed_input_offset(&backend, source).is_some());
1022    }
1023
1024    #[test]
1025    fn anchor_compatibility_does_not_merge_colliding_names() {
1026        let source = "first: &shared-name one\nsecond: &shared_name two\n";
1027
1028        assert_eq!(parser_compatible_source(source), None);
1029    }
1030
1031    #[test]
1032    fn anchor_compatibility_ignores_scalar_and_comment_content() {
1033        let source = "quoted: \"*not-an-alias\"\nplain: echo &not-an-anchor\n# *also-not-an-alias\n";
1034
1035        assert_eq!(parser_compatible_source(source), None);
1036    }
1037
1038    #[test]
1039    fn merge_scalars_retain_strict_yaml_string_identity() -> Result<(), Box<dyn std::error::Error>> {
1040        let parsed = SyntaxDocument::parse(
1041            SourceId::new(2),
1042            "plain: gpu\ntimestamp: !!timestamp 2023-12-25\nregex: !!regex 'gpu.*'\nquoted-timestamp: \"2023-12-25\"\nquoted-regex: \"gpu.*\"\n",
1043        )?;
1044        let root = parsed.document().merge_root().ok_or("merge root")?;
1045        let scalar = |name| -> Option<&MergeSyntaxScalar> {
1046            let MergeSyntaxValue::Mapping { entries, .. } = &root else {
1047                return None;
1048            };
1049            let value = &entries.iter().find(|entry| entry.key.value == name)?.value;
1050            match value {
1051                MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1052                MergeSyntaxValue::Tagged { value, .. } => match value.as_ref() {
1053                    MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1054                    _ => None,
1055                },
1056                _ => None,
1057            }
1058        };
1059        assert!(scalar("plain").is_some_and(|value| value.strict_yaml_string));
1060        for name in ["timestamp", "regex"] {
1061            assert!(
1062                scalar(name).is_some_and(|value| !value.strict_yaml_string),
1063                "{name}: {:?}",
1064                scalar(name)
1065            );
1066        }
1067        for name in ["quoted-timestamp", "quoted-regex"] {
1068            assert!(scalar(name).is_some_and(|value| value.strict_yaml_string));
1069        }
1070        Ok(())
1071    }
1072}