Skip to main content

compose_lens/syntax/
mod.rs

1//! Loss-aware YAML syntax documents.
2
3use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
4use crate::source::{LineColumn, SourceId, SourceSpan, line_column};
5use std::error::Error;
6use std::fmt;
7use std::sync::Arc;
8use yaml_edit::{
9    AnchorRegistry, AsYaml, Mapping, MappingMergedExt, Parse, ParseErrorKind, Scalar, ScalarType, ScalarValue,
10    YamlFile, YamlNode,
11};
12
13/// A generic YAML syntax error not covered by a more specific code.
14pub const YAML_SYNTAX_ERROR: DiagnosticCode = DiagnosticCode::new("compose.yaml.syntax");
15
16/// A flow sequence is missing its closing bracket.
17pub const YAML_UNCLOSED_FLOW_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.yaml.unclosed-flow-sequence");
18
19/// A flow mapping is missing its closing brace.
20pub const YAML_UNCLOSED_FLOW_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.yaml.unclosed-flow-mapping");
21
22/// A quoted scalar is missing its closing quote.
23pub const YAML_UNTERMINATED_STRING: DiagnosticCode = DiagnosticCode::new("compose.yaml.unterminated-string");
24
25/// A loss-aware YAML document and its original source text.
26///
27/// The underlying concrete syntax tree is intentionally private so `ComposeLens` can maintain a
28/// stable API independently of its parser dependency. Rendering this initial representation emits
29/// the concrete syntax tree without normalization, interpolation, or environment access.
30#[derive(Debug, Clone, PartialEq, Eq)]
31pub struct SyntaxDocument {
32    source_id: SourceId,
33    source: Arc<str>,
34    parse: Parse<YamlFile>,
35}
36
37impl SyntaxDocument {
38    /// Parses YAML into a loss-aware document and structured diagnostics.
39    ///
40    /// Recoverable YAML errors are returned in [`SyntaxParse::diagnostics`] while the syntax
41    /// document remains available. Only sources too large for the concrete syntax tree produce a
42    /// fatal error.
43    ///
44    /// # Errors
45    ///
46    /// Returns [`SyntaxParseError`] when the source exceeds the concrete syntax tree's byte-offset
47    /// capacity. Malformed YAML is recoverable and is reported through structured diagnostics.
48    pub fn parse(source_id: SourceId, source: impl Into<Arc<str>>) -> Result<SyntaxParse, SyntaxParseError> {
49        let source = source.into();
50        if u32::try_from(source.len()).is_err() {
51            return Err(SyntaxParseError {
52                source_id,
53                byte_len: source.len(),
54            });
55        }
56
57        let parse = YamlFile::parse(&source);
58        let diagnostics = parse
59            .positioned_errors()
60            .iter()
61            .map(|error| syntax_diagnostic(source_id, source.len(), error))
62            .collect();
63
64        Ok(SyntaxParse {
65            document: Self {
66                source_id,
67                source,
68                parse,
69            },
70            diagnostics,
71        })
72    }
73
74    /// Returns the source identifier supplied by the caller.
75    #[must_use]
76    pub const fn source_id(&self) -> SourceId {
77        self.source_id
78    }
79
80    /// Returns the original source text.
81    #[must_use]
82    pub fn source_text(&self) -> &str {
83        &self.source
84    }
85
86    /// Returns the span covering the complete source text.
87    #[must_use]
88    pub fn source_span(&self) -> SourceSpan {
89        SourceSpan::from_valid_offsets(self.source_id, 0, self.source.len())
90    }
91
92    /// Returns the source text covered by a span from this document.
93    #[must_use]
94    pub fn text(&self, span: SourceSpan) -> Option<&str> {
95        if span.source_id() != self.source_id || span.end() > self.source.len() {
96            return None;
97        }
98
99        self.source.get(span.range())
100    }
101
102    /// Converts a byte offset in this document to a one-based line and column.
103    #[must_use]
104    pub fn line_column(&self, byte_offset: usize) -> Option<LineColumn> {
105        line_column(&self.source, byte_offset)
106    }
107
108    /// Returns the number of YAML documents in the stream.
109    #[must_use]
110    pub fn document_count(&self) -> usize {
111        self.parse.tree().documents().count()
112    }
113
114    /// Returns the number of comment tokens retained by the concrete syntax tree.
115    #[must_use]
116    pub fn comment_count(&self) -> usize {
117        self.parse.tree().comments().count()
118    }
119
120    /// Renders the concrete syntax tree without semantic changes.
121    #[must_use]
122    pub fn render_preserved(&self) -> String {
123        self.parse.tree().to_string()
124    }
125
126    pub(crate) fn yaml_file(&self) -> YamlFile {
127        self.parse.tree()
128    }
129
130    pub(crate) fn interpolatable_value_scalars(&self) -> Vec<ValueScalar> {
131        let mut values = Vec::new();
132        if let Some(document) = self.parse.tree().document() {
133            if let Some(mapping) = document.as_mapping() {
134                collect_value_scalars(self.source_id, YamlNode::Mapping(mapping), &mut values);
135            } else if let Some(sequence) = document.as_sequence() {
136                collect_value_scalars(self.source_id, YamlNode::Sequence(sequence), &mut values);
137            } else if let Some(scalar) = document.as_scalar() {
138                collect_value_scalars(self.source_id, YamlNode::Scalar(scalar), &mut values);
139            }
140        }
141        values
142    }
143
144    pub(crate) fn editable_value_scalars(&self) -> Vec<EditableValueScalar> {
145        let mut values = Vec::new();
146        if let Some(document) = self.parse.tree().document() {
147            if let Some(mapping) = document.as_mapping() {
148                collect_editable_value_scalars(self.source_id, YamlNode::Mapping(mapping), &mut values);
149            } else if let Some(sequence) = document.as_sequence() {
150                collect_editable_value_scalars(self.source_id, YamlNode::Sequence(sequence), &mut values);
151            } else if let Some(scalar) = document.as_scalar() {
152                collect_editable_value_scalars(self.source_id, YamlNode::Scalar(scalar), &mut values);
153            }
154        }
155        values
156    }
157
158    pub(crate) fn merge_root(&self) -> Option<MergeSyntaxValue> {
159        let document = self.parse.tree().document()?;
160        let root = if let Some(mapping) = document.as_mapping() {
161            YamlNode::Mapping(mapping)
162        } else if let Some(sequence) = document.as_sequence() {
163            YamlNode::Sequence(sequence)
164        } else {
165            YamlNode::Scalar(document.as_scalar()?)
166        };
167        let registry = AnchorRegistry::from_document(&document);
168        Some(extract_merge_value(
169            self.source_id,
170            &self.source,
171            root,
172            &registry,
173            &mut Vec::new(),
174        ))
175    }
176}
177
178#[derive(Debug, Clone, PartialEq, Eq)]
179pub(crate) struct ValueScalar {
180    pub(crate) value: String,
181    pub(crate) span: SourceSpan,
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
185pub(crate) struct EditableValueScalar {
186    pub(crate) raw: String,
187    pub(crate) span: SourceSpan,
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub(crate) enum MergeScalarKind {
192    String,
193    Boolean,
194    Number,
195    Null,
196}
197
198#[derive(Debug, Clone, PartialEq, Eq)]
199pub(crate) struct MergeSyntaxScalar {
200    pub(crate) raw: String,
201    pub(crate) value: String,
202    pub(crate) kind: MergeScalarKind,
203    pub(crate) span: SourceSpan,
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207pub(crate) struct MergeSyntaxEntry {
208    pub(crate) key: MergeSyntaxScalar,
209    pub(crate) value: MergeSyntaxValue,
210}
211
212#[derive(Debug, Clone, PartialEq, Eq)]
213pub(crate) enum MergeSyntaxValue {
214    Empty(SourceSpan),
215    Scalar(MergeSyntaxScalar),
216    Mapping {
217        entries: Vec<MergeSyntaxEntry>,
218        span: SourceSpan,
219    },
220    Sequence {
221        values: Vec<MergeSyntaxValue>,
222        span: SourceSpan,
223    },
224    Alias {
225        name: String,
226        span: SourceSpan,
227    },
228    Tagged {
229        tag: String,
230        value: Box<MergeSyntaxValue>,
231        span: SourceSpan,
232    },
233}
234
235/// The recoverable result of parsing one YAML source.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub struct SyntaxParse {
238    document: SyntaxDocument,
239    diagnostics: Vec<Diagnostic>,
240}
241
242impl SyntaxParse {
243    /// Returns the loss-aware syntax document.
244    #[must_use]
245    pub const fn document(&self) -> &SyntaxDocument {
246        &self.document
247    }
248
249    /// Returns all syntax diagnostics in source order.
250    #[must_use]
251    pub fn diagnostics(&self) -> &[Diagnostic] {
252        &self.diagnostics
253    }
254
255    /// Reports whether parsing produced no error diagnostics.
256    #[must_use]
257    pub fn is_valid(&self) -> bool {
258        !self
259            .diagnostics
260            .iter()
261            .any(|diagnostic| diagnostic.severity() == Severity::Error)
262    }
263
264    /// Separates the document and diagnostics.
265    #[must_use]
266    pub fn into_parts(self) -> (SyntaxDocument, Vec<Diagnostic>) {
267        (self.document, self.diagnostics)
268    }
269}
270
271/// A source that exceeds the concrete syntax tree's byte-offset capacity.
272#[derive(Debug, Clone, Copy, PartialEq, Eq)]
273pub struct SyntaxParseError {
274    source_id: SourceId,
275    byte_len: usize,
276}
277
278impl SyntaxParseError {
279    /// Returns the source identifier supplied to the parser.
280    #[must_use]
281    pub const fn source_id(self) -> SourceId {
282        self.source_id
283    }
284
285    /// Returns the rejected source length in bytes.
286    #[must_use]
287    pub const fn byte_len(self) -> usize {
288        self.byte_len
289    }
290}
291
292impl fmt::Display for SyntaxParseError {
293    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
294        write!(
295            formatter,
296            "{} contains {} bytes, exceeding the YAML syntax tree limit",
297            self.source_id, self.byte_len
298        )
299    }
300}
301
302impl Error for SyntaxParseError {}
303
304fn syntax_diagnostic(source_id: SourceId, source_len: usize, error: &yaml_edit::PositionedParseError) -> Diagnostic {
305    let (code, message) = match error.kind {
306        ParseErrorKind::UnclosedFlowSequence => (YAML_UNCLOSED_FLOW_SEQUENCE, "flow sequence is missing a closing `]`"),
307        ParseErrorKind::UnclosedFlowMapping => (YAML_UNCLOSED_FLOW_MAPPING, "flow mapping is missing a closing `}`"),
308        ParseErrorKind::UnterminatedString => (YAML_UNTERMINATED_STRING, "quoted scalar is missing its closing quote"),
309        ParseErrorKind::Other => (YAML_SYNTAX_ERROR, "invalid YAML syntax"),
310    };
311    let start = (error.range.start as usize).min(source_len);
312    let end = (error.range.end as usize).clamp(start, source_len);
313    let span = SourceSpan::from_valid_offsets(source_id, start, end);
314
315    Diagnostic::new(code, Severity::Error, message).with_label(DiagnosticLabel::primary(span, "syntax error"))
316}
317
318fn collect_value_scalars(source_id: SourceId, node: YamlNode, values: &mut Vec<ValueScalar>) {
319    match node {
320        YamlNode::Scalar(scalar) => collect_scalar(source_id, &scalar, values),
321        YamlNode::Mapping(mapping) => {
322            for value in mapping.entries().filter_map(|entry| entry.value_node()) {
323                collect_value_scalars(source_id, value, values);
324            }
325        }
326        YamlNode::Sequence(sequence) => {
327            for value in sequence.values() {
328                collect_value_scalars(source_id, value, values);
329            }
330        }
331        YamlNode::TaggedNode(tagged) => {
332            if let Some(node) = tagged
333                .as_node()
334                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
335            {
336                collect_value_scalars(source_id, node, values);
337            }
338        }
339        YamlNode::Alias(_) => {}
340    }
341}
342
343fn collect_editable_value_scalars(source_id: SourceId, node: YamlNode, values: &mut Vec<EditableValueScalar>) {
344    match node {
345        YamlNode::Scalar(scalar) => {
346            values.push(EditableValueScalar {
347                raw: scalar.value(),
348                span: position_span(source_id, scalar.byte_range()),
349            });
350        }
351        YamlNode::Mapping(mapping) => {
352            for value in mapping.entries().filter_map(|entry| entry.value_node()) {
353                collect_editable_value_scalars(source_id, value, values);
354            }
355        }
356        YamlNode::Sequence(sequence) => {
357            for value in sequence.values() {
358                collect_editable_value_scalars(source_id, value, values);
359            }
360        }
361        YamlNode::TaggedNode(tagged) => {
362            if let Some(node) = tagged
363                .as_node()
364                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
365            {
366                collect_editable_value_scalars(source_id, node, values);
367            }
368        }
369        YamlNode::Alias(_) => {}
370    }
371}
372
373fn extract_merge_value(
374    source_id: SourceId,
375    source: &str,
376    node: YamlNode,
377    registry: &AnchorRegistry,
378    aliases: &mut Vec<String>,
379) -> MergeSyntaxValue {
380    match node {
381        YamlNode::Scalar(scalar) => MergeSyntaxValue::Scalar(extract_merge_scalar(source_id, &scalar)),
382        YamlNode::Mapping(mapping) => extract_merge_mapping(source_id, source, &mapping, registry, aliases),
383        YamlNode::Sequence(sequence) => {
384            let span = position_span(source_id, sequence.byte_range());
385            let values = sequence
386                .values()
387                .map(|value| extract_merge_value(source_id, source, value, registry, aliases))
388                .collect();
389            MergeSyntaxValue::Sequence { values, span }
390        }
391        YamlNode::Alias(alias) => {
392            let name = alias.name();
393            let span = yaml_node_span(source_id, &YamlNode::Alias(alias.clone()));
394            if aliases.contains(&name) || aliases.len() >= 64 {
395                return MergeSyntaxValue::Alias { name, span };
396            }
397            if let Some(target) = registry.resolve(&name).and_then(|node| {
398                YamlNode::from_syntax(node.clone()).or_else(|| node.children().find_map(YamlNode::from_syntax))
399            }) {
400                aliases.push(name);
401                let value = extract_merge_value(source_id, source, target, registry, aliases);
402                let _ = aliases.pop();
403                value
404            } else {
405                MergeSyntaxValue::Alias { name, span }
406            }
407        }
408        YamlNode::TaggedNode(tagged) => {
409            let span = yaml_node_span(source_id, &YamlNode::TaggedNode(tagged.clone()));
410            let value = tagged
411                .as_node()
412                .and_then(|node| node.children().find_map(YamlNode::from_syntax))
413                .map_or_else(
414                    || MergeSyntaxValue::Empty(span),
415                    |value| extract_merge_value(source_id, source, value, registry, aliases),
416                );
417            MergeSyntaxValue::Tagged {
418                tag: tagged.tag().unwrap_or_default(),
419                value: Box::new(value),
420                span,
421            }
422        }
423    }
424}
425
426fn extract_merge_mapping(
427    source_id: SourceId,
428    source: &str,
429    mapping: &Mapping,
430    registry: &AnchorRegistry,
431    aliases: &mut Vec<String>,
432) -> MergeSyntaxValue {
433    let span = position_span(source_id, mapping.byte_range());
434    let direct = flatten_merge_fields(source_id, source, raw_merge_fields(source_id, mapping));
435    let mut entries = Vec::new();
436    let mut direct_keys = Vec::new();
437
438    for field in direct {
439        if field.key.value == "<<" {
440            continue;
441        }
442        direct_keys.push(field.key.value.clone());
443        let value = field.value.map_or_else(
444            || {
445                MergeSyntaxValue::Empty(SourceSpan::from_valid_offsets(
446                    source_id,
447                    field.key.span.end(),
448                    field.key.span.end(),
449                ))
450            },
451            |value| extract_merge_value(source_id, source, resolve_alias(value, registry), registry, aliases),
452        );
453        entries.push(MergeSyntaxEntry { key: field.key, value });
454    }
455
456    for (key, value) in mapping.merged(registry).iter() {
457        let Some(key) = key.as_scalar().map(|scalar| extract_merge_scalar(source_id, scalar)) else {
458            continue;
459        };
460        if direct_keys.contains(&key.value) {
461            continue;
462        }
463        entries.push(MergeSyntaxEntry {
464            key,
465            value: extract_merge_value(source_id, source, value, registry, aliases),
466        });
467    }
468
469    MergeSyntaxValue::Mapping { entries, span }
470}
471
472#[derive(Debug)]
473struct RawMergeField {
474    key: MergeSyntaxScalar,
475    value: Option<YamlNode>,
476}
477
478fn raw_merge_fields(source_id: SourceId, mapping: &Mapping) -> Vec<RawMergeField> {
479    mapping
480        .entries()
481        .filter_map(|entry| {
482            let key = entry.key_node()?.as_scalar().cloned()?;
483            Some(RawMergeField {
484                key: extract_merge_scalar(source_id, &key),
485                value: entry.value_node(),
486            })
487        })
488        .collect()
489}
490
491fn flatten_merge_fields(source_id: SourceId, source: &str, fields: Vec<RawMergeField>) -> Vec<RawMergeField> {
492    let Some(target_column) = fields
493        .first()
494        .map(|field| source_column(source, field.key.span.start()))
495    else {
496        return fields;
497    };
498    recover_merge_fields(source_id, source, fields, target_column)
499}
500
501fn recover_merge_fields(
502    source_id: SourceId,
503    source: &str,
504    fields: Vec<RawMergeField>,
505    target_column: usize,
506) -> Vec<RawMergeField> {
507    let mut flattened = Vec::new();
508    for mut field in fields {
509        let field_column = source_column(source, field.key.span.start());
510        let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
511        let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
512            !is_flow_mapping(source, mapping)
513                && mapping
514                    .entries()
515                    .find_map(|entry| entry.key_node()?.as_scalar().map(Scalar::byte_range))
516                    .is_some_and(|position| source_column(source, position.start as usize) <= field_column)
517        });
518        if continuation {
519            field.value = None;
520        }
521        if field_column == target_column {
522            flattened.push(field);
523        }
524        if let Some(mapping) = nested_mapping.filter(|mapping| !is_flow_mapping(source, mapping)) {
525            let nested = raw_merge_fields(source_id, &mapping);
526            flattened.extend(recover_merge_fields(source_id, source, nested, target_column));
527        }
528    }
529    flattened
530}
531
532fn is_flow_mapping(source: &str, mapping: &Mapping) -> bool {
533    let position = mapping.byte_range();
534    source
535        .get(position.start as usize..position.end as usize)
536        .is_some_and(|text| text.trim_start().starts_with('{'))
537}
538
539fn source_column(source: &str, offset: usize) -> usize {
540    let prefix = &source[..offset.min(source.len())];
541    let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
542    source[line_start..offset.min(source.len())].chars().count()
543}
544
545fn resolve_alias(node: YamlNode, registry: &AnchorRegistry) -> YamlNode {
546    let YamlNode::Alias(alias) = &node else {
547        return node;
548    };
549    registry
550        .resolve(&alias.name())
551        .and_then(|target| {
552            YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
553        })
554        .unwrap_or(node)
555}
556
557fn extract_merge_scalar(source_id: SourceId, scalar: &Scalar) -> MergeSyntaxScalar {
558    let kind = match ScalarValue::from_scalar(scalar).scalar_type() {
559        ScalarType::Boolean => MergeScalarKind::Boolean,
560        ScalarType::Integer | ScalarType::Float => MergeScalarKind::Number,
561        ScalarType::Null => MergeScalarKind::Null,
562        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MergeScalarKind::String,
563    };
564    MergeSyntaxScalar {
565        raw: scalar.value(),
566        value: scalar.as_string(),
567        kind,
568        span: position_span(source_id, scalar.byte_range()),
569    }
570}
571
572fn yaml_node_span(source_id: SourceId, node: &YamlNode) -> SourceSpan {
573    let Some(syntax) = node.as_node() else {
574        return SourceSpan::from_valid_offsets(source_id, 0, 0);
575    };
576    let range = syntax.text_range();
577    SourceSpan::from_valid_offsets(
578        source_id,
579        u32::from(range.start()) as usize,
580        u32::from(range.end()) as usize,
581    )
582}
583
584fn position_span(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
585    SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
586}
587
588fn collect_scalar(source_id: SourceId, scalar: &Scalar, values: &mut Vec<ValueScalar>) {
589    let raw = scalar.value();
590    let eligible_style = !raw.starts_with('\'') && !raw.starts_with('|') && !raw.starts_with('>');
591    if !eligible_style || !raw.contains('$') {
592        return;
593    }
594    let position = scalar.byte_range();
595    values.push(ValueScalar {
596        value: scalar.as_string(),
597        span: SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize),
598    });
599}
600
601#[cfg(test)]
602mod tests {
603    use super::SyntaxDocument;
604    use crate::source::SourceId;
605
606    fn assert_send_and_sync<T: Send + Sync>() {}
607
608    #[test]
609    fn syntax_documents_are_send_and_sync() {
610        assert_send_and_sync::<SyntaxDocument>();
611    }
612
613    #[test]
614    fn parsing_never_reads_the_process_environment() -> Result<(), Box<dyn std::error::Error>> {
615        let source = "services:\n  app:\n    image: ${COMPOSE_LENS_SECRET}\n";
616        let parsed = SyntaxDocument::parse(SourceId::new(1), source)?;
617
618        assert_eq!(parsed.document().render_preserved(), source);
619        assert!(parsed.is_valid());
620        Ok(())
621    }
622}