1use 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
14pub const YAML_SYNTAX_ERROR: DiagnosticCode = DiagnosticCode::new("compose.yaml.syntax");
16
17pub const YAML_UNPARSED_INPUT: DiagnosticCode = DiagnosticCode::new("compose.yaml.unparsed-input");
19
20pub const YAML_UNCLOSED_FLOW_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.yaml.unclosed-flow-sequence");
22
23pub const YAML_UNCLOSED_FLOW_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.yaml.unclosed-flow-mapping");
25
26pub const YAML_UNTERMINATED_STRING: DiagnosticCode = DiagnosticCode::new("compose.yaml.unterminated-string");
28
29#[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 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 #[must_use]
86 pub const fn source_id(&self) -> SourceId {
87 self.source_id
88 }
89
90 #[must_use]
92 pub fn source_text(&self) -> &str {
93 &self.source
94 }
95
96 #[must_use]
98 pub fn source_span(&self) -> SourceSpan {
99 SourceSpan::from_valid_offsets(self.source_id, 0, self.source.len())
100 }
101
102 #[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 #[must_use]
114 pub fn line_column(&self, byte_offset: usize) -> Option<LineColumn> {
115 line_column(&self.source, byte_offset)
116 }
117
118 #[must_use]
120 pub fn document_count(&self) -> usize {
121 self.parse.tree().documents().count()
122 }
123
124 #[must_use]
126 pub fn comment_count(&self) -> usize {
127 self.parse.tree().comments().count()
128 }
129
130 #[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 ®istry,
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#[derive(Debug, Clone, PartialEq, Eq)]
249pub struct SyntaxParse {
250 document: SyntaxDocument,
251 diagnostics: Vec<Diagnostic>,
252}
253
254impl SyntaxParse {
255 #[must_use]
257 pub const fn document(&self) -> &SyntaxDocument {
258 &self.document
259 }
260
261 #[must_use]
263 pub fn diagnostics(&self) -> &[Diagnostic] {
264 &self.diagnostics
265 }
266
267 #[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 #[must_use]
278 pub fn into_parts(self) -> (SyntaxDocument, Vec<Diagnostic>) {
279 (self.document, self.diagnostics)
280 }
281}
282
283#[derive(Debug, Clone, Copy, PartialEq, Eq)]
285pub struct SyntaxParseError {
286 source_id: SourceId,
287 byte_len: usize,
288}
289
290impl SyntaxParseError {
291 #[must_use]
293 pub const fn source_id(self) -> SourceId {
294 self.source_id
295 }
296
297 #[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
359fn 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 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 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 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 ¬-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}