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, Parse, ParseErrorKind, Scalar, ScalarStyle, ScalarType, ScalarValue, YamlFile,
11    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/// Authored YAML scalar presentation, independently of its semantic category.
30#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
31#[non_exhaustive]
32pub enum ScalarPresentation {
33    /// An unquoted scalar.
34    Plain,
35    /// A single-quoted scalar.
36    SingleQuoted,
37    /// A double-quoted scalar.
38    DoubleQuoted,
39    /// A literal block scalar (`|`).
40    Literal,
41    /// A folded block scalar (`>`).
42    Folded,
43}
44
45/// A loss-aware YAML document and its original source text.
46///
47/// The underlying concrete syntax tree is intentionally private so `ComposeLens` can maintain a
48/// stable API independently of its parser dependency. Preservation rendering emits the original
49/// source without normalization, interpolation, or environment access.
50#[derive(Debug, Clone, PartialEq, Eq)]
51pub struct SyntaxDocument {
52    source_id: SourceId,
53    source: Arc<str>,
54    parse: Parse<YamlFile>,
55}
56
57impl SyntaxDocument {
58    /// Parses YAML into a loss-aware document and structured diagnostics.
59    ///
60    /// Recoverable YAML errors are returned in [`SyntaxParse::diagnostics`] while the syntax
61    /// document remains available. Only sources too large for the concrete syntax tree produce a
62    /// fatal error.
63    ///
64    /// # Errors
65    ///
66    /// Returns [`SyntaxParseError`] when the source exceeds the concrete syntax tree's byte-offset
67    /// capacity. Malformed YAML is recoverable and is reported through structured diagnostics.
68    pub fn parse(source_id: SourceId, source: impl Into<Arc<str>>) -> Result<SyntaxParse, SyntaxParseError> {
69        let source = source.into();
70        if u32::try_from(source.len()).is_err() {
71            return Err(SyntaxParseError {
72                source_id,
73                byte_len: source.len(),
74            });
75        }
76
77        let (parser_source, block_scalars) = parser_compatible_source(&source);
78        let parse = YamlFile::parse(parser_source.as_deref().unwrap_or(&source));
79        let mut diagnostics: Vec<_> = parse
80            .positioned_errors()
81            .iter()
82            .map(|error| syntax_diagnostic(source_id, source.len(), error))
83            .collect();
84        for block in &block_scalars {
85            if !block.valid {
86                diagnostics.push(block_scalar_diagnostic(
87                    source_id,
88                    source.len(),
89                    block.start,
90                    YAML_SYNTAX_ERROR,
91                ));
92            } else if !block_scalar_is_complete(&parse, &source, block) {
93                diagnostics.push(block_scalar_diagnostic(
94                    source_id,
95                    source.len(),
96                    block.start,
97                    YAML_UNPARSED_INPUT,
98                ));
99            }
100        }
101        if diagnostics.is_empty()
102            && let Some(document_end) = unparsed_input_offset(&parse, &source)
103        {
104            diagnostics.push(unparsed_input_diagnostic(source_id, source.len(), document_end));
105        }
106
107        let document = Self {
108            source_id,
109            source,
110            parse,
111        };
112        for scalar in document.editable_value_scalars() {
113            if let Some(offset) = anchored_mapping_in_plain_scalar(&scalar.raw) {
114                diagnostics.push(unparsed_input_diagnostic(
115                    source_id,
116                    document.source.len(),
117                    scalar.span.start() + offset,
118                ));
119            }
120        }
121        Ok(SyntaxParse { document, diagnostics })
122    }
123
124    /// Returns the source identifier supplied by the caller.
125    #[must_use]
126    pub const fn source_id(&self) -> SourceId {
127        self.source_id
128    }
129
130    /// Returns the original source text.
131    #[must_use]
132    pub fn source_text(&self) -> &str {
133        &self.source
134    }
135
136    /// Returns the span covering the complete source text.
137    #[must_use]
138    pub fn source_span(&self) -> SourceSpan {
139        SourceSpan::from_valid_offsets(self.source_id, 0, self.source.len())
140    }
141
142    /// Returns the source text covered by a span from this document.
143    #[must_use]
144    pub fn text(&self, span: SourceSpan) -> Option<&str> {
145        if span.source_id() != self.source_id || span.end() > self.source.len() {
146            return None;
147        }
148
149        self.source.get(span.range())
150    }
151
152    /// Converts a byte offset in this document to a one-based line and column.
153    #[must_use]
154    pub fn line_column(&self, byte_offset: usize) -> Option<LineColumn> {
155        line_column(&self.source, byte_offset)
156    }
157
158    /// Returns the number of YAML documents in the stream.
159    #[must_use]
160    pub fn document_count(&self) -> usize {
161        self.parse.tree().documents().count()
162    }
163
164    /// Returns the number of comment tokens retained by the concrete syntax tree.
165    #[must_use]
166    pub fn comment_count(&self) -> usize {
167        self.parse.tree().comments().count()
168    }
169
170    /// Returns the original source as preservation-oriented output.
171    #[must_use]
172    pub fn render_preserved(&self) -> String {
173        self.source.to_string()
174    }
175
176    pub(crate) fn yaml_file(&self) -> YamlFile {
177        self.parse.tree()
178    }
179
180    pub(crate) fn interpolatable_value_scalars(&self) -> Vec<ValueScalar> {
181        let mut values = Vec::new();
182        if let Some(document) = self.parse.tree().document() {
183            if let Some(mapping) = document.as_mapping() {
184                collect_value_scalars(self.source_id, &self.source, YamlNode::Mapping(mapping), &mut values);
185            } else if let Some(sequence) = document.as_sequence() {
186                collect_value_scalars(self.source_id, &self.source, YamlNode::Sequence(sequence), &mut values);
187            } else if let Some(scalar) = document.as_scalar() {
188                collect_value_scalars(self.source_id, &self.source, YamlNode::Scalar(scalar), &mut values);
189            }
190        }
191        values
192    }
193
194    pub(crate) fn editable_value_scalars(&self) -> Vec<EditableValueScalar> {
195        let mut values = Vec::new();
196        if let Some(document) = self.parse.tree().document() {
197            if let Some(mapping) = document.as_mapping() {
198                collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Mapping(mapping), &mut values);
199            } else if let Some(sequence) = document.as_sequence() {
200                collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Sequence(sequence), &mut values);
201            } else if let Some(scalar) = document.as_scalar() {
202                collect_editable_value_scalars(self.source_id, &self.source, YamlNode::Scalar(scalar), &mut values);
203            }
204        }
205        values
206    }
207
208    pub(crate) fn merge_root(&self) -> Option<MergeSyntaxValue> {
209        let document = self.parse.tree().document()?;
210        let root = if let Some(mapping) = document.as_mapping() {
211            YamlNode::Mapping(mapping)
212        } else if let Some(sequence) = document.as_sequence() {
213            YamlNode::Sequence(sequence)
214        } else {
215            YamlNode::Scalar(document.as_scalar()?)
216        };
217        let registry = AnchorRegistry::from_document(&document);
218        Some(extract_merge_value(
219            self.source_id,
220            &self.source,
221            root,
222            &registry,
223            &mut Vec::new(),
224        ))
225    }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
229pub(crate) struct ValueScalar {
230    pub(crate) value: String,
231    pub(crate) span: SourceSpan,
232}
233
234#[derive(Debug, Clone, PartialEq, Eq)]
235pub(crate) struct EditableValueScalar {
236    pub(crate) raw: String,
237    pub(crate) span: SourceSpan,
238}
239
240#[derive(Debug, Clone, Copy, PartialEq, Eq)]
241pub(crate) enum MergeScalarKind {
242    String,
243    Boolean,
244    Number,
245    Null,
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
249pub(crate) struct MergeSyntaxScalar {
250    pub(crate) raw: String,
251    pub(crate) value: String,
252    pub(crate) kind: MergeScalarKind,
253    pub(crate) plain: bool,
254    pub(crate) presentation: ScalarPresentation,
255    pub(crate) strict_yaml_string: bool,
256    pub(crate) span: SourceSpan,
257}
258
259#[derive(Debug, Clone, PartialEq, Eq)]
260pub(crate) struct MergeSyntaxEntry {
261    pub(crate) key: MergeSyntaxScalar,
262    pub(crate) value: MergeSyntaxValue,
263}
264
265#[derive(Debug, Clone, PartialEq, Eq)]
266pub(crate) enum MergeSyntaxValue {
267    Empty(SourceSpan),
268    Scalar(MergeSyntaxScalar),
269    Mapping {
270        entries: Vec<MergeSyntaxEntry>,
271        span: SourceSpan,
272    },
273    Sequence {
274        values: Vec<MergeSyntaxValue>,
275        span: SourceSpan,
276    },
277    Alias {
278        name: String,
279        span: SourceSpan,
280    },
281    Tagged {
282        tag: String,
283        value: Box<MergeSyntaxValue>,
284        span: SourceSpan,
285    },
286}
287
288/// The recoverable result of parsing one YAML source.
289#[derive(Debug, Clone, PartialEq, Eq)]
290pub struct SyntaxParse {
291    document: SyntaxDocument,
292    diagnostics: Vec<Diagnostic>,
293}
294
295impl SyntaxParse {
296    /// Returns the loss-aware syntax document.
297    #[must_use]
298    pub const fn document(&self) -> &SyntaxDocument {
299        &self.document
300    }
301
302    /// Returns all syntax diagnostics in source order.
303    #[must_use]
304    pub fn diagnostics(&self) -> &[Diagnostic] {
305        &self.diagnostics
306    }
307
308    /// Reports whether parsing produced no error diagnostics.
309    #[must_use]
310    pub fn is_valid(&self) -> bool {
311        !self
312            .diagnostics
313            .iter()
314            .any(|diagnostic| diagnostic.severity() == Severity::Error)
315    }
316
317    /// Separates the document and diagnostics.
318    #[must_use]
319    pub fn into_parts(self) -> (SyntaxDocument, Vec<Diagnostic>) {
320        (self.document, self.diagnostics)
321    }
322}
323
324/// A source that exceeds the concrete syntax tree's byte-offset capacity.
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326pub struct SyntaxParseError {
327    source_id: SourceId,
328    byte_len: usize,
329}
330
331impl SyntaxParseError {
332    /// Returns the source identifier supplied to the parser.
333    #[must_use]
334    pub const fn source_id(self) -> SourceId {
335        self.source_id
336    }
337
338    /// Returns the rejected source length in bytes.
339    #[must_use]
340    pub const fn byte_len(self) -> usize {
341        self.byte_len
342    }
343}
344
345impl fmt::Display for SyntaxParseError {
346    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
347        write!(
348            formatter,
349            "{} contains {} bytes, exceeding the YAML syntax tree limit",
350            self.source_id, self.byte_len
351        )
352    }
353}
354
355impl Error for SyntaxParseError {}
356
357fn syntax_diagnostic(source_id: SourceId, source_len: usize, error: &yaml_edit::PositionedParseError) -> Diagnostic {
358    let (code, message) = match error.kind {
359        ParseErrorKind::UnclosedFlowSequence => (YAML_UNCLOSED_FLOW_SEQUENCE, "flow sequence is missing a closing `]`"),
360        ParseErrorKind::UnclosedFlowMapping => (YAML_UNCLOSED_FLOW_MAPPING, "flow mapping is missing a closing `}`"),
361        ParseErrorKind::UnterminatedString => (YAML_UNTERMINATED_STRING, "quoted scalar is missing its closing quote"),
362        ParseErrorKind::Other => (YAML_SYNTAX_ERROR, "invalid YAML syntax"),
363    };
364    let start = (error.range.start as usize).min(source_len);
365    let end = (error.range.end as usize).clamp(start, source_len);
366    let span = SourceSpan::from_valid_offsets(source_id, start, end);
367
368    Diagnostic::new(code, Severity::Error, message).with_label(DiagnosticLabel::primary(span, "syntax error"))
369}
370
371fn unparsed_input_diagnostic(source_id: SourceId, source_len: usize, document_end: usize) -> Diagnostic {
372    let span = SourceSpan::from_valid_offsets(source_id, document_end.min(source_len), source_len);
373
374    Diagnostic::new(
375        YAML_UNPARSED_INPUT,
376        Severity::Error,
377        "YAML parser did not include the complete input in the document root",
378    )
379    .with_label(DiagnosticLabel::primary(span, "input omitted from YAML document"))
380    .with_note("the original source remains available, but typed processing must not continue silently")
381}
382
383fn unparsed_input_offset(parse: &Parse<YamlFile>, source: &str) -> Option<usize> {
384    let document_end = parse
385        .tree()
386        .document()
387        .and_then(|document| {
388            document
389                .as_node()
390                .map(|node| u32::from(node.text_range().end()) as usize)
391        })
392        .unwrap_or_default();
393    unparsed_input_offset_after_document_end(document_end, source)
394}
395
396fn unparsed_input_offset_after_document_end(document_end: usize, source: &str) -> Option<usize> {
397    source
398        .as_bytes()
399        .get(document_end..)
400        .is_some_and(|suffix| suffix.iter().any(|byte| !byte.is_ascii_whitespace()))
401        .then_some(document_end)
402}
403
404/// Produces a same-length input for private YAML backend limitations. It masks safe, non-colliding
405/// hyphens in anchor and alias names, leading option dashes, and commas in block-style plain
406/// scalars, plus block-body quotes and explicit indentation. Authored block boundaries are checked
407/// independently. Original text remains authoritative for scalar meaning and preservation.
408fn parser_compatible_source(source: &str) -> (Option<String>, Vec<BlockScalarBoundary>) {
409    let mut compatible = source.as_bytes().to_vec();
410    let mut changed = mask_blank_lines_after_mapping_keys(source, &mut compatible);
411    changed |= mask_non_colliding_anchor_hyphens(source, &mut compatible);
412    let mut offset = 0;
413    let mut blocks: Vec<BlockScalarBoundary> = Vec::new();
414    let mut state = ParserCompatibilityState::default();
415
416    for line in source.split_inclusive('\n') {
417        let content = line.trim_end_matches(['\r', '\n']);
418        let indent = content.bytes().take_while(|byte| *byte == b' ').count();
419        let blank = content[indent..].is_empty();
420
421        if let Some(block) = blocks.last_mut().filter(|block| block.end == offset)
422            && (blank || indent > block.parent_indent)
423        {
424            if blank && block.content_indent.is_none() {
425                block.leading_blank_indent = block.leading_blank_indent.max(indent);
426            } else if !blank {
427                let required = *block.content_indent.get_or_insert(indent);
428                block.valid &= indent >= required && block.leading_blank_indent <= required;
429                if block.explicit_indent && indent > required {
430                    // The implicit private header must infer the explicit minimum even when
431                    // the first content line deliberately retains additional leading spaces.
432                    compatible[offset + required..offset + indent].fill(b'_');
433                    changed = true;
434                }
435            }
436            // Quotes are content in YAML block scalars, but the backend lexer scans them as
437            // quoted strings before recognizing the block. Keep byte offsets while preventing
438            // that scan from consuming a dedented sibling. Decode the original scalar below.
439            for (index, byte) in content.bytes().enumerate() {
440                if matches!(byte, b'\'' | b'"') {
441                    compatible[offset + index] = b'_';
442                    changed = true;
443                }
444            }
445            block.end += line.len();
446            offset += line.len();
447            continue;
448        }
449
450        let (line_changed, starts_block_scalar) = mask_block_plain_commas(content, offset, &mut compatible, &mut state);
451        changed |= line_changed;
452        if let Some(index) = starts_block_scalar {
453            let parent_indent = block_parent_indent(&content[..index]);
454            let header = &content[index..];
455            let token = header.split_ascii_whitespace().next().unwrap_or_default();
456            let mut digit = None;
457            let mut chomp = None;
458            let mut valid = true;
459            for byte in token.bytes().skip(1) {
460                match byte {
461                    b'1'..=b'9' if digit.is_none() => digit = Some(usize::from(byte - b'0')),
462                    b'+' | b'-' if chomp.is_none() => chomp = Some(byte),
463                    _ => valid = false,
464                }
465            }
466            valid &=
467                header[token.len()..].trim_start().is_empty() || header[token.len()..].trim_start().starts_with('#');
468            if digit.is_some() && valid {
469                // The backend interprets explicit indentation as an absolute column. Parse an
470                // implicit header instead; decode its authored semantics independently below.
471                compatible[offset + index + 1..offset + index + token.len()].fill(b' ');
472                if token.len() > 2 {
473                    compatible[offset + index + 2] = b'#';
474                }
475                changed = true;
476            }
477            blocks.push(BlockScalarBoundary {
478                start: offset + index,
479                end: offset + line.len(),
480                parent_indent,
481                content_indent: digit.map(|digit| parent_indent + digit),
482                explicit_indent: digit.is_some(),
483                leading_blank_indent: 0,
484                valid,
485            });
486        }
487        offset += line.len();
488    }
489
490    if changed {
491        (String::from_utf8(compatible).ok(), blocks)
492    } else {
493        (None, blocks)
494    }
495}
496
497#[derive(Debug)]
498struct BlockScalarBoundary {
499    start: usize,
500    end: usize,
501    parent_indent: usize,
502    content_indent: Option<usize>,
503    explicit_indent: bool,
504    leading_blank_indent: usize,
505    valid: bool,
506}
507
508fn block_parent_indent(prefix: &str) -> usize {
509    let indent = prefix.bytes().take_while(|byte| *byte == b' ').count();
510    let bytes = prefix.as_bytes();
511    let mut cursor = indent;
512    let mut sequence_indent = None;
513    while bytes.get(cursor) == Some(&b'-') && bytes.get(cursor + 1).is_some_and(u8::is_ascii_whitespace) {
514        sequence_indent = Some(cursor);
515        cursor += 1;
516        cursor += bytes[cursor..]
517            .iter()
518            .take_while(|byte| byte.is_ascii_whitespace())
519            .count();
520    }
521    // Anchor/tag properties belong to the scalar; they do not introduce a compact mapping.
522    while matches!(bytes.get(cursor), Some(b'&' | b'!')) {
523        cursor += bytes[cursor..]
524            .iter()
525            .take_while(|byte| !byte.is_ascii_whitespace())
526            .count();
527        cursor += bytes[cursor..]
528            .iter()
529            .take_while(|byte| byte.is_ascii_whitespace())
530            .count();
531    }
532    sequence_indent.map_or(indent, |base| if cursor == prefix.len() { base } else { cursor })
533}
534
535fn block_scalar_is_complete(parse: &Parse<YamlFile>, source: &str, block: &BlockScalarBoundary) -> bool {
536    parse.tree().documents().any(|document| {
537        document.as_node().is_some_and(|root| {
538            root.descendants().any(|node| {
539                let Some(YamlNode::Scalar(scalar)) = YamlNode::from_syntax(node) else {
540                    return false;
541                };
542                let range = scalar.byte_range();
543                range.start as usize == block.start
544                    && range.end as usize <= block.end
545                    && source[range.end as usize..block.end]
546                        .bytes()
547                        .all(|byte| byte.is_ascii_whitespace())
548            })
549        })
550    })
551}
552
553fn block_scalar_diagnostic(source_id: SourceId, source_len: usize, start: usize, code: DiagnosticCode) -> Diagnostic {
554    Diagnostic::new(
555        code,
556        Severity::Error,
557        "The YAML block scalar could not be interpreted safely.",
558    )
559    .with_label(DiagnosticLabel::primary(
560        SourceSpan::from_valid_offsets(source_id, start, source_len.min(start + 1)),
561        "Check the block scalar header and indentation.",
562    ))
563}
564
565fn mask_blank_lines_after_mapping_keys(source: &str, compatible: &mut [u8]) -> bool {
566    let bytes = source.as_bytes();
567    let mut line_starts = vec![0];
568    line_starts.extend(
569        bytes
570            .iter()
571            .enumerate()
572            .filter_map(|(index, byte)| (*byte == b'\n').then_some(index + 1)),
573    );
574    let mut changed = false;
575
576    for line_index in 0..line_starts.len().saturating_sub(2) {
577        let start = line_starts[line_index];
578        let end = line_starts.get(line_index + 1).copied().unwrap_or(bytes.len());
579        let content_end = line_content_end(bytes, start, end);
580        let content = &source[start..content_end];
581        let trimmed = content.trim();
582        if trimmed.starts_with('#') || !trimmed.ends_with(':') {
583            continue;
584        }
585
586        let mut next_line = line_index + 1;
587        while next_line < line_starts.len() {
588            let next_start = line_starts[next_line];
589            let next_end = line_starts.get(next_line + 1).copied().unwrap_or(bytes.len());
590            let next_content_end = line_content_end(bytes, next_start, next_end);
591            if !source[next_start..next_content_end].trim().is_empty() {
592                break;
593            }
594            next_line += 1;
595        }
596        if next_line == line_index + 1 || next_line >= line_starts.len() {
597            continue;
598        }
599
600        let key_indent = content.bytes().take_while(|byte| *byte == b' ').count();
601        let value_start = line_starts[next_line];
602        let value_indent = source[value_start..].bytes().take_while(|byte| *byte == b' ').count();
603        if value_indent <= key_indent {
604            continue;
605        }
606
607        // Keep the final blank line's line ending and turn the preceding separators into trailing
608        // spaces. This removes the backend-only blank-line ambiguity without changing byte spans.
609        let final_blank_start = line_starts[next_line - 1];
610        compatible[content_end..final_blank_start].fill(b' ');
611        changed = true;
612    }
613    changed
614}
615
616fn line_content_end(bytes: &[u8], start: usize, end: usize) -> usize {
617    let mut content_end = end;
618    if content_end > start && bytes[content_end - 1] == b'\n' {
619        content_end -= 1;
620    }
621    if content_end > start && bytes[content_end - 1] == b'\r' {
622        content_end -= 1;
623    }
624    content_end
625}
626
627fn mask_non_colliding_anchor_hyphens(source: &str, compatible: &mut [u8]) -> bool {
628    let bytes = source.as_bytes();
629    let mut occurrences = Vec::new();
630    let mut originals_by_normalized = BTreeMap::<Vec<u8>, BTreeSet<Vec<u8>>>::new();
631    let mut index = 0;
632
633    while index < bytes.len() {
634        if !matches!(bytes[index], b'&' | b'*') {
635            index += 1;
636            continue;
637        }
638        let line_start = bytes[..index]
639            .iter()
640            .rposition(|byte| *byte == b'\n')
641            .map_or(0, |position| position + 1);
642        let preceding = bytes[line_start..index]
643            .iter()
644            .rfind(|byte| !byte.is_ascii_whitespace())
645            .copied();
646        if preceding.is_some_and(|byte| !matches!(byte, b':' | b'-' | b'[' | b'{' | b',' | b'?')) {
647            index += 1;
648            continue;
649        }
650        let start = index + 1;
651        let mut end = start;
652        while bytes.get(end).is_some_and(|byte| !anchor_name_delimiter(*byte)) {
653            end += 1;
654        }
655        if start == end {
656            index += 1;
657            continue;
658        }
659
660        let original = bytes[start..end].to_vec();
661        let normalized = original
662            .iter()
663            .map(|byte| if *byte == b'-' { b'_' } else { *byte })
664            .collect::<Vec<_>>();
665        originals_by_normalized
666            .entry(normalized.clone())
667            .or_default()
668            .insert(original.clone());
669        occurrences.push((start, end, original, normalized));
670        index = end;
671    }
672
673    let mut changed = false;
674    for (start, end, original, normalized) in occurrences {
675        if !original.contains(&b'-')
676            || originals_by_normalized
677                .get(&normalized)
678                .is_none_or(|originals| originals.len() != 1)
679        {
680            continue;
681        }
682        for byte in &mut compatible[start..end] {
683            if *byte == b'-' {
684                *byte = b'_';
685                changed = true;
686            }
687        }
688    }
689    changed
690}
691
692const fn anchor_name_delimiter(byte: u8) -> bool {
693    byte.is_ascii_whitespace() || matches!(byte, b'[' | b']' | b'{' | b'}' | b',')
694}
695
696#[derive(Debug, Default)]
697struct ParserCompatibilityState {
698    flow_depth: u32,
699    quote: Option<u8>,
700    escaped: bool,
701}
702
703fn block_sequence_indicator(bytes: &[u8], index: usize) -> bool {
704    bytes[index] == b'-' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace)
705}
706
707fn mask_block_plain_commas(
708    content: &str,
709    offset: usize,
710    compatible: &mut [u8],
711    state: &mut ParserCompatibilityState,
712) -> (bool, Option<usize>) {
713    let bytes = content.as_bytes();
714    let mut index = 0;
715    let mut first_token = true;
716    let mut plain_started = false;
717    let mut eligible_plain_value = false;
718    let mut interpolation_depth = 0u32;
719    let mut changed = false;
720
721    while index < bytes.len() {
722        let byte = bytes[index];
723
724        if let Some(delimiter) = state.quote {
725            if delimiter == b'"' && state.escaped {
726                state.escaped = false;
727            } else if delimiter == b'"' && byte == b'\\' {
728                state.escaped = true;
729            } else if byte == delimiter {
730                if delimiter == b'\'' && bytes.get(index + 1) == Some(&b'\'') {
731                    index += 1;
732                } else {
733                    state.quote = None;
734                }
735            }
736            index += 1;
737            continue;
738        }
739
740        if byte == b'#' && (index == 0 || bytes[index - 1].is_ascii_whitespace()) {
741            break;
742        }
743
744        if eligible_plain_value && !plain_started && matches!(byte, b'!' | b'&') {
745            index += 1;
746            while bytes.get(index).is_some_and(|byte| !byte.is_ascii_whitespace()) {
747                index += 1;
748            }
749            first_token = false;
750            continue;
751        }
752
753        if matches!(byte, b'\'' | b'"') && !plain_started {
754            state.quote = Some(byte);
755            first_token = false;
756            index += 1;
757            continue;
758        }
759
760        if state.flow_depth > 0 {
761            match byte {
762                b'[' | b'{' => state.flow_depth += 1,
763                b']' | b'}' => state.flow_depth -= 1,
764                _ => {}
765            }
766            index += 1;
767            continue;
768        }
769
770        let token_boundary = index == 0 || bytes[index - 1].is_ascii_whitespace();
771        if matches!(byte, b'[' | b'{') && (!plain_started || (eligible_plain_value && token_boundary)) {
772            state.flow_depth = 1;
773            first_token = false;
774            index += 1;
775            continue;
776        }
777
778        if byte.is_ascii_whitespace() {
779            index += 1;
780            continue;
781        }
782
783        if (first_token || (eligible_plain_value && !plain_started)) && block_sequence_indicator(bytes, index) {
784            eligible_plain_value = true;
785            plain_started = false;
786            first_token = false;
787            index += 1;
788            continue;
789        }
790
791        if byte == b':' && bytes.get(index + 1).is_none_or(u8::is_ascii_whitespace) {
792            eligible_plain_value = true;
793            plain_started = false;
794            first_token = false;
795            index += 1;
796            continue;
797        }
798
799        if eligible_plain_value && !plain_started && matches!(byte, b'|' | b'>') {
800            return (changed, Some(index));
801        }
802
803        // yaml-edit currently treats the first dash in an unquoted `- --option` item as another
804        // block-sequence indicator. Masking one dash in the private, same-length parser input keeps
805        // the item scalar while scalar extraction still reads the authored source.
806        if eligible_plain_value && !plain_started && byte == b'-' && bytes.get(index + 1) == Some(&b'-') {
807            compatible[offset + index] = b'_';
808            changed = true;
809        }
810
811        if eligible_plain_value && plain_started && byte == b',' {
812            compatible[offset + index] = b'_';
813            changed = true;
814        }
815
816        // yaml-edit treats `}` as a plain-scalar terminator, while Compose permits
817        // unquoted `${NAME}` expressions. Mask only balanced interpolation closers
818        // in the private same-length parser input; source extraction restores the
819        // authored brace and therefore preserves exact diagnostics and rendering.
820        if eligible_plain_value && byte == b'$' && bytes.get(index + 1) == Some(&b'{') {
821            interpolation_depth = interpolation_depth.saturating_add(1);
822        } else if eligible_plain_value && plain_started && byte == b'}' && interpolation_depth > 0 {
823            interpolation_depth -= 1;
824            compatible[offset + index] = b'_';
825            changed = true;
826        }
827
828        plain_started = true;
829        first_token = false;
830        index += 1;
831    }
832
833    state.escaped = false;
834    (changed, None)
835}
836
837pub(crate) fn scalar_raw_from_source(source: &str, scalar: &Scalar) -> String {
838    let range = scalar.byte_range();
839    source
840        .get(range.start as usize..range.end as usize)
841        .map_or_else(|| scalar.value(), str::to_owned)
842}
843
844pub(crate) fn scalar_string_from_source(source: &str, scalar: &Scalar) -> String {
845    let authored = scalar_raw_from_source(source, scalar);
846    if authored.starts_with(['|', '>']) {
847        let line_start = source[..scalar.byte_range().start as usize]
848            .rfind('\n')
849            .map_or(0, |index| index + 1);
850        let indent = block_parent_indent(&source[line_start..scalar.byte_range().start as usize]);
851        decode_block_scalar(&authored, indent)
852    } else if authored == scalar.value() {
853        scalar.as_string()
854    } else {
855        // Other compatibility replacements rewrite complete, single-line block plain scalars.
856        authored
857    }
858}
859
860/// Decodes authored block scalar text already bounded and checked by the syntax adapter.
861/// YAML 1.2.2 section 8.1 defines relative indentation, folding and chomping independently of
862/// the private backend's lexer and scalar decoder.
863fn decode_block_scalar(authored: &str, parent_indent: usize) -> String {
864    let Some((header, body)) = authored.split_once('\n') else {
865        return String::new();
866    };
867    let token = header.split_ascii_whitespace().next().unwrap_or_default();
868    let explicit = token.bytes().skip(1).find(|byte| matches!(byte, b'1'..=b'9'));
869    let indent = explicit.map_or_else(
870        || {
871            body.split_inclusive('\n')
872                .find_map(|line| {
873                    let content = line.trim_end_matches(['\r', '\n']);
874                    let spaces = content.bytes().take_while(|byte| *byte == b' ').count();
875                    (spaces < content.len()).then_some(spaces)
876                })
877                // An implicit block with no non-empty content consists entirely of trailing
878                // empty lines. Their indentation spaces are not scalar content.
879                .unwrap_or_else(|| body.split_inclusive('\n').map(|line| {
880                    line.bytes().take_while(|byte| *byte == b' ').count()
881                }).max().unwrap_or(parent_indent + 1))
882        },
883        |digit| parent_indent + usize::from(digit - b'0'),
884    );
885    let lines: Vec<_> = body
886        .split_inclusive('\n')
887        .map(|line| {
888            let has_break = line.ends_with('\n');
889            let content = line.strip_suffix('\n').unwrap_or(line);
890            let content = content.strip_suffix('\r').unwrap_or(content);
891            let spaces = content.bytes().take_while(|byte| *byte == b' ').count();
892            let text = &content[spaces.min(indent)..];
893            (text, has_break, spaces > indent || text.starts_with('\t'))
894        })
895        .collect();
896    let folded = token.starts_with('>');
897    let mut decoded = String::new();
898    for (index, &(text, has_break, more_indented)) in lines.iter().enumerate() {
899        decoded.push_str(text);
900        if !has_break {
901            continue;
902        }
903        if !folded || text.is_empty() || more_indented {
904            decoded.push('\n');
905            continue;
906        }
907        match lines.get(index + 1) {
908            Some((next, _, false)) if !next.is_empty() => decoded.push(' '),
909            Some((next, _, _))
910                if next.is_empty()
911                    && lines[index + 1..]
912                        .iter()
913                        .find(|(text, _, _)| !text.is_empty())
914                        .is_some_and(|(_, _, more)| !more) => {}
915            _ => decoded.push('\n'),
916        }
917    }
918    let has_final_break = decoded.ends_with('\n');
919    if !token.contains('+') {
920        decoded.truncate(decoded.trim_end_matches('\n').len());
921        if !token.contains('-') && has_final_break && !decoded.is_empty() {
922            decoded.push('\n');
923        }
924    }
925    decoded
926}
927
928fn collect_value_scalars(source_id: SourceId, source: &str, node: YamlNode, values: &mut Vec<ValueScalar>) {
929    match node {
930        YamlNode::Scalar(scalar) => collect_scalar(source_id, source, &scalar, values),
931        YamlNode::Mapping(mapping) => {
932            for value in mapping.entries().filter_map(|entry| entry.value_node()) {
933                collect_value_scalars(source_id, source, value, values);
934            }
935        }
936        YamlNode::Sequence(sequence) => {
937            for value in sequence.values() {
938                collect_value_scalars(source_id, source, value, values);
939            }
940        }
941        YamlNode::TaggedNode(tagged) => {
942            if let Some(node) = tagged
943                .as_node()
944                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
945            {
946                collect_value_scalars(source_id, source, node, values);
947            }
948        }
949        YamlNode::Alias(_) => {}
950    }
951}
952
953fn collect_editable_value_scalars(
954    source_id: SourceId,
955    source: &str,
956    node: YamlNode,
957    values: &mut Vec<EditableValueScalar>,
958) {
959    match node {
960        YamlNode::Scalar(scalar) => {
961            values.push(EditableValueScalar {
962                raw: scalar_raw_from_source(source, &scalar),
963                span: position_span(source_id, scalar.byte_range()),
964            });
965        }
966        YamlNode::Mapping(mapping) => {
967            for value in mapping.entries().filter_map(|entry| entry.value_node()) {
968                collect_editable_value_scalars(source_id, source, value, values);
969            }
970        }
971        YamlNode::Sequence(sequence) => {
972            for value in sequence.values() {
973                collect_editable_value_scalars(source_id, source, value, values);
974            }
975        }
976        YamlNode::TaggedNode(tagged) => {
977            if let Some(node) = tagged
978                .as_node()
979                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
980            {
981                collect_editable_value_scalars(source_id, source, node, values);
982            }
983        }
984        YamlNode::Alias(_) => {}
985    }
986}
987
988fn extract_merge_value(
989    source_id: SourceId,
990    source: &str,
991    node: YamlNode,
992    registry: &AnchorRegistry,
993    aliases: &mut Vec<String>,
994) -> MergeSyntaxValue {
995    match node {
996        YamlNode::Scalar(scalar) if scalar.byte_range().is_empty() => {
997            MergeSyntaxValue::Empty(position_span(source_id, scalar.byte_range()))
998        }
999        YamlNode::Scalar(scalar) => MergeSyntaxValue::Scalar(extract_merge_scalar(source_id, source, &scalar)),
1000        YamlNode::Mapping(mapping) => extract_merge_mapping(source_id, source, &mapping, registry, aliases),
1001        YamlNode::Sequence(sequence) => {
1002            let span = position_span(source_id, sequence.byte_range());
1003            let values = sequence
1004                .values()
1005                .map(|value| extract_merge_value(source_id, source, value, registry, aliases))
1006                .collect();
1007            MergeSyntaxValue::Sequence { values, span }
1008        }
1009        YamlNode::Alias(alias) => {
1010            let name = alias.name();
1011            let span = yaml_node_span(source_id, &YamlNode::Alias(alias.clone()));
1012            if aliases.contains(&name) || aliases.len() >= 64 {
1013                return MergeSyntaxValue::Alias {
1014                    name: original_alias_name(source, span).unwrap_or(name),
1015                    span,
1016                };
1017            }
1018            if let Some(target) = registry.resolve(&name).and_then(|node| {
1019                YamlNode::from_syntax(node.clone()).or_else(|| node.children().find_map(YamlNode::from_syntax))
1020            }) {
1021                aliases.push(name);
1022                let value = extract_merge_value(source_id, source, target, registry, aliases);
1023                let _ = aliases.pop();
1024                value
1025            } else {
1026                MergeSyntaxValue::Alias {
1027                    name: original_alias_name(source, span).unwrap_or(name),
1028                    span,
1029                }
1030            }
1031        }
1032        YamlNode::TaggedNode(tagged) => {
1033            let span = yaml_node_span(source_id, &YamlNode::TaggedNode(tagged.clone()));
1034            let tag = tagged.tag().unwrap_or_default();
1035            let mut value = tagged
1036                .as_node()
1037                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
1038                .map_or_else(
1039                    || MergeSyntaxValue::Empty(span),
1040                    |value| extract_merge_value(source_id, source, value, registry, aliases),
1041                );
1042            if matches!(tag.as_str(), "!!timestamp" | "!!regex")
1043                && let MergeSyntaxValue::Scalar(scalar) = &mut value
1044            {
1045                scalar.strict_yaml_string = false;
1046            }
1047            MergeSyntaxValue::Tagged {
1048                tag,
1049                value: Box::new(value),
1050                span,
1051            }
1052        }
1053    }
1054}
1055
1056fn original_alias_name(source: &str, span: SourceSpan) -> Option<String> {
1057    source.get(span.range())?.strip_prefix('*').map(ToOwned::to_owned)
1058}
1059
1060fn extract_merge_mapping(
1061    source_id: SourceId,
1062    source: &str,
1063    mapping: &Mapping,
1064    registry: &AnchorRegistry,
1065    aliases: &mut Vec<String>,
1066) -> MergeSyntaxValue {
1067    let span = position_span(source_id, mapping.byte_range());
1068    let fields = raw_merge_fields(source_id, source, mapping);
1069    let direct = if is_flow_mapping(source, mapping) {
1070        fields
1071    } else {
1072        flatten_merge_fields(source_id, source, fields)
1073    };
1074    let mut entries = Vec::new();
1075    let mut inherited = Vec::new();
1076    for mut field in direct {
1077        let value = field.value.map_or_else(
1078            || {
1079                MergeSyntaxValue::Empty(SourceSpan::from_valid_offsets(
1080                    source_id,
1081                    field.key.span.end(),
1082                    field.key.span.end(),
1083                ))
1084            },
1085            |value| extract_merge_value(source_id, source, value, registry, aliases),
1086        );
1087        if field.key.plain && field.key.value == "<<" {
1088            match merge_source_groups(value) {
1089                Ok(groups) => inherited.extend(groups),
1090                Err(value) => {
1091                    // Invalid/unresolved merge sources must remain diagnosable, not disappear.
1092                    field.key.strict_yaml_string = false;
1093                    entries.push(MergeSyntaxEntry { key: field.key, value });
1094                }
1095            }
1096        } else {
1097            entries.push(MergeSyntaxEntry { key: field.key, value });
1098        }
1099    }
1100    // Local keys override inherited keys; earlier merge sources override later sources. Add a
1101    // group's keys only after filtering it so duplicate entries within that source stay visible.
1102    let mut previous: BTreeSet<_> = entries
1103        .iter()
1104        .map(|entry| (entry.key.value.clone(), entry.key.strict_yaml_string))
1105        .collect();
1106    for group in inherited {
1107        let mut added = BTreeSet::new();
1108        entries.extend(group.into_iter().filter(|entry| {
1109            let key = (entry.key.value.clone(), entry.key.strict_yaml_string);
1110            if previous.contains(&key) {
1111                false
1112            } else {
1113                added.insert(key);
1114                true
1115            }
1116        }));
1117        previous.extend(added);
1118    }
1119    MergeSyntaxValue::Mapping { entries, span }
1120}
1121
1122fn merge_source_groups(value: MergeSyntaxValue) -> Result<Vec<Vec<MergeSyntaxEntry>>, MergeSyntaxValue> {
1123    match value {
1124        MergeSyntaxValue::Mapping { entries, .. } => Ok(vec![entries]),
1125        MergeSyntaxValue::Sequence { values, span } => {
1126            if values
1127                .iter()
1128                .any(|value| !matches!(value, MergeSyntaxValue::Mapping { .. }))
1129            {
1130                return Err(MergeSyntaxValue::Sequence { values, span });
1131            }
1132            Ok(values
1133                .into_iter()
1134                .filter_map(|value| match value {
1135                    MergeSyntaxValue::Mapping { entries, .. } => Some(entries),
1136                    _ => None,
1137                })
1138                .collect())
1139        }
1140        value => Err(value),
1141    }
1142}
1143
1144#[derive(Debug)]
1145struct RawMergeField {
1146    key: MergeSyntaxScalar,
1147    value: Option<YamlNode>,
1148}
1149
1150fn raw_merge_fields(source_id: SourceId, source: &str, mapping: &Mapping) -> Vec<RawMergeField> {
1151    let node = YamlNode::Mapping(mapping.clone());
1152    let Some(syntax) = node.as_node() else {
1153        return Vec::new();
1154    };
1155    // Mapping children are entry nodes. Keep their KEY wrappers available: key_node() omits
1156    // unsupported properties such as a tag token followed by a scalar inside that wrapper.
1157    mapping
1158        .entries()
1159        .zip(syntax.children())
1160        .filter_map(|(entry, authored)| {
1161            let wrapper = authored.children().next()?;
1162            let range = wrapper.text_range();
1163            let span = SourceSpan::from_valid_offsets(
1164                source_id,
1165                u32::from(authored.text_range().start()) as usize,
1166                u32::from(range.end()) as usize,
1167            );
1168            let key = if source.get(span.range()).is_some_and(|raw| raw.starts_with(['!', '&'])) {
1169                // key_node() exposes only the scalar body and hides leading property tokens.
1170                unsupported_merge_key(source, span)
1171            } else {
1172                entry.key_node().map_or_else(
1173                    || unsupported_merge_key(source, span),
1174                    |node| extract_merge_key(source_id, source, &node),
1175                )
1176            };
1177            Some(RawMergeField {
1178                key,
1179                value: entry.value_node(),
1180            })
1181        })
1182        .collect()
1183}
1184
1185// The backend can consume an anchored sibling mapping key into a preceding plain scalar.
1186// Such a declaration cannot be valid plain-scalar content: colon + separation requires structure.
1187fn anchored_mapping_in_plain_scalar(raw: &str) -> Option<usize> {
1188    if raw.starts_with(['\'', '"', '|', '>']) {
1189        return None;
1190    }
1191    let mut offset = 0;
1192    for line in raw.split_inclusive('\n') {
1193        let content = line.trim_start_matches([' ', '\t']);
1194        if content.starts_with('&')
1195            && (content.contains(": ") || content.contains(":\t") || content.trim_end().ends_with(':'))
1196        {
1197            return Some(offset + line.len() - content.len());
1198        }
1199        offset += line.len();
1200    }
1201    None
1202}
1203
1204fn extract_merge_key(source_id: SourceId, source: &str, node: &YamlNode) -> MergeSyntaxScalar {
1205    node.as_scalar().map_or_else(
1206        || unsupported_merge_key(source, yaml_node_span(source_id, node)),
1207        |scalar| extract_merge_scalar(source_id, source, scalar),
1208    )
1209}
1210
1211fn unsupported_merge_key(source: &str, span: SourceSpan) -> MergeSyntaxScalar {
1212    let raw = source.get(span.range()).unwrap_or_default().to_owned();
1213    MergeSyntaxScalar {
1214        value: raw.clone(),
1215        raw,
1216        kind: MergeScalarKind::String,
1217        plain: false,
1218        presentation: ScalarPresentation::Plain,
1219        strict_yaml_string: false,
1220        span,
1221    }
1222}
1223
1224fn flatten_merge_fields(source_id: SourceId, source: &str, fields: Vec<RawMergeField>) -> Vec<RawMergeField> {
1225    let Some(target_column) = fields
1226        .first()
1227        .map(|field| source_column(source, field.key.span.start()))
1228    else {
1229        return fields;
1230    };
1231    recover_merge_fields(source_id, source, fields, target_column)
1232}
1233
1234fn recover_merge_fields(
1235    source_id: SourceId,
1236    source: &str,
1237    fields: Vec<RawMergeField>,
1238    target_column: usize,
1239) -> Vec<RawMergeField> {
1240    let mut flattened = Vec::new();
1241    for mut field in fields {
1242        let field_column = source_column(source, field.key.span.start());
1243        let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
1244        let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
1245            !is_flow_mapping(source, mapping)
1246                && mapping
1247                    .entries()
1248                    .find_map(|entry| entry.key_node()?.as_scalar().map(Scalar::byte_range))
1249                    .is_some_and(|position| source_column(source, position.start as usize) <= field_column)
1250        });
1251        if continuation {
1252            field.value = None;
1253        }
1254        if field_column == target_column {
1255            flattened.push(field);
1256        }
1257        if let Some(mapping) = nested_mapping.filter(|mapping| !is_flow_mapping(source, mapping)) {
1258            let nested = raw_merge_fields(source_id, source, &mapping);
1259            flattened.extend(recover_merge_fields(source_id, source, nested, target_column));
1260        }
1261    }
1262    flattened
1263}
1264
1265fn is_flow_mapping(source: &str, mapping: &Mapping) -> bool {
1266    let position = mapping.byte_range();
1267    source
1268        .get(position.start as usize..position.end as usize)
1269        .is_some_and(|text| text.trim_start().starts_with('{'))
1270}
1271
1272fn source_column(source: &str, offset: usize) -> usize {
1273    let prefix = &source[..offset.min(source.len())];
1274    let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
1275    source[line_start..offset.min(source.len())].chars().count()
1276}
1277
1278fn extract_merge_scalar(source_id: SourceId, source: &str, scalar: &Scalar) -> MergeSyntaxScalar {
1279    let scalar_type = ScalarValue::from_scalar(scalar).scalar_type();
1280    let kind = match scalar_type {
1281        ScalarType::Boolean => MergeScalarKind::Boolean,
1282        ScalarType::Integer | ScalarType::Float => MergeScalarKind::Number,
1283        ScalarType::Null => MergeScalarKind::Null,
1284        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MergeScalarKind::String,
1285    };
1286    MergeSyntaxScalar {
1287        presentation: match scalar_raw_from_source(source, scalar).chars().next() {
1288            Some('|') => ScalarPresentation::Literal,
1289            Some('>') => ScalarPresentation::Folded,
1290            Some('\'') => ScalarPresentation::SingleQuoted,
1291            Some('"') => ScalarPresentation::DoubleQuoted,
1292            _ => ScalarPresentation::Plain,
1293        },
1294        raw: scalar_raw_from_source(source, scalar),
1295        value: scalar_string_from_source(source, scalar),
1296        kind,
1297        plain: ScalarValue::from_scalar(scalar).style() == ScalarStyle::Plain
1298            && !scalar_uses_block_style(source, scalar),
1299        strict_yaml_string: scalar_type == ScalarType::String,
1300        span: position_span(source_id, scalar.byte_range()),
1301    }
1302}
1303
1304fn scalar_uses_block_style(source: &str, scalar: &Scalar) -> bool {
1305    let start = scalar.byte_range().start as usize;
1306    source[start..].trim_start().starts_with(['|', '>'])
1307        || source[..start]
1308            .lines()
1309            .rev()
1310            .find(|line| !line.trim().is_empty())
1311            .is_some_and(|header| header.contains(": |") || header.contains(": >"))
1312}
1313
1314fn yaml_node_span(source_id: SourceId, node: &YamlNode) -> SourceSpan {
1315    let Some(syntax) = node.as_node() else {
1316        return SourceSpan::from_valid_offsets(source_id, 0, 0);
1317    };
1318    let range = syntax.text_range();
1319    SourceSpan::from_valid_offsets(
1320        source_id,
1321        u32::from(range.start()) as usize,
1322        u32::from(range.end()) as usize,
1323    )
1324}
1325
1326fn position_span(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
1327    SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
1328}
1329
1330fn collect_scalar(source_id: SourceId, source: &str, scalar: &Scalar, values: &mut Vec<ValueScalar>) {
1331    let raw = scalar_raw_from_source(source, scalar);
1332    let eligible_style = !raw.starts_with('\'') && !raw.starts_with('|') && !raw.starts_with('>');
1333    if !eligible_style || !raw.contains('$') {
1334        return;
1335    }
1336    let position = scalar.byte_range();
1337    values.push(ValueScalar {
1338        value: scalar_string_from_source(source, scalar),
1339        span: SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize),
1340    });
1341}
1342
1343#[cfg(test)]
1344mod tests {
1345    use super::{
1346        MergeSyntaxScalar, MergeSyntaxValue, SyntaxDocument, block_scalar_is_complete, parser_compatible_source,
1347        unparsed_input_offset_after_document_end,
1348    };
1349    use crate::source::SourceId;
1350
1351    fn assert_send_and_sync<T: Send + Sync>() {}
1352
1353    #[test]
1354    fn syntax_documents_are_send_and_sync() {
1355        assert_send_and_sync::<SyntaxDocument>();
1356    }
1357
1358    #[test]
1359    fn parsing_never_reads_the_process_environment() -> Result<(), Box<dyn std::error::Error>> {
1360        let source = "services:\n  app:\n    image: ${COMPOSE_LENS_SECRET}\n";
1361        let parsed = SyntaxDocument::parse(SourceId::new(1), source)?;
1362
1363        assert_eq!(parsed.document().render_preserved(), source);
1364        assert!(parsed.is_valid());
1365        Ok(())
1366    }
1367
1368    #[test]
1369    fn complete_root_guard_rejects_non_whitespace_after_the_backend_root() {
1370        let source = "services:\n  app:\n    image: example.invalid/app\n";
1371        let document_end = "services:".len();
1372
1373        assert_eq!(
1374            unparsed_input_offset_after_document_end(document_end, source),
1375            Some(document_end)
1376        );
1377        assert_eq!(unparsed_input_offset_after_document_end(source.len(), source), None);
1378    }
1379
1380    #[test]
1381    fn block_boundary_guard_rejects_backend_scalars_crossing_siblings() {
1382        let source = "services:\n  app:\n    command: |2\n      body\n  sibling:\n    image: later\n";
1383        let (_, blocks) = parser_compatible_source(source);
1384        let raw = yaml_edit::YamlFile::parse(source);
1385        assert_eq!(blocks.len(), 1);
1386        assert!(!block_scalar_is_complete(&raw, source, &blocks[0]));
1387    }
1388
1389    #[test]
1390    fn comma_bearing_short_volumes_are_complete_and_preserved() -> Result<(), Box<dyn std::error::Error>> {
1391        let source = "services:\n  app:\n    volumes:\n      - ./data:/data:Z,ro\n  later:\n    image: later\n";
1392
1393        let parsed = SyntaxDocument::parse(SourceId::new(2), source)?;
1394
1395        assert!(parsed.is_valid());
1396        assert_eq!(parsed.document().render_preserved(), source);
1397        Ok(())
1398    }
1399
1400    #[test]
1401    fn anchor_compatibility_does_not_merge_colliding_names() {
1402        let source = "first: &shared-name one\nsecond: &shared_name two\n";
1403
1404        assert_eq!(parser_compatible_source(source).0, None);
1405    }
1406
1407    #[test]
1408    fn anchor_compatibility_ignores_scalar_and_comment_content() {
1409        let source = "quoted: \"*not-an-alias\"\nplain: echo &not-an-anchor\n# *also-not-an-alias\n";
1410
1411        assert_eq!(parser_compatible_source(source).0, None);
1412    }
1413
1414    #[test]
1415    fn merge_scalars_retain_strict_yaml_string_identity() -> Result<(), Box<dyn std::error::Error>> {
1416        let parsed = SyntaxDocument::parse(
1417            SourceId::new(2),
1418            "plain: gpu\ntimestamp: !!timestamp 2023-12-25\nregex: !!regex 'gpu.*'\nquoted-timestamp: \"2023-12-25\"\nquoted-regex: \"gpu.*\"\n",
1419        )?;
1420        let root = parsed.document().merge_root().ok_or("merge root")?;
1421        let scalar = |name| -> Option<&MergeSyntaxScalar> {
1422            let MergeSyntaxValue::Mapping { entries, .. } = &root else {
1423                return None;
1424            };
1425            let value = &entries.iter().find(|entry| entry.key.value == name)?.value;
1426            match value {
1427                MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1428                MergeSyntaxValue::Tagged { value, .. } => match value.as_ref() {
1429                    MergeSyntaxValue::Scalar(scalar) => Some(scalar),
1430                    _ => None,
1431                },
1432                _ => None,
1433            }
1434        };
1435        assert!(scalar("plain").is_some_and(|value| value.strict_yaml_string));
1436        for name in ["timestamp", "regex"] {
1437            assert!(
1438                scalar(name).is_some_and(|value| !value.strict_yaml_string),
1439                "{name}: {:?}",
1440                scalar(name)
1441            );
1442        }
1443        for name in ["quoted-timestamp", "quoted-regex"] {
1444            assert!(scalar(name).is_some_and(|value| value.strict_yaml_string));
1445        }
1446        Ok(())
1447    }
1448}