Skip to main content

compose_lens/model/
mod.rs

1//! Source-aware native Compose document types.
2
3mod command;
4mod dependency;
5mod environment;
6mod host;
7mod identity;
8mod image;
9mod network;
10mod port;
11mod resource;
12mod restart;
13mod sections;
14mod ulimit;
15mod value;
16mod volume;
17
18pub use command::Command;
19pub use dependency::{
20    DependencyCondition, DependsOn, Healthcheck, HealthcheckDuration, HealthcheckRetries, HealthcheckTest,
21    HealthcheckTestKind, ServiceDependency,
22};
23pub use environment::{
24    Environment, EnvironmentFile, EnvironmentFileFormat, EnvironmentFileFormatKind, EnvironmentListEntry,
25    EnvironmentMapEntry, LongEnvironmentFile,
26};
27pub use host::{ExtraHostSeparator, ExtraHosts, HostAddress, HostAddressKind, LongExtraHost, ShortExtraHost};
28pub use identity::{IdentityComponent, UserNamespaceMode, UserNamespaceModeKind, UserSpec};
29pub use image::{ImageDigest, ImageReference};
30pub use network::{Ipam, IpamConfig, NetworkDefinition, ServiceNetwork, ServiceNetworks};
31pub use port::{LongPort, Port, ShortPort};
32pub use resource::{ConfigDefinition, ConfigGrant, LongGrant, SecretDefinition, SecretGrant, VolumeDefinition};
33pub use restart::{RestartPolicy, RestartPolicyKind};
34pub use sections::{
35    Build, BuildDefinition, BuildField, BuildFieldKind, DeployDefinition, DeployField, DeployFieldKind,
36};
37pub use ulimit::{LimitValue, Ulimit, UlimitRange, UlimitValue, Ulimits};
38pub use value::{BooleanValue, ComposeScalar, KeyValueEntry, Labels};
39pub use volume::{
40    BindOptions, ContainerPath, ContainerPathKind, LongVolumeMount, MountType, SelinuxRelabel, ShortVolumeMount,
41    VolumeMount, VolumeSyntax,
42};
43
44use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
45use crate::source::{SourceId, SourceSpan};
46use crate::syntax::{SyntaxDocument, scalar_string_from_source};
47use std::collections::{BTreeMap, BTreeSet};
48use yaml_edit::{AnchorRegistry, AsYaml, Mapping, ScalarType, ScalarValue, YamlNode};
49
50/// A Compose document root must be a mapping.
51pub const DOCUMENT_ROOT_TYPE: DiagnosticCode = DiagnosticCode::new("compose.document.expected-mapping");
52
53/// `ComposeLens` currently types the first document in a multi-document YAML stream.
54pub const MULTIPLE_DOCUMENTS: DiagnosticCode = DiagnosticCode::new("compose.document.multiple-documents");
55
56/// A mapping contains a duplicate field.
57pub const DUPLICATE_FIELD: DiagnosticCode = DiagnosticCode::new("compose.model.duplicate-field");
58
59/// A Compose value has to be a mapping at this location.
60pub const EXPECTED_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.model.expected-mapping");
61
62/// A Compose value has to be a sequence at this location.
63pub const EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.model.expected-sequence");
64
65/// A Compose value has to be a scalar at this location.
66pub const EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.model.expected-scalar");
67
68/// A Compose value has to be a boolean at this location.
69pub const EXPECTED_BOOLEAN: DiagnosticCode = DiagnosticCode::new("compose.model.expected-boolean");
70
71/// A field supports multiple Compose syntax forms, but the authored form is invalid here.
72pub const EXPECTED_FIELD_FORM: DiagnosticCode = DiagnosticCode::new("compose.model.expected-field-form");
73
74/// A service port is neither scalar short syntax nor mapping long syntax.
75pub const PORT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.port.expected-short-or-long");
76
77/// A long-syntax service port is missing `target`.
78pub const PORT_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.port.long.missing-target");
79
80/// A service config or secret grant is neither scalar short syntax nor mapping long syntax.
81pub const GRANT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.grant.expected-short-or-long");
82
83/// A long-syntax service config or secret grant is missing `source`.
84pub const GRANT_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.grant.long.missing-source");
85
86/// A top-level resource definition must be a mapping or an explicit null.
87pub const RESOURCE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.resource.expected-mapping-or-null");
88
89/// A service-volume item is neither short nor long syntax.
90pub const VOLUME_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.volume.expected-short-or-long");
91
92/// A long-syntax service volume is missing `type`.
93pub const VOLUME_MISSING_TYPE: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-type");
94
95/// A long-syntax service volume is missing `target`.
96pub const VOLUME_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-target");
97
98/// A long-syntax bind mount has an invalid `SELinux` value.
99pub const VOLUME_INVALID_SELINUX: DiagnosticCode = DiagnosticCode::new("compose.volume.bind.invalid-selinux");
100
101/// A short `extra_hosts` entry does not contain a hostname/address separator.
102pub const EXTRA_HOST_INVALID_ENTRY: DiagnosticCode = DiagnosticCode::new("compose.extra-hosts.invalid-entry");
103
104/// A service limit is neither unlimited, a non-negative integer, nor deferred.
105pub const ULIMIT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-value");
106
107/// A health-check list has no valid command-mode token.
108pub const HEALTHCHECK_INVALID_TEST: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-test");
109
110/// A health-check duration does not follow Compose duration syntax.
111pub const HEALTHCHECK_INVALID_DURATION: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-duration");
112
113/// A health-check retry count is not a non-negative integer or deferred expression.
114pub const HEALTHCHECK_INVALID_RETRIES: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-retries");
115
116/// A service-level restart policy is not one of the Compose-defined forms or an expression.
117pub const RESTART_INVALID_POLICY: DiagnosticCode = DiagnosticCode::new("compose.restart.invalid-policy");
118
119/// A service environment-file item is neither scalar short syntax nor mapping long syntax.
120pub const ENVIRONMENT_FILE_EXPECTED_FORM: DiagnosticCode =
121    DiagnosticCode::new("compose.environment-file.expected-short-or-long");
122
123/// A long-syntax service environment-file entry is missing `path`.
124pub const ENVIRONMENT_FILE_MISSING_PATH: DiagnosticCode =
125    DiagnosticCode::new("compose.environment-file.long.missing-path");
126
127/// A long-syntax service environment-file format is not defined by Compose.
128pub const ENVIRONMENT_FILE_INVALID_FORMAT: DiagnosticCode =
129    DiagnosticCode::new("compose.environment-file.invalid-format");
130
131/// A long dependency uses an unrecognized condition.
132pub const DEPENDENCY_INVALID_CONDITION: DiagnosticCode = DiagnosticCode::new("compose.dependencies.invalid-condition");
133
134/// A typed dependency names a service missing from the same document.
135pub const DEPENDENCY_MISSING_SERVICE: DiagnosticCode = DiagnosticCode::new("compose.dependencies.missing-service");
136
137/// A `service_healthy` dependency has no enabled health check.
138pub const DEPENDENCY_MISSING_HEALTHCHECK: DiagnosticCode =
139    DiagnosticCode::new("compose.dependencies.missing-healthcheck");
140
141/// A `service_healthy` dependency may rely on health metadata from its image.
142pub const DEPENDENCY_HEALTHCHECK_UNVERIFIED: DiagnosticCode =
143    DiagnosticCode::new("compose.dependencies.healthcheck-unverified");
144
145/// A typed value and the exact source span from which it was read.
146#[derive(Debug, Clone, PartialEq, Eq)]
147pub struct Located<T> {
148    value: T,
149    span: SourceSpan,
150}
151
152impl<T> Located<T> {
153    pub(crate) const fn new(value: T, span: SourceSpan) -> Self {
154        Self { value, span }
155    }
156
157    /// Returns the typed value.
158    #[must_use]
159    pub const fn value(&self) -> &T {
160        &self.value
161    }
162
163    /// Returns the value's source span.
164    #[must_use]
165    pub const fn span(&self) -> SourceSpan {
166        self.span
167    }
168
169    /// Removes the source wrapper and returns the typed value.
170    #[must_use]
171    pub fn into_value(self) -> T {
172        self.value
173    }
174}
175
176/// Source provenance for an extension or not-yet-typed field.
177///
178/// The loss-aware [`SyntaxDocument`] retains the actual value and spelling. This reference lets
179/// typed callers locate it without exposing the private YAML implementation.
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct FieldReference {
182    name: Located<String>,
183    span: SourceSpan,
184    value_span: Option<SourceSpan>,
185}
186
187impl FieldReference {
188    /// Returns the semantic field name and its source span.
189    #[must_use]
190    pub const fn name(&self) -> &Located<String> {
191        &self.name
192    }
193
194    /// Returns the span covering the key and value when both are available.
195    #[must_use]
196    pub const fn span(&self) -> SourceSpan {
197        self.span
198    }
199
200    /// Returns the value span when the YAML node exposes one.
201    #[must_use]
202    pub const fn value_span(&self) -> Option<SourceSpan> {
203        self.value_span
204    }
205}
206
207/// A source-aware typed Compose service.
208#[derive(Debug, Clone, PartialEq, Eq)]
209pub struct Service {
210    name: Located<String>,
211    span: SourceSpan,
212    container_name: Option<Located<String>>,
213    image: Option<Located<ImageReference>>,
214    command: Option<Command>,
215    environment: Option<Environment>,
216    environment_files: Vec<EnvironmentFile>,
217    labels: Option<Labels>,
218    extra_hosts: Option<ExtraHosts>,
219    user: Option<UserSpec>,
220    userns_mode: Option<UserNamespaceMode>,
221    group_add: Vec<Located<String>>,
222    working_dir: Option<Located<String>>,
223    read_only: Option<Located<BooleanValue>>,
224    restart: Option<RestartPolicy>,
225    ulimits: Option<Ulimits>,
226    depends_on: Option<DependsOn>,
227    healthcheck: Option<Healthcheck>,
228    build: Option<Build>,
229    deploy: Option<DeployDefinition>,
230    ports: Vec<Port>,
231    volumes: Vec<VolumeMount>,
232    networks: Option<ServiceNetworks>,
233    profiles: Vec<Located<String>>,
234    configs: Vec<ConfigGrant>,
235    secrets: Vec<SecretGrant>,
236    extension_fields: Vec<FieldReference>,
237    unknown_fields: Vec<FieldReference>,
238}
239
240impl Service {
241    fn new(name: Located<String>, span: SourceSpan) -> Self {
242        Self {
243            name,
244            span,
245            container_name: None,
246            image: None,
247            command: None,
248            environment: None,
249            environment_files: Vec::new(),
250            labels: None,
251            extra_hosts: None,
252            user: None,
253            userns_mode: None,
254            group_add: Vec::new(),
255            working_dir: None,
256            read_only: None,
257            restart: None,
258            ulimits: None,
259            depends_on: None,
260            healthcheck: None,
261            build: None,
262            deploy: None,
263            ports: Vec::new(),
264            volumes: Vec::new(),
265            networks: None,
266            profiles: Vec::new(),
267            configs: Vec::new(),
268            secrets: Vec::new(),
269            extension_fields: Vec::new(),
270            unknown_fields: Vec::new(),
271        }
272    }
273
274    /// Returns the service name.
275    #[must_use]
276    pub const fn name(&self) -> &Located<String> {
277        &self.name
278    }
279
280    /// Returns the complete service definition span.
281    #[must_use]
282    pub const fn span(&self) -> SourceSpan {
283        self.span
284    }
285
286    /// Returns the explicitly authored runtime container name.
287    #[must_use]
288    pub const fn container_name(&self) -> Option<&Located<String>> {
289        self.container_name.as_ref()
290    }
291
292    /// Returns the explicitly authored image reference.
293    #[must_use]
294    pub const fn image(&self) -> Option<&Located<ImageReference>> {
295        self.image.as_ref()
296    }
297
298    /// Returns the command without normalizing its authored form.
299    #[must_use]
300    pub const fn command(&self) -> Option<&Command> {
301        self.command.as_ref()
302    }
303
304    /// Returns environment variables with list and mapping forms kept distinct.
305    #[must_use]
306    pub const fn environment(&self) -> Option<&Environment> {
307        self.environment.as_ref()
308    }
309
310    /// Returns service environment files in authored order with syntax retained.
311    #[must_use]
312    pub fn environment_files(&self) -> &[EnvironmentFile] {
313        &self.environment_files
314    }
315
316    /// Returns service metadata labels with list and mapping forms kept distinct.
317    #[must_use]
318    pub const fn labels(&self) -> Option<&Labels> {
319        self.labels.as_ref()
320    }
321
322    /// Returns additional host mappings with short and long forms retained.
323    #[must_use]
324    pub const fn extra_hosts(&self) -> Option<&ExtraHosts> {
325        self.extra_hosts.as_ref()
326    }
327
328    /// Returns the raw-preserving container user/group value.
329    #[must_use]
330    pub const fn user(&self) -> Option<&UserSpec> {
331        self.user.as_ref()
332    }
333
334    /// Returns the raw-preserving user-namespace mode.
335    #[must_use]
336    pub const fn userns_mode(&self) -> Option<&UserNamespaceMode> {
337        self.userns_mode.as_ref()
338    }
339
340    /// Returns supplementary groups in authored order without resolving names or IDs.
341    #[must_use]
342    pub fn group_add(&self) -> &[Located<String>] {
343        &self.group_add
344    }
345
346    /// Returns the container working-directory override.
347    #[must_use]
348    pub const fn working_dir(&self) -> Option<&Located<String>> {
349        self.working_dir.as_ref()
350    }
351
352    /// Returns the explicit read-only root-filesystem choice.
353    #[must_use]
354    pub const fn read_only(&self) -> Option<&Located<BooleanValue>> {
355        self.read_only.as_ref()
356    }
357
358    /// Returns the service-level container restart policy.
359    #[must_use]
360    pub const fn restart(&self) -> Option<&RestartPolicy> {
361        self.restart.as_ref()
362    }
363
364    /// Returns explicitly authored service resource limits.
365    #[must_use]
366    pub const fn ulimits(&self) -> Option<&Ulimits> {
367        self.ulimits.as_ref()
368    }
369
370    /// Returns service dependencies with short and long forms retained.
371    #[must_use]
372    pub const fn depends_on(&self) -> Option<&DependsOn> {
373        self.depends_on.as_ref()
374    }
375
376    /// Returns the service health-check definition.
377    #[must_use]
378    pub const fn healthcheck(&self) -> Option<&Healthcheck> {
379        self.healthcheck.as_ref()
380    }
381
382    /// Returns the build declaration with short and long forms retained.
383    #[must_use]
384    pub const fn build(&self) -> Option<&Build> {
385        self.build.as_ref()
386    }
387
388    /// Returns independently classified deploy subfields.
389    #[must_use]
390    pub const fn deploy(&self) -> Option<&DeployDefinition> {
391        self.deploy.as_ref()
392    }
393
394    /// Returns published ports in authored order.
395    #[must_use]
396    pub fn ports(&self) -> &[Port] {
397        &self.ports
398    }
399
400    /// Returns service-volume mounts in authored order.
401    #[must_use]
402    pub fn volumes(&self) -> &[VolumeMount] {
403        &self.volumes
404    }
405
406    /// Returns service network attachments with short and long forms kept distinct.
407    #[must_use]
408    pub const fn networks(&self) -> Option<&ServiceNetworks> {
409        self.networks.as_ref()
410    }
411
412    /// Returns explicitly authored profile names.
413    #[must_use]
414    pub fn profiles(&self) -> &[Located<String>] {
415        &self.profiles
416    }
417
418    /// Returns service config grants in authored order.
419    #[must_use]
420    pub fn configs(&self) -> &[ConfigGrant] {
421        &self.configs
422    }
423
424    /// Returns service secret grants in authored order.
425    #[must_use]
426    pub fn secrets(&self) -> &[SecretGrant] {
427        &self.secrets
428    }
429
430    /// Returns retained service `x-` extension fields.
431    #[must_use]
432    pub fn extension_fields(&self) -> &[FieldReference] {
433        &self.extension_fields
434    }
435
436    /// Returns service fields not yet represented by the typed subset.
437    #[must_use]
438    pub fn unknown_fields(&self) -> &[FieldReference] {
439        &self.unknown_fields
440    }
441}
442
443/// A source-aware native Compose document.
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct ComposeDocument {
446    source_id: SourceId,
447    span: SourceSpan,
448    name: Option<Located<String>>,
449    services: Vec<Service>,
450    networks: Vec<NetworkDefinition>,
451    volumes: Vec<VolumeDefinition>,
452    configs: Vec<ConfigDefinition>,
453    secrets: Vec<SecretDefinition>,
454    extension_fields: Vec<FieldReference>,
455    unknown_fields: Vec<FieldReference>,
456}
457
458impl ComposeDocument {
459    /// Extracts the initial typed Compose subset from a loss-aware syntax document.
460    ///
461    /// Parsing does not interpolate values, apply defaults, normalize short and long forms, or
462    /// access the environment. Structural problems produce diagnostics and as much typed data as
463    /// can be recovered.
464    #[must_use]
465    pub fn parse(syntax: &SyntaxDocument) -> ModelParse {
466        Parser::new(syntax).parse()
467    }
468
469    /// Returns the source identifier.
470    #[must_use]
471    pub const fn source_id(&self) -> SourceId {
472        self.source_id
473    }
474
475    /// Returns the typed root mapping span.
476    #[must_use]
477    pub const fn span(&self) -> SourceSpan {
478        self.span
479    }
480
481    /// Returns the explicitly authored project name.
482    #[must_use]
483    pub const fn name(&self) -> Option<&Located<String>> {
484        self.name.as_ref()
485    }
486
487    /// Returns services in authored order.
488    #[must_use]
489    pub fn services(&self) -> &[Service] {
490        &self.services
491    }
492
493    /// Finds the first service with the requested name.
494    #[must_use]
495    pub fn service(&self, name: &str) -> Option<&Service> {
496        self.services.iter().find(|service| service.name.value == name)
497    }
498
499    /// Validates dependency targets and `service_healthy` health-check requirements in this document.
500    ///
501    /// Multi-file callers should validate the merged project view through
502    /// [`crate::resolution::validate_references`] instead.
503    #[must_use]
504    pub fn validate_dependencies(&self) -> Vec<Diagnostic> {
505        let mut diagnostics = Vec::new();
506        for service in &self.services {
507            let Some(depends_on) = service.depends_on() else {
508                continue;
509            };
510            match depends_on {
511                DependsOn::Short { services, .. } => {
512                    for target in services {
513                        if self.service(target.value()).is_none() {
514                            diagnostics.push(missing_dependency_diagnostic(target.span(), false, true));
515                        }
516                    }
517                }
518                DependsOn::Long { services, .. } => {
519                    for dependency in services {
520                        let required = !matches!(
521                            dependency.required().map(Located::value),
522                            Some(BooleanValue::Literal(false))
523                        );
524                        let Some(target) = self.service(dependency.service().value()) else {
525                            diagnostics.push(missing_dependency_diagnostic(
526                                dependency.service().span(),
527                                false,
528                                required,
529                            ));
530                            continue;
531                        };
532                        let needs_healthcheck = matches!(
533                            dependency.condition().map(Located::value),
534                            Some(DependencyCondition::ServiceHealthy)
535                        );
536                        if needs_healthcheck && target.healthcheck().is_none() {
537                            let span = dependency
538                                .condition()
539                                .map_or_else(|| dependency.service().span(), Located::span);
540                            diagnostics.push(unverified_healthcheck_diagnostic(span));
541                        } else if needs_healthcheck && target.healthcheck().is_some_and(Healthcheck::is_disabled) {
542                            let span = dependency
543                                .condition()
544                                .map_or_else(|| dependency.service().span(), Located::span);
545                            diagnostics.push(missing_dependency_diagnostic(span, true, required));
546                        }
547                    }
548                }
549            }
550        }
551        diagnostics
552    }
553
554    /// Returns top-level network definitions in authored order.
555    #[must_use]
556    pub fn networks(&self) -> &[NetworkDefinition] {
557        &self.networks
558    }
559
560    /// Returns top-level volume definitions in authored order.
561    #[must_use]
562    pub fn volumes(&self) -> &[VolumeDefinition] {
563        &self.volumes
564    }
565
566    /// Returns top-level config definitions in authored order.
567    #[must_use]
568    pub fn configs(&self) -> &[ConfigDefinition] {
569        &self.configs
570    }
571
572    /// Returns top-level secret definitions in authored order.
573    #[must_use]
574    pub fn secrets(&self) -> &[SecretDefinition] {
575        &self.secrets
576    }
577
578    /// Returns retained top-level `x-` extension fields.
579    #[must_use]
580    pub fn extension_fields(&self) -> &[FieldReference] {
581        &self.extension_fields
582    }
583
584    /// Returns top-level fields not yet represented by the typed subset.
585    #[must_use]
586    pub fn unknown_fields(&self) -> &[FieldReference] {
587        &self.unknown_fields
588    }
589}
590
591/// A recoverable typed-model parse result.
592#[derive(Debug, Clone, PartialEq, Eq)]
593pub struct ModelParse {
594    document: Option<ComposeDocument>,
595    diagnostics: Vec<Diagnostic>,
596}
597
598impl ModelParse {
599    /// Returns the typed document when the root could be interpreted.
600    #[must_use]
601    pub const fn document(&self) -> Option<&ComposeDocument> {
602        self.document.as_ref()
603    }
604
605    /// Returns structural typed-model diagnostics in source order.
606    #[must_use]
607    pub fn diagnostics(&self) -> &[Diagnostic] {
608        &self.diagnostics
609    }
610
611    /// Reports whether no error diagnostics were emitted.
612    #[must_use]
613    pub fn is_valid(&self) -> bool {
614        !self
615            .diagnostics
616            .iter()
617            .any(|diagnostic| diagnostic.severity() == Severity::Error)
618    }
619
620    /// Separates the recovered document and diagnostics.
621    #[must_use]
622    pub fn into_parts(self) -> (Option<ComposeDocument>, Vec<Diagnostic>) {
623        (self.document, self.diagnostics)
624    }
625}
626
627fn missing_dependency_diagnostic(span: SourceSpan, healthcheck: bool, required: bool) -> Diagnostic {
628    let severity = if required { Severity::Error } else { Severity::Warning };
629    if healthcheck {
630        Diagnostic::new(
631            DEPENDENCY_MISSING_HEALTHCHECK,
632            severity,
633            if required {
634                "service_healthy dependency requires an enabled health check"
635            } else {
636                "optional service_healthy dependency has no enabled health check"
637            },
638        )
639        .with_label(DiagnosticLabel::primary(span, "dependency cannot become healthy"))
640    } else {
641        Diagnostic::new(
642            DEPENDENCY_MISSING_SERVICE,
643            severity,
644            if required {
645                "service dependency is not declared in this Compose document"
646            } else {
647                "optional service dependency is not declared in this Compose document"
648            },
649        )
650        .with_label(DiagnosticLabel::primary(span, "missing dependency service"))
651    }
652}
653
654fn unverified_healthcheck_diagnostic(span: SourceSpan) -> Diagnostic {
655    Diagnostic::new(
656        DEPENDENCY_HEALTHCHECK_UNVERIFIED,
657        Severity::Warning,
658        "service_healthy dependency has no Compose healthcheck to validate",
659    )
660    .with_label(DiagnosticLabel::primary(span, "image health metadata is not available"))
661    .with_note("the dependency image may still define a health check; verify it at build or runtime")
662}
663
664#[derive(Debug)]
665struct Parser {
666    source_id: SourceId,
667    source_span: SourceSpan,
668    source: String,
669    tree: yaml_edit::YamlFile,
670    anchors: AnchorRegistry,
671    diagnostics: Vec<Diagnostic>,
672}
673
674impl Parser {
675    fn new(syntax: &SyntaxDocument) -> Self {
676        let tree = syntax.yaml_file();
677        let anchors = tree
678            .document()
679            .map_or_else(AnchorRegistry::new, |document| AnchorRegistry::from_document(&document));
680        Self {
681            source_id: syntax.source_id(),
682            source_span: syntax.source_span(),
683            source: syntax.source_text().to_owned(),
684            tree,
685            anchors,
686            diagnostics: Vec::new(),
687        }
688    }
689
690    fn parse(mut self) -> ModelParse {
691        if self.tree.documents().count() > 1 {
692            self.diagnostics.push(
693                Diagnostic::new(
694                    MULTIPLE_DOCUMENTS,
695                    Severity::Error,
696                    "Compose input must contain one YAML document",
697                )
698                .with_label(DiagnosticLabel::primary(self.source_span, "multiple YAML documents")),
699            );
700        }
701
702        let Some(root) = self.tree.document() else {
703            self.diagnostics.push(
704                Diagnostic::new(
705                    DOCUMENT_ROOT_TYPE,
706                    Severity::Error,
707                    "Compose document root must be a mapping",
708                )
709                .with_label(DiagnosticLabel::primary(self.source_span, "empty document")),
710            );
711            return ModelParse {
712                document: None,
713                diagnostics: self.diagnostics,
714            };
715        };
716        let root_span = span_from_position(self.source_id, root.byte_range());
717        let Some(mapping) = root.as_mapping() else {
718            self.diagnostics.push(
719                Diagnostic::new(
720                    DOCUMENT_ROOT_TYPE,
721                    Severity::Error,
722                    "Compose document root must be a mapping",
723                )
724                .with_label(DiagnosticLabel::primary(root_span, "not a mapping")),
725            );
726            return ModelParse {
727                document: None,
728                diagnostics: self.diagnostics,
729            };
730        };
731
732        let document = self.parse_root(&mapping, root_span);
733        ModelParse {
734            document: Some(document),
735            diagnostics: self.diagnostics,
736        }
737    }
738
739    fn parse_root(&mut self, mapping: &Mapping, span: SourceSpan) -> ComposeDocument {
740        let mut document = ComposeDocument {
741            source_id: self.source_id,
742            span,
743            name: None,
744            services: Vec::new(),
745            networks: Vec::new(),
746            volumes: Vec::new(),
747            configs: Vec::new(),
748            secrets: Vec::new(),
749            extension_fields: Vec::new(),
750            unknown_fields: Vec::new(),
751        };
752        let mut seen = BTreeMap::new();
753
754        for field in self.fields(mapping) {
755            let duplicate = self.record_duplicate(&mut seen, &field);
756            match field.name.value.as_str() {
757                "name" if !duplicate => {
758                    document.name = self.parse_string(&field, "project name");
759                }
760                "services" if !duplicate => {
761                    document.services = self.parse_services(&field);
762                }
763                "networks" if !duplicate => {
764                    document.networks = self.parse_network_definitions(&field);
765                }
766                "volumes" if !duplicate => {
767                    document.volumes = self.parse_volume_definitions(&field);
768                }
769                "configs" if !duplicate => {
770                    document.configs = self.parse_config_definitions(&field);
771                }
772                "secrets" if !duplicate => {
773                    document.secrets = self.parse_secret_definitions(&field);
774                }
775                name if name.starts_with("x-") => {
776                    document.extension_fields.push(field.reference());
777                }
778                _ if duplicate => {}
779                _ => document.unknown_fields.push(field.reference()),
780            }
781        }
782        document
783    }
784
785    fn parse_services(&mut self, field: &ParsedField) -> Vec<Service> {
786        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
787            self.expected(EXPECTED_MAPPING, field, "services must be a mapping");
788            return Vec::new();
789        };
790        let mut services = Vec::new();
791        let mut seen = BTreeMap::new();
792        for service_field in self.fields(mapping) {
793            self.record_duplicate(&mut seen, &service_field);
794            let Some(service_mapping) = service_field.value.as_ref().and_then(YamlNode::as_mapping) else {
795                self.expected(EXPECTED_MAPPING, &service_field, "service definition must be a mapping");
796                continue;
797            };
798            services.push(self.parse_service(&service_field, service_mapping));
799        }
800        services
801    }
802
803    fn parse_service(&mut self, field: &ParsedField, mapping: &Mapping) -> Service {
804        let mut service = Service::new(field.name.clone(), field.span);
805        let mut seen = BTreeMap::new();
806        for service_field in self.fields(mapping) {
807            let duplicate = self.record_duplicate(&mut seen, &service_field);
808            match service_field.name.value.as_str() {
809                "container_name" if !duplicate => {
810                    service.container_name = self.parse_string(&service_field, "service container name");
811                }
812                "image" if !duplicate => {
813                    service.image = self
814                        .parse_string(&service_field, "service image")
815                        .map(|value| Located::new(ImageReference::parse(value.value), value.span));
816                }
817                "command" if !duplicate => {
818                    service.command = self.parse_command(&service_field);
819                }
820                "environment" if !duplicate => {
821                    service.environment = self.parse_environment(&service_field);
822                }
823                "env_file" if !duplicate => {
824                    service.environment_files = self.parse_environment_files(&service_field);
825                }
826                "labels" if !duplicate => {
827                    service.labels = self.parse_labels(&service_field);
828                }
829                "extra_hosts" if !duplicate => {
830                    service.extra_hosts = self.parse_extra_hosts(&service_field);
831                }
832                "user" if !duplicate => {
833                    service.user = self.parse_string(&service_field, "service user").map(UserSpec::parse);
834                }
835                "userns_mode" if !duplicate => {
836                    service.userns_mode = self
837                        .parse_string(&service_field, "service user namespace mode")
838                        .map(UserNamespaceMode::parse);
839                }
840                "group_add" if !duplicate => {
841                    service.group_add = self.parse_string_sequence(&service_field, "service supplementary groups");
842                }
843                "working_dir" if !duplicate => {
844                    service.working_dir = self.parse_string(&service_field, "service working directory");
845                }
846                "read_only" if !duplicate => {
847                    service.read_only = self.parse_boolean(&service_field, "service read_only");
848                }
849                "restart" if !duplicate => {
850                    service.restart = self.parse_restart_policy(&service_field);
851                }
852                "ulimits" if !duplicate => {
853                    service.ulimits = self.parse_ulimits(&service_field);
854                }
855                "depends_on" if !duplicate => {
856                    service.depends_on = self.parse_depends_on(&service_field);
857                }
858                "healthcheck" if !duplicate => {
859                    service.healthcheck = self.parse_healthcheck(&service_field);
860                }
861                "build" if !duplicate => {
862                    service.build = self.parse_build(&service_field);
863                }
864                "deploy" if !duplicate => {
865                    service.deploy = self.parse_deploy(&service_field);
866                }
867                "ports" if !duplicate => {
868                    service.ports = self.parse_service_ports(&service_field);
869                }
870                "volumes" if !duplicate => {
871                    service.volumes = self.parse_service_volumes(&service_field);
872                }
873                "networks" if !duplicate => {
874                    service.networks = self.parse_service_networks(&service_field);
875                }
876                "profiles" if !duplicate => {
877                    service.profiles = self.parse_string_sequence(&service_field, "service profiles");
878                }
879                "configs" if !duplicate => {
880                    service.configs = self.parse_config_grants(&service_field);
881                }
882                "secrets" if !duplicate => {
883                    service.secrets = self.parse_secret_grants(&service_field);
884                }
885                name if name.starts_with("x-") => {
886                    service.extension_fields.push(service_field.reference());
887                }
888                _ if duplicate => {}
889                _ => service.unknown_fields.push(service_field.reference()),
890            }
891        }
892        service
893    }
894
895    fn parse_restart_policy(&mut self, field: &ParsedField) -> Option<RestartPolicy> {
896        let value = self.parse_string(field, "service restart policy")?;
897        let policy = RestartPolicy::parse(value);
898        if !policy.is_valid() {
899            self.diagnostics.push(
900                Diagnostic::new(
901                    RESTART_INVALID_POLICY,
902                    Severity::Error,
903                    "restart must be `no`, `always`, `on-failure[:max-retries]`, `unless-stopped`, or interpolation",
904                )
905                .with_label(DiagnosticLabel::primary(
906                    policy.raw().span(),
907                    "invalid service restart policy",
908                )),
909            );
910        }
911        Some(policy)
912    }
913
914    fn parse_command(&mut self, field: &ParsedField) -> Option<Command> {
915        match field.value.as_ref() {
916            Some(YamlNode::Scalar(scalar)) => {
917                let span = span_from_position(self.source_id, scalar.byte_range());
918                if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
919                    Some(Command::Null(span))
920                } else {
921                    Some(Command::String(Located::new(
922                        scalar_string_from_source(&self.source, scalar),
923                        span,
924                    )))
925                }
926            }
927            Some(YamlNode::Sequence(sequence)) => {
928                let span = span_from_position(self.source_id, sequence.byte_range());
929                let values =
930                    self.parse_scalar_nodes(sequence.values(), field.span, "command list items must be scalars");
931                Some(Command::List { span, values })
932            }
933            _ => {
934                self.expected(
935                    EXPECTED_FIELD_FORM,
936                    field,
937                    "command must be null, a scalar, or a sequence",
938                );
939                None
940            }
941        }
942    }
943
944    fn parse_environment(&mut self, field: &ParsedField) -> Option<Environment> {
945        match field.value.as_ref() {
946            Some(YamlNode::Sequence(sequence)) => {
947                let span = span_from_position(self.source_id, sequence.byte_range());
948                let entries = self
949                    .parse_scalar_nodes(sequence.values(), field.span, "environment list items must be scalars")
950                    .into_iter()
951                    .map(EnvironmentListEntry::parse)
952                    .collect();
953                Some(Environment::List { span, entries })
954            }
955            Some(YamlNode::Mapping(mapping)) => {
956                let span = span_from_position(self.source_id, mapping.byte_range());
957                let entries = self.parse_environment_map(mapping);
958                Some(Environment::Map { span, entries })
959            }
960            _ => {
961                self.expected(EXPECTED_FIELD_FORM, field, "environment must be a sequence or mapping");
962                None
963            }
964        }
965    }
966
967    fn parse_environment_map(&mut self, mapping: &Mapping) -> Vec<EnvironmentMapEntry> {
968        let mut entries = Vec::new();
969        let mut seen = BTreeMap::new();
970        for field in self.fields(mapping) {
971            if self.record_duplicate(&mut seen, &field) {
972                continue;
973            }
974            let value = self.parse_compose_scalar(&field, "environment values must be scalars");
975            if let Some(value) = value {
976                entries.push(EnvironmentMapEntry::new(field.name, value, field.span));
977            }
978        }
979        entries
980    }
981
982    fn parse_environment_files(&mut self, field: &ParsedField) -> Vec<EnvironmentFile> {
983        match field.value.as_ref() {
984            Some(YamlNode::Scalar(_)) => self
985                .parse_string(field, "service environment-file path")
986                .map(EnvironmentFile::Short)
987                .into_iter()
988                .collect(),
989            Some(YamlNode::Sequence(sequence)) => sequence
990                .values()
991                .filter_map(|value| match value {
992                    YamlNode::Scalar(scalar) => {
993                        let span = span_from_position(self.source_id, scalar.byte_range());
994                        Some(EnvironmentFile::Short(Located::new(
995                            scalar_string_from_source(&self.source, &scalar),
996                            span,
997                        )))
998                    }
999                    YamlNode::Mapping(mapping) => Some(EnvironmentFile::Long(Box::new(
1000                        self.parse_long_environment_file(&mapping),
1001                    ))),
1002                    _ => {
1003                        self.diagnostics.push(
1004                            Diagnostic::new(
1005                                ENVIRONMENT_FILE_EXPECTED_FORM,
1006                                Severity::Error,
1007                                "env_file item must use scalar short syntax or mapping long syntax",
1008                            )
1009                            .with_label(DiagnosticLabel::primary(
1010                                node_span(self.source_id, &value).unwrap_or(field.span),
1011                                "invalid environment-file item",
1012                            )),
1013                        );
1014                        None
1015                    }
1016                })
1017                .collect(),
1018            _ => {
1019                self.expected(
1020                    EXPECTED_FIELD_FORM,
1021                    field,
1022                    "env_file must be a scalar path or a sequence of short/long entries",
1023                );
1024                Vec::new()
1025            }
1026        }
1027    }
1028
1029    fn parse_long_environment_file(&mut self, mapping: &Mapping) -> LongEnvironmentFile {
1030        let span = span_from_position(self.source_id, mapping.byte_range());
1031        let mut environment_file = LongEnvironmentFile::new(span);
1032        let mut seen = BTreeMap::new();
1033        for field in self.fields(mapping) {
1034            let duplicate = self.record_duplicate(&mut seen, &field);
1035            match field.name.value.as_str() {
1036                "path" if !duplicate => self
1037                    .parse_string(&field, "environment-file path")
1038                    .into_iter()
1039                    .for_each(|value| environment_file.set_path(value)),
1040                "required" if !duplicate => self
1041                    .parse_boolean(&field, "environment-file required option")
1042                    .into_iter()
1043                    .for_each(|value| environment_file.set_required(value)),
1044                "format" if !duplicate => {
1045                    if let Some(raw) = self.parse_string(&field, "environment-file format") {
1046                        let format = EnvironmentFileFormat::parse(raw);
1047                        if !format.is_valid() {
1048                            self.diagnostics.push(
1049                                Diagnostic::new(
1050                                    ENVIRONMENT_FILE_INVALID_FORMAT,
1051                                    Severity::Error,
1052                                    "environment-file format must be `raw` or interpolation",
1053                                )
1054                                .with_label(DiagnosticLabel::primary(format.raw().span(), "invalid format")),
1055                            );
1056                        }
1057                        environment_file.set_format(format);
1058                    }
1059                }
1060                name if name.starts_with("x-") => environment_file.push_extension(field.reference()),
1061                _ if duplicate => {}
1062                _ => environment_file.push_unknown(field.reference()),
1063            }
1064        }
1065        if environment_file.path().is_none() {
1066            self.missing(
1067                ENVIRONMENT_FILE_MISSING_PATH,
1068                span,
1069                "long environment-file entry is missing `path`",
1070            );
1071        }
1072        environment_file
1073    }
1074
1075    fn parse_extra_hosts(&mut self, field: &ParsedField) -> Option<ExtraHosts> {
1076        match field.value.as_ref() {
1077            Some(YamlNode::Sequence(sequence)) => {
1078                let span = span_from_position(self.source_id, sequence.byte_range());
1079                let entries = self
1080                    .parse_scalar_nodes(sequence.values(), field.span, "extra_hosts entries must be scalars")
1081                    .into_iter()
1082                    .map(|raw| {
1083                        let entry = ShortExtraHost::parse(raw);
1084                        if !entry.is_complete() {
1085                            self.diagnostics.push(
1086                                Diagnostic::new(
1087                                    EXTRA_HOST_INVALID_ENTRY,
1088                                    Severity::Error,
1089                                    "short extra_hosts entry must contain a hostname and address",
1090                                )
1091                                .with_label(DiagnosticLabel::primary(
1092                                    entry.raw().span(),
1093                                    "missing separator or value",
1094                                )),
1095                            );
1096                        }
1097                        entry
1098                    })
1099                    .collect();
1100                Some(ExtraHosts::Short { span, entries })
1101            }
1102            Some(YamlNode::Mapping(mapping)) => {
1103                let span = span_from_position(self.source_id, mapping.byte_range());
1104                let mut entries = Vec::new();
1105                let mut seen = BTreeMap::new();
1106                for host in self.fields(mapping) {
1107                    if self.record_duplicate(&mut seen, &host) {
1108                        continue;
1109                    }
1110                    if let Some(address) = self.parse_string(&host, "extra host address") {
1111                        let address = Located::new(HostAddress::parse(address.value), address.span);
1112                        entries.push(LongExtraHost::new(host.name, address, host.span));
1113                    }
1114                }
1115                Some(ExtraHosts::Long { span, entries })
1116            }
1117            _ => {
1118                self.expected(EXPECTED_FIELD_FORM, field, "extra_hosts must be a sequence or mapping");
1119                None
1120            }
1121        }
1122    }
1123
1124    fn parse_ulimits(&mut self, field: &ParsedField) -> Option<Ulimits> {
1125        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1126            self.expected(EXPECTED_MAPPING, field, "ulimits must be a mapping");
1127            return None;
1128        };
1129        let span = span_from_position(self.source_id, mapping.byte_range());
1130        let mut entries = Vec::new();
1131        let mut seen = BTreeMap::new();
1132        for limit in self.fields(mapping) {
1133            if self.record_duplicate(&mut seen, &limit) {
1134                continue;
1135            }
1136            let value = match limit.value.as_ref() {
1137                Some(YamlNode::Scalar(_)) => self.parse_limit_value(&limit, "ulimit value").map(UlimitValue::Single),
1138                Some(YamlNode::Mapping(range)) => Some(UlimitValue::Range(self.parse_ulimit_range(range))),
1139                _ => {
1140                    self.expected(
1141                        EXPECTED_FIELD_FORM,
1142                        &limit,
1143                        "ulimit must be a scalar or soft/hard mapping",
1144                    );
1145                    None
1146                }
1147            };
1148            if let Some(value) = value {
1149                entries.push(Ulimit::new(limit.name, limit.span, value));
1150            }
1151        }
1152        Some(Ulimits::new(span, entries))
1153    }
1154
1155    fn parse_ulimit_range(&mut self, mapping: &Mapping) -> UlimitRange {
1156        let span = span_from_position(self.source_id, mapping.byte_range());
1157        let mut range = UlimitRange::new(span);
1158        let mut seen = BTreeMap::new();
1159        for field in self.fields(mapping) {
1160            let duplicate = self.record_duplicate(&mut seen, &field);
1161            match field.name.value.as_str() {
1162                "soft" if !duplicate => self
1163                    .parse_limit_value(&field, "ulimit soft value")
1164                    .into_iter()
1165                    .for_each(|value| range.set_soft(value)),
1166                "hard" if !duplicate => self
1167                    .parse_limit_value(&field, "ulimit hard value")
1168                    .into_iter()
1169                    .for_each(|value| range.set_hard(value)),
1170                name if name.starts_with("x-") => range.push_extension(field.reference()),
1171                _ if duplicate => {}
1172                _ => range.push_unknown(field.reference()),
1173            }
1174        }
1175        range
1176    }
1177
1178    fn parse_limit_value(&mut self, field: &ParsedField, description: &str) -> Option<Located<LimitValue>> {
1179        let value = self.parse_string(field, description)?;
1180        let parsed = LimitValue::parse(value.value);
1181        if !parsed.is_valid() {
1182            self.diagnostics.push(
1183                Diagnostic::new(
1184                    ULIMIT_INVALID_VALUE,
1185                    Severity::Error,
1186                    "ulimit must be -1, a non-negative integer, or an interpolation expression",
1187                )
1188                .with_label(DiagnosticLabel::primary(value.span, "invalid ulimit value")),
1189            );
1190        }
1191        Some(Located::new(parsed, value.span))
1192    }
1193
1194    fn parse_depends_on(&mut self, field: &ParsedField) -> Option<DependsOn> {
1195        match field.value.as_ref() {
1196            Some(YamlNode::Sequence(sequence)) => {
1197                let span = span_from_position(self.source_id, sequence.byte_range());
1198                let services = self.parse_scalar_nodes(
1199                    sequence.values(),
1200                    field.span,
1201                    "dependency service names must be scalars",
1202                );
1203                Some(DependsOn::Short { span, services })
1204            }
1205            Some(YamlNode::Mapping(mapping)) => {
1206                let span = span_from_position(self.source_id, mapping.byte_range());
1207                let mut services = Vec::new();
1208                let mut seen = BTreeMap::new();
1209                for dependency in self.fields(mapping) {
1210                    if self.record_duplicate(&mut seen, &dependency) {
1211                        continue;
1212                    }
1213                    let mut parsed = ServiceDependency::new(dependency.name.clone(), dependency.span);
1214                    if Self::field_is_null(&dependency) {
1215                        services.push(parsed);
1216                        continue;
1217                    }
1218                    let Some(options) = dependency.value.as_ref().and_then(YamlNode::as_mapping) else {
1219                        self.expected(
1220                            EXPECTED_MAPPING,
1221                            &dependency,
1222                            "long dependency options must be a mapping or null",
1223                        );
1224                        continue;
1225                    };
1226                    let mut option_seen = BTreeMap::new();
1227                    for option in self.fields(options) {
1228                        let duplicate = self.record_duplicate(&mut option_seen, &option);
1229                        match option.name.value.as_str() {
1230                            "condition" if !duplicate => {
1231                                if let Some(value) = self.parse_string(&option, "dependency condition") {
1232                                    let condition = DependencyCondition::parse(value.value);
1233                                    if !condition.is_known() {
1234                                        self.diagnostics.push(
1235                                            Diagnostic::new(
1236                                                DEPENDENCY_INVALID_CONDITION,
1237                                                Severity::Error,
1238                                                "dependency condition is not defined by Compose",
1239                                            )
1240                                            .with_label(
1241                                                DiagnosticLabel::primary(value.span, "unknown dependency condition"),
1242                                            ),
1243                                        );
1244                                    }
1245                                    parsed.set_condition(Located::new(condition, value.span));
1246                                }
1247                            }
1248                            "restart" if !duplicate => self
1249                                .parse_boolean(&option, "dependency restart")
1250                                .into_iter()
1251                                .for_each(|value| parsed.set_restart(value)),
1252                            "required" if !duplicate => self
1253                                .parse_boolean(&option, "dependency required")
1254                                .into_iter()
1255                                .for_each(|value| parsed.set_required(value)),
1256                            name if name.starts_with("x-") => parsed.push_extension(option.reference()),
1257                            _ if duplicate => {}
1258                            _ => parsed.push_unknown(option.reference()),
1259                        }
1260                    }
1261                    services.push(parsed);
1262                }
1263                Some(DependsOn::Long { span, services })
1264            }
1265            _ => {
1266                self.expected(EXPECTED_FIELD_FORM, field, "depends_on must be a sequence or mapping");
1267                None
1268            }
1269        }
1270    }
1271
1272    fn parse_healthcheck(&mut self, field: &ParsedField) -> Option<Healthcheck> {
1273        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1274            self.expected(EXPECTED_MAPPING, field, "healthcheck must be a mapping");
1275            return None;
1276        };
1277        let span = span_from_position(self.source_id, mapping.byte_range());
1278        let mut healthcheck = Healthcheck::new(span);
1279        let mut seen = BTreeMap::new();
1280        for option in self.fields(mapping) {
1281            let duplicate = self.record_duplicate(&mut seen, &option);
1282            match option.name.value.as_str() {
1283                "test" if !duplicate => self
1284                    .parse_healthcheck_test(&option)
1285                    .into_iter()
1286                    .for_each(|value| healthcheck.set_test(value)),
1287                "interval" if !duplicate => self
1288                    .parse_healthcheck_duration(&option, "healthcheck interval")
1289                    .into_iter()
1290                    .for_each(|value| healthcheck.set_interval(value)),
1291                "timeout" if !duplicate => self
1292                    .parse_healthcheck_duration(&option, "healthcheck timeout")
1293                    .into_iter()
1294                    .for_each(|value| healthcheck.set_timeout(value)),
1295                "retries" if !duplicate => self
1296                    .parse_healthcheck_retries(&option)
1297                    .into_iter()
1298                    .for_each(|value| healthcheck.set_retries(value)),
1299                "start_period" if !duplicate => self
1300                    .parse_healthcheck_duration(&option, "healthcheck start period")
1301                    .into_iter()
1302                    .for_each(|value| healthcheck.set_start_period(value)),
1303                "start_interval" if !duplicate => self
1304                    .parse_healthcheck_duration(&option, "healthcheck start interval")
1305                    .into_iter()
1306                    .for_each(|value| healthcheck.set_start_interval(value)),
1307                "disable" if !duplicate => self
1308                    .parse_boolean(&option, "healthcheck disable")
1309                    .into_iter()
1310                    .for_each(|value| healthcheck.set_disable(value)),
1311                name if name.starts_with("x-") => healthcheck.push_extension(option.reference()),
1312                _ if duplicate => {}
1313                _ => healthcheck.push_unknown(option.reference()),
1314            }
1315        }
1316        Some(healthcheck)
1317    }
1318
1319    fn parse_healthcheck_duration(
1320        &mut self,
1321        field: &ParsedField,
1322        description: &str,
1323    ) -> Option<Located<HealthcheckDuration>> {
1324        let value = self.parse_string(field, description)?;
1325        let duration = HealthcheckDuration::parse(value.value);
1326        if !duration.is_valid() {
1327            self.diagnostics.push(
1328                Diagnostic::new(
1329                    HEALTHCHECK_INVALID_DURATION,
1330                    Severity::Error,
1331                    "healthcheck duration must use Compose duration syntax or interpolation",
1332                )
1333                .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck duration")),
1334            );
1335        }
1336        Some(Located::new(duration, value.span))
1337    }
1338
1339    fn parse_healthcheck_retries(&mut self, field: &ParsedField) -> Option<Located<HealthcheckRetries>> {
1340        let value = self.parse_string(field, "healthcheck retries")?;
1341        let retries = HealthcheckRetries::parse(value.value);
1342        if !retries.is_valid() {
1343            self.diagnostics.push(
1344                Diagnostic::new(
1345                    HEALTHCHECK_INVALID_RETRIES,
1346                    Severity::Error,
1347                    "healthcheck retries must be a non-negative integer or interpolation expression",
1348                )
1349                .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck retry count")),
1350            );
1351        }
1352        Some(Located::new(retries, value.span))
1353    }
1354
1355    fn parse_healthcheck_test(&mut self, field: &ParsedField) -> Option<HealthcheckTest> {
1356        match field.value.as_ref() {
1357            Some(YamlNode::Scalar(_)) => self
1358                .parse_string(field, "healthcheck test")
1359                .map(HealthcheckTest::String),
1360            Some(YamlNode::Sequence(sequence)) => {
1361                let span = span_from_position(self.source_id, sequence.byte_range());
1362                let values =
1363                    self.parse_scalar_nodes(sequence.values(), field.span, "healthcheck test items must be scalars");
1364                let kind = values.first().map(|value| HealthcheckTestKind::parse(value.value()));
1365                if kind.is_none()
1366                    || kind == Some(HealthcheckTestKind::Other)
1367                    || (kind == Some(HealthcheckTestKind::None) && values.len() != 1)
1368                {
1369                    self.diagnostics.push(
1370                        Diagnostic::new(
1371                            HEALTHCHECK_INVALID_TEST,
1372                            Severity::Error,
1373                            "healthcheck list must begin with NONE, CMD, or CMD-SHELL",
1374                        )
1375                        .with_label(DiagnosticLabel::primary(span, "invalid healthcheck command mode")),
1376                    );
1377                }
1378                Some(HealthcheckTest::List { span, kind, values })
1379            }
1380            _ => {
1381                self.expected(
1382                    EXPECTED_FIELD_FORM,
1383                    field,
1384                    "healthcheck test must be a scalar or sequence",
1385                );
1386                None
1387            }
1388        }
1389    }
1390
1391    fn parse_build(&mut self, field: &ParsedField) -> Option<Build> {
1392        match field.value.as_ref() {
1393            Some(YamlNode::Scalar(_)) => self.parse_string(field, "build context").map(Build::Context),
1394            Some(YamlNode::Mapping(mapping)) => {
1395                let span = span_from_position(self.source_id, mapping.byte_range());
1396                let mut definition = BuildDefinition::new(span);
1397                let mut seen = BTreeMap::new();
1398                for option in self.fields(mapping) {
1399                    let duplicate = self.record_duplicate(&mut seen, &option);
1400                    if duplicate {
1401                        continue;
1402                    }
1403                    if let Some(kind) = BuildFieldKind::from_name(option.name.value()) {
1404                        definition.push_field(BuildField::new(kind, option.reference()));
1405                    } else if option.name.value().starts_with("x-") {
1406                        definition.push_extension(option.reference());
1407                    } else {
1408                        definition.push_unknown(option.reference());
1409                    }
1410                }
1411                Some(Build::Definition(definition))
1412            }
1413            _ => {
1414                self.expected(EXPECTED_FIELD_FORM, field, "build must be a scalar context or mapping");
1415                None
1416            }
1417        }
1418    }
1419
1420    fn parse_deploy(&mut self, field: &ParsedField) -> Option<DeployDefinition> {
1421        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1422            self.expected(EXPECTED_MAPPING, field, "deploy must be a mapping");
1423            return None;
1424        };
1425        let span = span_from_position(self.source_id, mapping.byte_range());
1426        let mut definition = DeployDefinition::new(span);
1427        let mut seen = BTreeMap::new();
1428        for option in self.fields(mapping) {
1429            let duplicate = self.record_duplicate(&mut seen, &option);
1430            if duplicate {
1431                continue;
1432            }
1433            if let Some(kind) = DeployFieldKind::from_name(option.name.value()) {
1434                definition.push_field(DeployField::new(kind, option.reference()));
1435            } else if option.name.value().starts_with("x-") {
1436                definition.push_extension(option.reference());
1437            } else {
1438                definition.push_unknown(option.reference());
1439            }
1440        }
1441        Some(definition)
1442    }
1443
1444    fn source_column(&self, offset: usize) -> usize {
1445        let prefix = self.source.get(..offset).unwrap_or_default();
1446        let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
1447        self.source[line_start..offset].chars().count()
1448    }
1449
1450    fn parse_service_ports(&mut self, field: &ParsedField) -> Vec<Port> {
1451        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1452            self.expected(EXPECTED_SEQUENCE, field, "service ports must be a sequence");
1453            return Vec::new();
1454        };
1455
1456        let mut ports = Vec::new();
1457        for value in sequence.values() {
1458            match value {
1459                YamlNode::Scalar(scalar) => {
1460                    let span = span_from_position(self.source_id, scalar.byte_range());
1461                    ports.push(Port::Short(ShortPort::parse(Located::new(
1462                        scalar_string_from_source(&self.source, &scalar),
1463                        span,
1464                    ))));
1465                }
1466                YamlNode::Mapping(mapping) => {
1467                    ports.push(Port::Long(Box::new(self.parse_long_port(&mapping))));
1468                }
1469                other => self.unsupported_sequence_item(
1470                    PORT_EXPECTED_FORM,
1471                    &other,
1472                    field.span,
1473                    "service port must use scalar short syntax or mapping long syntax",
1474                ),
1475            }
1476        }
1477        ports
1478    }
1479
1480    fn parse_long_port(&mut self, mapping: &Mapping) -> LongPort {
1481        let span = span_from_position(self.source_id, mapping.byte_range());
1482        let mut port = LongPort::new(span);
1483        let mut seen = BTreeMap::new();
1484        for field in self.fields(mapping) {
1485            let duplicate = self.record_duplicate(&mut seen, &field);
1486            match field.name.value.as_str() {
1487                "target" if !duplicate => self
1488                    .parse_string(&field, "port target")
1489                    .into_iter()
1490                    .for_each(|value| port.set_target(value)),
1491                "published" if !duplicate => self
1492                    .parse_string(&field, "published port")
1493                    .into_iter()
1494                    .for_each(|value| port.set_published(value)),
1495                "host_ip" if !duplicate => self
1496                    .parse_string(&field, "port host IP")
1497                    .into_iter()
1498                    .for_each(|value| port.set_host_ip(value)),
1499                "protocol" if !duplicate => self
1500                    .parse_string(&field, "port protocol")
1501                    .into_iter()
1502                    .for_each(|value| port.set_protocol(value)),
1503                "app_protocol" if !duplicate => self
1504                    .parse_string(&field, "port application protocol")
1505                    .into_iter()
1506                    .for_each(|value| port.set_app_protocol(value)),
1507                "mode" if !duplicate => self
1508                    .parse_string(&field, "port mode")
1509                    .into_iter()
1510                    .for_each(|value| port.set_mode(value)),
1511                "name" if !duplicate => self
1512                    .parse_string(&field, "port name")
1513                    .into_iter()
1514                    .for_each(|value| port.set_name(value)),
1515                name if name.starts_with("x-") => port.push_extension(field.reference()),
1516                _ if duplicate => {}
1517                _ => port.push_unknown(field.reference()),
1518            }
1519        }
1520        if port.target().is_none() {
1521            self.missing(PORT_MISSING_TARGET, span, "long port is missing `target`");
1522        }
1523        port
1524    }
1525
1526    fn parse_service_networks(&mut self, field: &ParsedField) -> Option<ServiceNetworks> {
1527        match field.value.as_ref() {
1528            Some(YamlNode::Sequence(sequence)) => {
1529                let span = span_from_position(self.source_id, sequence.byte_range());
1530                let names =
1531                    self.parse_scalar_nodes(sequence.values(), field.span, "service network names must be scalars");
1532                Some(ServiceNetworks::Short { span, names })
1533            }
1534            Some(YamlNode::Mapping(mapping)) => {
1535                let span = span_from_position(self.source_id, mapping.byte_range());
1536                let networks = self.parse_service_network_map(mapping);
1537                Some(ServiceNetworks::Long { span, networks })
1538            }
1539            _ => {
1540                self.expected(
1541                    EXPECTED_FIELD_FORM,
1542                    field,
1543                    "service networks must be a sequence or mapping",
1544                );
1545                None
1546            }
1547        }
1548    }
1549
1550    fn parse_service_network_map(&mut self, mapping: &Mapping) -> Vec<ServiceNetwork> {
1551        let mut networks = Vec::new();
1552        let mut seen = BTreeMap::new();
1553        for field in self.fields(mapping) {
1554            if self.record_duplicate(&mut seen, &field) {
1555                continue;
1556            }
1557            if Self::field_is_null(&field) {
1558                networks.push(ServiceNetwork::new(field.name, field.span));
1559                continue;
1560            }
1561            let Some(options) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1562                self.expected(
1563                    EXPECTED_MAPPING,
1564                    &field,
1565                    "service network options must be a mapping or null",
1566                );
1567                continue;
1568            };
1569            networks.push(self.parse_service_network(&field, options));
1570        }
1571        networks
1572    }
1573
1574    fn parse_service_network(&mut self, field: &ParsedField, mapping: &Mapping) -> ServiceNetwork {
1575        let mut network = ServiceNetwork::new(field.name.clone(), field.span);
1576        let mut seen = BTreeMap::new();
1577        for option in self.fields(mapping) {
1578            let duplicate = self.record_duplicate(&mut seen, &option);
1579            match option.name.value.as_str() {
1580                "aliases" if !duplicate => network.set_aliases(self.parse_string_sequence(&option, "network aliases")),
1581                "interface_name" if !duplicate => self
1582                    .parse_string(&option, "network interface name")
1583                    .into_iter()
1584                    .for_each(|value| network.set_interface_name(value)),
1585                "ipv4_address" if !duplicate => self
1586                    .parse_string(&option, "network IPv4 address")
1587                    .into_iter()
1588                    .for_each(|value| network.set_ipv4_address(value)),
1589                "ipv6_address" if !duplicate => self
1590                    .parse_string(&option, "network IPv6 address")
1591                    .into_iter()
1592                    .for_each(|value| network.set_ipv6_address(value)),
1593                "link_local_ips" if !duplicate => {
1594                    network.set_link_local_ips(self.parse_string_sequence(&option, "link-local IP addresses"));
1595                }
1596                "mac_address" if !duplicate => self
1597                    .parse_string(&option, "network MAC address")
1598                    .into_iter()
1599                    .for_each(|value| network.set_mac_address(value)),
1600                "driver_opts" if !duplicate => {
1601                    network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
1602                }
1603                "gw_priority" if !duplicate => self
1604                    .parse_string(&option, "network gateway priority")
1605                    .into_iter()
1606                    .for_each(|value| network.set_gw_priority(value)),
1607                "priority" if !duplicate => self
1608                    .parse_string(&option, "network priority")
1609                    .into_iter()
1610                    .for_each(|value| network.set_priority(value)),
1611                name if name.starts_with("x-") => network.push_extension(option.reference()),
1612                _ if duplicate => {}
1613                _ => network.push_unknown(option.reference()),
1614            }
1615        }
1616        network
1617    }
1618
1619    fn parse_config_grants(&mut self, field: &ParsedField) -> Vec<ConfigGrant> {
1620        self.parse_grants(field)
1621            .into_iter()
1622            .map(|grant| match grant {
1623                ParsedGrant::Short(value) => ConfigGrant::Short(value),
1624                ParsedGrant::Long(value) => ConfigGrant::Long(value),
1625            })
1626            .collect()
1627    }
1628
1629    fn parse_secret_grants(&mut self, field: &ParsedField) -> Vec<SecretGrant> {
1630        self.parse_grants(field)
1631            .into_iter()
1632            .map(|grant| match grant {
1633                ParsedGrant::Short(value) => SecretGrant::Short(value),
1634                ParsedGrant::Long(value) => SecretGrant::Long(value),
1635            })
1636            .collect()
1637    }
1638
1639    fn parse_grants(&mut self, field: &ParsedField) -> Vec<ParsedGrant> {
1640        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1641            self.expected(EXPECTED_SEQUENCE, field, "service grants must be a sequence");
1642            return Vec::new();
1643        };
1644        let mut grants = Vec::new();
1645        for value in sequence.values() {
1646            match value {
1647                YamlNode::Scalar(scalar) => {
1648                    let span = span_from_position(self.source_id, scalar.byte_range());
1649                    grants.push(ParsedGrant::Short(Located::new(
1650                        scalar_string_from_source(&self.source, &scalar),
1651                        span,
1652                    )));
1653                }
1654                YamlNode::Mapping(mapping) => {
1655                    grants.push(ParsedGrant::Long(Box::new(self.parse_long_grant(&mapping))));
1656                }
1657                other => self.unsupported_sequence_item(
1658                    GRANT_EXPECTED_FORM,
1659                    &other,
1660                    field.span,
1661                    "grant must use scalar short syntax or mapping long syntax",
1662                ),
1663            }
1664        }
1665        grants
1666    }
1667
1668    fn parse_long_grant(&mut self, mapping: &Mapping) -> LongGrant {
1669        let span = span_from_position(self.source_id, mapping.byte_range());
1670        let mut grant = LongGrant::new(span);
1671        let mut seen = BTreeMap::new();
1672        for field in self.fields(mapping) {
1673            let duplicate = self.record_duplicate(&mut seen, &field);
1674            match field.name.value.as_str() {
1675                "source" if !duplicate => self
1676                    .parse_string(&field, "grant source")
1677                    .into_iter()
1678                    .for_each(|value| grant.set_source(value)),
1679                "target" if !duplicate => self
1680                    .parse_string(&field, "grant target")
1681                    .into_iter()
1682                    .for_each(|value| grant.set_target(value)),
1683                "uid" if !duplicate => self
1684                    .parse_string(&field, "grant user ID")
1685                    .into_iter()
1686                    .for_each(|value| grant.set_uid(value)),
1687                "gid" if !duplicate => self
1688                    .parse_string(&field, "grant group ID")
1689                    .into_iter()
1690                    .for_each(|value| grant.set_gid(value)),
1691                "mode" if !duplicate => self
1692                    .parse_string(&field, "grant mode")
1693                    .into_iter()
1694                    .for_each(|value| grant.set_mode(value)),
1695                name if name.starts_with("x-") => grant.push_extension(field.reference()),
1696                _ if duplicate => {}
1697                _ => grant.push_unknown(field.reference()),
1698            }
1699        }
1700        if grant.source().is_none() {
1701            self.missing(GRANT_MISSING_SOURCE, span, "long grant is missing `source`");
1702        }
1703        grant
1704    }
1705
1706    fn parse_service_volumes(&mut self, field: &ParsedField) -> Vec<VolumeMount> {
1707        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1708            self.expected(EXPECTED_SEQUENCE, field, "service volumes must be a sequence");
1709            return Vec::new();
1710        };
1711
1712        sequence
1713            .values()
1714            .filter_map(|value| match value {
1715                YamlNode::Scalar(scalar) => {
1716                    let span = span_from_position(self.source_id, scalar.byte_range());
1717                    let raw = Located::new(scalar_string_from_source(&self.source, &scalar), span);
1718                    Some(VolumeMount::Short(ShortVolumeMount::new(raw)))
1719                }
1720                YamlNode::Mapping(mapping) => Some(VolumeMount::Long(Box::new(self.parse_long_volume(&mapping)))),
1721                other => {
1722                    let span = node_span(self.source_id, &other).unwrap_or(field.span);
1723                    self.diagnostics.push(
1724                        Diagnostic::new(
1725                            VOLUME_EXPECTED_FORM,
1726                            Severity::Error,
1727                            "service volume must use scalar short syntax or mapping long syntax",
1728                        )
1729                        .with_label(DiagnosticLabel::primary(span, "unsupported volume form")),
1730                    );
1731                    None
1732                }
1733            })
1734            .collect()
1735    }
1736
1737    fn parse_long_volume(&mut self, mapping: &Mapping) -> LongVolumeMount {
1738        let span = span_from_position(self.source_id, mapping.byte_range());
1739        let mut mount = LongVolumeMount::new(span);
1740        let mut seen = BTreeMap::new();
1741        for field in self.fields(mapping) {
1742            let duplicate = self.record_duplicate(&mut seen, &field);
1743            match field.name.value.as_str() {
1744                "type" if !duplicate => {
1745                    if let Some(value) = self.parse_string(&field, "volume type") {
1746                        mount.set_mount_type(Located::new(MountType::from_text(value.value), value.span));
1747                    }
1748                }
1749                "source" if !duplicate => {
1750                    if let Some(value) = self.parse_string(&field, "volume source") {
1751                        mount.set_source(value);
1752                    }
1753                }
1754                "target" if !duplicate => {
1755                    if let Some(value) = self.parse_string(&field, "volume target") {
1756                        mount.set_target(value);
1757                    }
1758                }
1759                "read_only" if !duplicate => {
1760                    if let Some(value) = self.parse_boolean(&field, "read_only") {
1761                        mount.set_read_only(value);
1762                    }
1763                }
1764                "bind" if !duplicate => {
1765                    if let Some(value) = self.parse_bind_options(&field) {
1766                        mount.set_bind(value);
1767                    }
1768                }
1769                name if name.starts_with("x-") => mount.push_extension(field.reference()),
1770                _ if duplicate => {}
1771                _ => mount.push_unknown(field.reference()),
1772            }
1773        }
1774
1775        if mount.mount_type().is_none() {
1776            self.missing(VOLUME_MISSING_TYPE, span, "long volume is missing `type`");
1777        }
1778        if mount.target().is_none() {
1779            self.missing(VOLUME_MISSING_TARGET, span, "long volume is missing `target`");
1780        }
1781        mount
1782    }
1783
1784    fn parse_bind_options(&mut self, field: &ParsedField) -> Option<BindOptions> {
1785        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1786            self.expected(EXPECTED_MAPPING, field, "bind options must be a mapping");
1787            return None;
1788        };
1789        let span = span_from_position(self.source_id, mapping.byte_range());
1790        let mut bind = BindOptions::new(span);
1791        let mut seen = BTreeMap::new();
1792        for bind_field in self.fields(mapping) {
1793            let duplicate = self.record_duplicate(&mut seen, &bind_field);
1794            match bind_field.name.value.as_str() {
1795                "propagation" if !duplicate => {
1796                    if let Some(value) = self.parse_string(&bind_field, "bind propagation") {
1797                        bind.set_propagation(value);
1798                    }
1799                }
1800                "create_host_path" if !duplicate => {
1801                    if let Some(value) = self.parse_boolean(&bind_field, "create_host_path") {
1802                        bind.set_create_host_path(value);
1803                    }
1804                }
1805                "selinux" if !duplicate => {
1806                    if let Some(value) = self.parse_string(&bind_field, "SELinux relabel mode") {
1807                        let mode = match value.value.as_str() {
1808                            "z" => Some(SelinuxRelabel::Shared),
1809                            "Z" => Some(SelinuxRelabel::Private),
1810                            _ => None,
1811                        };
1812                        if let Some(mode) = mode {
1813                            bind.set_selinux(Located::new(mode, value.span));
1814                        } else {
1815                            self.diagnostics.push(
1816                                Diagnostic::new(
1817                                    VOLUME_INVALID_SELINUX,
1818                                    Severity::Error,
1819                                    "SELinux relabel mode must be `z` or `Z`",
1820                                )
1821                                .with_label(DiagnosticLabel::primary(value.span, "invalid SELinux mode")),
1822                            );
1823                        }
1824                    }
1825                }
1826                name if name.starts_with("x-") => bind.push_extension(bind_field.reference()),
1827                _ if duplicate => {}
1828                _ => bind.push_unknown(bind_field.reference()),
1829            }
1830        }
1831        Some(bind)
1832    }
1833
1834    fn parse_network_definitions(&mut self, field: &ParsedField) -> Vec<NetworkDefinition> {
1835        let Some(mapping) = self.resource_collection(field, "networks") else {
1836            return Vec::new();
1837        };
1838        let mut definitions = Vec::new();
1839        let mut seen = BTreeMap::new();
1840        for resource in self.fields(&mapping) {
1841            if self.record_duplicate(&mut seen, &resource) {
1842                continue;
1843            }
1844            if Self::field_is_null(&resource) {
1845                definitions.push(NetworkDefinition::new(resource.name, resource.span));
1846                continue;
1847            }
1848            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
1849                self.expected(
1850                    RESOURCE_EXPECTED_FORM,
1851                    &resource,
1852                    "network definition must be a mapping or null",
1853                );
1854                continue;
1855            };
1856            definitions.push(self.parse_network_definition(&resource, definition));
1857        }
1858        definitions
1859    }
1860
1861    fn parse_network_definition(&mut self, field: &ParsedField, mapping: &Mapping) -> NetworkDefinition {
1862        let mut network = NetworkDefinition::new(field.name.clone(), field.span);
1863        let mut seen = BTreeMap::new();
1864        for option in self.fields(mapping) {
1865            let duplicate = self.record_duplicate(&mut seen, &option);
1866            match option.name.value.as_str() {
1867                "driver" if !duplicate => self
1868                    .parse_string(&option, "network driver")
1869                    .into_iter()
1870                    .for_each(|value| network.set_driver(value)),
1871                "driver_opts" if !duplicate => {
1872                    network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
1873                }
1874                "attachable" if !duplicate => self
1875                    .parse_boolean(&option, "network attachable")
1876                    .into_iter()
1877                    .for_each(|value| network.set_attachable(value)),
1878                "enable_ipv4" if !duplicate => self
1879                    .parse_boolean(&option, "network enable_ipv4")
1880                    .into_iter()
1881                    .for_each(|value| network.set_enable_ipv4(value)),
1882                "enable_ipv6" if !duplicate => self
1883                    .parse_boolean(&option, "network enable_ipv6")
1884                    .into_iter()
1885                    .for_each(|value| network.set_enable_ipv6(value)),
1886                "external" if !duplicate => self
1887                    .parse_boolean(&option, "network external")
1888                    .into_iter()
1889                    .for_each(|value| network.set_external(value)),
1890                "internal" if !duplicate => self
1891                    .parse_boolean(&option, "network internal")
1892                    .into_iter()
1893                    .for_each(|value| network.set_internal(value)),
1894                "ipam" if !duplicate => self
1895                    .parse_ipam(&option)
1896                    .into_iter()
1897                    .for_each(|value| network.set_ipam(value)),
1898                "labels" if !duplicate => self
1899                    .parse_labels(&option)
1900                    .into_iter()
1901                    .for_each(|value| network.set_labels(value)),
1902                "name" if !duplicate => self
1903                    .parse_string(&option, "network custom name")
1904                    .into_iter()
1905                    .for_each(|value| network.set_custom_name(value)),
1906                name if name.starts_with("x-") => network.push_extension(option.reference()),
1907                _ if duplicate => {}
1908                _ => network.push_unknown(option.reference()),
1909            }
1910        }
1911        network
1912    }
1913
1914    fn parse_ipam(&mut self, field: &ParsedField) -> Option<Ipam> {
1915        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1916            self.expected(EXPECTED_MAPPING, field, "network IPAM must be a mapping");
1917            return None;
1918        };
1919        let span = span_from_position(self.source_id, mapping.byte_range());
1920        let mut ipam = Ipam::new(span);
1921        let mut seen = BTreeMap::new();
1922        for option in self.fields(mapping) {
1923            let duplicate = self.record_duplicate(&mut seen, &option);
1924            match option.name.value.as_str() {
1925                "driver" if !duplicate => self
1926                    .parse_string(&option, "IPAM driver")
1927                    .into_iter()
1928                    .for_each(|value| ipam.set_driver(value)),
1929                "config" if !duplicate => ipam.set_config(self.parse_ipam_configs(&option)),
1930                "options" if !duplicate => {
1931                    ipam.set_options(self.parse_scalar_mapping(&option, "IPAM options"));
1932                }
1933                name if name.starts_with("x-") => ipam.push_extension(option.reference()),
1934                _ if duplicate => {}
1935                _ => ipam.push_unknown(option.reference()),
1936            }
1937        }
1938        Some(ipam)
1939    }
1940
1941    fn parse_ipam_configs(&mut self, field: &ParsedField) -> Vec<IpamConfig> {
1942        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1943            self.expected(EXPECTED_SEQUENCE, field, "IPAM config must be a sequence");
1944            return Vec::new();
1945        };
1946        let mut configs = Vec::new();
1947        for value in sequence.values() {
1948            let YamlNode::Mapping(mapping) = value else {
1949                self.unsupported_sequence_item(
1950                    EXPECTED_MAPPING,
1951                    &value,
1952                    field.span,
1953                    "IPAM config entries must be mappings",
1954                );
1955                continue;
1956            };
1957            configs.push(self.parse_ipam_config(&mapping));
1958        }
1959        configs
1960    }
1961
1962    fn parse_ipam_config(&mut self, mapping: &Mapping) -> IpamConfig {
1963        let span = span_from_position(self.source_id, mapping.byte_range());
1964        let mut config = IpamConfig::new(span);
1965        let mut seen = BTreeMap::new();
1966        for field in self.fields(mapping) {
1967            let duplicate = self.record_duplicate(&mut seen, &field);
1968            match field.name.value.as_str() {
1969                "subnet" if !duplicate => self
1970                    .parse_string(&field, "IPAM subnet")
1971                    .into_iter()
1972                    .for_each(|value| config.set_subnet(value)),
1973                "ip_range" if !duplicate => self
1974                    .parse_string(&field, "IPAM allocation range")
1975                    .into_iter()
1976                    .for_each(|value| config.set_ip_range(value)),
1977                "gateway" if !duplicate => self
1978                    .parse_string(&field, "IPAM gateway")
1979                    .into_iter()
1980                    .for_each(|value| config.set_gateway(value)),
1981                "aux_addresses" if !duplicate => {
1982                    config.set_aux_addresses(self.parse_scalar_mapping(&field, "IPAM auxiliary addresses"));
1983                }
1984                name if name.starts_with("x-") => config.push_extension(field.reference()),
1985                _ if duplicate => {}
1986                _ => config.push_unknown(field.reference()),
1987            }
1988        }
1989        config
1990    }
1991
1992    fn parse_volume_definitions(&mut self, field: &ParsedField) -> Vec<VolumeDefinition> {
1993        let Some(mapping) = self.resource_collection(field, "volumes") else {
1994            return Vec::new();
1995        };
1996        let mut definitions = Vec::new();
1997        let mut seen = BTreeMap::new();
1998        for resource in self.fields(&mapping) {
1999            if self.record_duplicate(&mut seen, &resource) {
2000                continue;
2001            }
2002            let mut volume = VolumeDefinition::new(resource.name.clone(), resource.span);
2003            if Self::field_is_null(&resource) {
2004                definitions.push(volume);
2005                continue;
2006            }
2007            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
2008                self.expected(
2009                    RESOURCE_EXPECTED_FORM,
2010                    &resource,
2011                    "volume definition must be a mapping or null",
2012                );
2013                continue;
2014            };
2015            let mut nested_seen = BTreeMap::new();
2016            for option in self.fields(definition) {
2017                let duplicate = self.record_duplicate(&mut nested_seen, &option);
2018                match option.name.value.as_str() {
2019                    "driver" if !duplicate => self
2020                        .parse_string(&option, "volume driver")
2021                        .into_iter()
2022                        .for_each(|value| volume.set_driver(value)),
2023                    "driver_opts" if !duplicate => {
2024                        volume.set_driver_opts(self.parse_scalar_mapping(&option, "volume driver options"));
2025                    }
2026                    "external" if !duplicate => self
2027                        .parse_boolean(&option, "volume external")
2028                        .into_iter()
2029                        .for_each(|value| volume.set_external(value)),
2030                    "labels" if !duplicate => self
2031                        .parse_labels(&option)
2032                        .into_iter()
2033                        .for_each(|value| volume.set_labels(value)),
2034                    "name" if !duplicate => self
2035                        .parse_string(&option, "volume custom name")
2036                        .into_iter()
2037                        .for_each(|value| volume.set_custom_name(value)),
2038                    name if name.starts_with("x-") => volume.push_extension(option.reference()),
2039                    _ if duplicate => {}
2040                    _ => volume.push_unknown(option.reference()),
2041                }
2042            }
2043            definitions.push(volume);
2044        }
2045        definitions
2046    }
2047
2048    fn parse_config_definitions(&mut self, field: &ParsedField) -> Vec<ConfigDefinition> {
2049        let Some(mapping) = self.resource_collection(field, "configs") else {
2050            return Vec::new();
2051        };
2052        let mut definitions = Vec::new();
2053        let mut seen = BTreeMap::new();
2054        for resource in self.fields(&mapping) {
2055            if self.record_duplicate(&mut seen, &resource) {
2056                continue;
2057            }
2058            let mut config = ConfigDefinition::new(resource.name.clone(), resource.span);
2059            if Self::field_is_null(&resource) {
2060                definitions.push(config);
2061                continue;
2062            }
2063            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
2064                self.expected(
2065                    RESOURCE_EXPECTED_FORM,
2066                    &resource,
2067                    "config definition must be a mapping or null",
2068                );
2069                continue;
2070            };
2071            let mut nested_seen = BTreeMap::new();
2072            for option in self.fields(definition) {
2073                let duplicate = self.record_duplicate(&mut nested_seen, &option);
2074                match option.name.value.as_str() {
2075                    "file" if !duplicate => self
2076                        .parse_string(&option, "config file")
2077                        .into_iter()
2078                        .for_each(|value| config.set_file(value)),
2079                    "environment" if !duplicate => self
2080                        .parse_string(&option, "config environment source")
2081                        .into_iter()
2082                        .for_each(|value| config.set_environment(value)),
2083                    "content" if !duplicate => self
2084                        .parse_string(&option, "config content")
2085                        .into_iter()
2086                        .for_each(|value| config.set_content(value)),
2087                    "external" if !duplicate => self
2088                        .parse_boolean(&option, "config external")
2089                        .into_iter()
2090                        .for_each(|value| config.set_external(value)),
2091                    "name" if !duplicate => self
2092                        .parse_string(&option, "config custom name")
2093                        .into_iter()
2094                        .for_each(|value| config.set_custom_name(value)),
2095                    name if name.starts_with("x-") => config.push_extension(option.reference()),
2096                    _ if duplicate => {}
2097                    _ => config.push_unknown(option.reference()),
2098                }
2099            }
2100            definitions.push(config);
2101        }
2102        definitions
2103    }
2104
2105    fn parse_secret_definitions(&mut self, field: &ParsedField) -> Vec<SecretDefinition> {
2106        let Some(mapping) = self.resource_collection(field, "secrets") else {
2107            return Vec::new();
2108        };
2109        let mut definitions = Vec::new();
2110        let mut seen = BTreeMap::new();
2111        for resource in self.fields(&mapping) {
2112            if self.record_duplicate(&mut seen, &resource) {
2113                continue;
2114            }
2115            let mut secret = SecretDefinition::new(resource.name.clone(), resource.span);
2116            if Self::field_is_null(&resource) {
2117                definitions.push(secret);
2118                continue;
2119            }
2120            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
2121                self.expected(
2122                    RESOURCE_EXPECTED_FORM,
2123                    &resource,
2124                    "secret definition must be a mapping or null",
2125                );
2126                continue;
2127            };
2128            let mut nested_seen = BTreeMap::new();
2129            for option in self.fields(definition) {
2130                let duplicate = self.record_duplicate(&mut nested_seen, &option);
2131                match option.name.value.as_str() {
2132                    "file" if !duplicate => self
2133                        .parse_string(&option, "secret file")
2134                        .into_iter()
2135                        .for_each(|value| secret.set_file(value)),
2136                    "environment" if !duplicate => self
2137                        .parse_string(&option, "secret environment source")
2138                        .into_iter()
2139                        .for_each(|value| secret.set_environment(value)),
2140                    "external" if !duplicate => self
2141                        .parse_boolean(&option, "secret external")
2142                        .into_iter()
2143                        .for_each(|value| secret.set_external(value)),
2144                    "name" if !duplicate => self
2145                        .parse_string(&option, "secret custom name")
2146                        .into_iter()
2147                        .for_each(|value| secret.set_custom_name(value)),
2148                    name if name.starts_with("x-") => secret.push_extension(option.reference()),
2149                    _ if duplicate => {}
2150                    _ => secret.push_unknown(option.reference()),
2151                }
2152            }
2153            definitions.push(secret);
2154        }
2155        definitions
2156    }
2157
2158    fn resource_collection(&mut self, field: &ParsedField, kind: &str) -> Option<Mapping> {
2159        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
2160            self.expected(EXPECTED_MAPPING, field, format!("top-level {kind} must be a mapping"));
2161            return None;
2162        };
2163        Some(mapping.clone())
2164    }
2165
2166    fn parse_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
2167        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2168            self.expected(EXPECTED_SCALAR, field, format!("{description} must be a scalar"));
2169            return None;
2170        };
2171        if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
2172            self.expected(
2173                EXPECTED_SCALAR,
2174                field,
2175                format!("{description} must be a non-null scalar"),
2176            );
2177            return None;
2178        }
2179        Some(Located::new(
2180            scalar_string_from_source(&self.source, scalar),
2181            span_from_position(self.source_id, scalar.byte_range()),
2182        ))
2183    }
2184
2185    fn parse_boolean(&mut self, field: &ParsedField, description: &str) -> Option<Located<BooleanValue>> {
2186        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2187            self.expected(EXPECTED_BOOLEAN, field, format!("{description} must be a boolean"));
2188            return None;
2189        };
2190        let span = span_from_position(self.source_id, scalar.byte_range());
2191        let scalar_value = ScalarValue::from_scalar(scalar);
2192        if let Some(value) = scalar_value.to_bool() {
2193            return Some(Located::new(BooleanValue::Literal(value), span));
2194        }
2195        let value = scalar_string_from_source(&self.source, scalar);
2196        if value.contains('$') {
2197            return Some(Located::new(BooleanValue::Expression(value), span));
2198        }
2199        self.diagnostics.push(
2200            Diagnostic::new(
2201                EXPECTED_BOOLEAN,
2202                Severity::Error,
2203                format!("{description} must be a boolean or interpolation expression"),
2204            )
2205            .with_label(DiagnosticLabel::primary(span, "not a boolean expression")),
2206        );
2207        None
2208    }
2209
2210    fn parse_string_sequence(&mut self, field: &ParsedField, description: &str) -> Vec<Located<String>> {
2211        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
2212            self.expected(EXPECTED_SEQUENCE, field, format!("{description} must be a sequence"));
2213            return Vec::new();
2214        };
2215        self.parse_scalar_nodes(
2216            sequence.values(),
2217            field.span,
2218            format!("{description} entries must be scalars"),
2219        )
2220    }
2221
2222    fn parse_scalar_nodes(
2223        &mut self,
2224        nodes: impl Iterator<Item = YamlNode>,
2225        fallback_span: SourceSpan,
2226        message: impl Into<String>,
2227    ) -> Vec<Located<String>> {
2228        let message = message.into();
2229        let mut values = Vec::new();
2230        for node in nodes {
2231            let YamlNode::Scalar(scalar) = node else {
2232                self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
2233                continue;
2234            };
2235            let scalar_value = ScalarValue::from_scalar(&scalar);
2236            if scalar_value.scalar_type() == ScalarType::Null {
2237                self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
2238                continue;
2239            }
2240            let span = span_from_position(self.source_id, scalar.byte_range());
2241            values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
2242        }
2243        values
2244    }
2245
2246    fn parse_scalar_mapping(&mut self, field: &ParsedField, description: &str) -> Vec<KeyValueEntry> {
2247        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
2248            self.expected(EXPECTED_MAPPING, field, format!("{description} must be a mapping"));
2249            return Vec::new();
2250        };
2251        let mut entries = Vec::new();
2252        let mut seen = BTreeMap::new();
2253        for entry in self.fields(mapping) {
2254            if self.record_duplicate(&mut seen, &entry) {
2255                continue;
2256            }
2257            if let Some(value) = self.parse_compose_scalar(&entry, format!("{description} values must be scalars")) {
2258                entries.push(KeyValueEntry::new(entry.name, value, entry.span));
2259            }
2260        }
2261        entries
2262    }
2263
2264    fn parse_compose_scalar(
2265        &mut self,
2266        field: &ParsedField,
2267        message: impl Into<String>,
2268    ) -> Option<Located<ComposeScalar>> {
2269        let Some(node) = field.value.as_ref() else {
2270            return Some(Located::new(ComposeScalar::Null, field.name.span));
2271        };
2272        let Some(scalar) = node.as_scalar() else {
2273            self.expected(EXPECTED_SCALAR, field, message);
2274            return None;
2275        };
2276        let span = span_from_position(self.source_id, scalar.byte_range());
2277        let value = ScalarValue::from_scalar(scalar);
2278        let typed = match value.scalar_type() {
2279            ScalarType::Null => ComposeScalar::Null,
2280            ScalarType::Boolean => ComposeScalar::Boolean(value.to_bool().unwrap_or(false)),
2281            ScalarType::Integer | ScalarType::Float => {
2282                ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
2283            }
2284            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
2285                ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
2286            }
2287        };
2288        Some(Located::new(typed, span))
2289    }
2290
2291    fn parse_labels(&mut self, field: &ParsedField) -> Option<Labels> {
2292        match field.value.as_ref() {
2293            Some(YamlNode::Sequence(sequence)) => {
2294                let span = span_from_position(self.source_id, sequence.byte_range());
2295                let values =
2296                    self.parse_scalar_nodes(sequence.values(), field.span, "label list entries must be scalars");
2297                Some(Labels::List { span, values })
2298            }
2299            Some(YamlNode::Mapping(mapping)) => {
2300                let span = span_from_position(self.source_id, mapping.byte_range());
2301                let entries = self.parse_scalar_mapping(field, "labels");
2302                Some(Labels::Map { span, entries })
2303            }
2304            _ => {
2305                self.expected(EXPECTED_FIELD_FORM, field, "labels must be a sequence or mapping");
2306                None
2307            }
2308        }
2309    }
2310
2311    fn field_is_null(field: &ParsedField) -> bool {
2312        field.value.as_ref().is_none_or(|node| {
2313            node.as_scalar()
2314                .is_some_and(|scalar| ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null)
2315        })
2316    }
2317
2318    fn unsupported_sequence_item(
2319        &mut self,
2320        code: DiagnosticCode,
2321        node: &YamlNode,
2322        fallback_span: SourceSpan,
2323        message: impl Into<String>,
2324    ) {
2325        let span = node_span(self.source_id, node).unwrap_or(fallback_span);
2326        self.diagnostics.push(
2327            Diagnostic::new(code, Severity::Error, message)
2328                .with_label(DiagnosticLabel::primary(span, "unsupported value form")),
2329        );
2330    }
2331
2332    fn fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
2333        let fields = self.raw_fields(mapping);
2334        let mut fields = self.flatten_empty_value_continuations(fields);
2335        for field in &mut fields {
2336            field.value = field.value.take().map(|value| self.resolve_alias(value));
2337        }
2338        fields
2339    }
2340
2341    fn raw_fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
2342        mapping
2343            .entries()
2344            .filter_map(|entry| {
2345                let key = entry.key_node()?;
2346                let Some(scalar) = key.as_scalar() else {
2347                    let span = node_span(self.source_id, &key)
2348                        .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
2349                    self.diagnostics.push(
2350                        Diagnostic::new(EXPECTED_SCALAR, Severity::Error, "Compose mapping keys must be scalars")
2351                            .with_label(DiagnosticLabel::primary(span, "non-scalar key")),
2352                    );
2353                    return None;
2354                };
2355                let name_span = span_from_position(self.source_id, scalar.byte_range());
2356                let authored_value = entry.value_node();
2357                let value_span = authored_value
2358                    .as_ref()
2359                    .and_then(|value| node_span(self.source_id, value));
2360                let value = authored_value.map(unwrap_processing_tag);
2361                let span = value_span.map_or(name_span, |value_span| union(name_span, value_span));
2362                Some(ParsedField {
2363                    name: Located::new(scalar_string_from_source(&self.source, scalar), name_span),
2364                    value,
2365                    value_span,
2366                    span,
2367                })
2368            })
2369            .collect()
2370    }
2371
2372    fn resolve_alias(&self, node: YamlNode) -> YamlNode {
2373        let mut node = node;
2374        let mut visited = BTreeSet::new();
2375        for _ in 0..64 {
2376            let YamlNode::Alias(alias) = &node else {
2377                return node;
2378            };
2379            if !visited.insert(alias.name()) {
2380                return node;
2381            }
2382            let Some(target) = self.anchors.resolve(&alias.name()).and_then(|target| {
2383                YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
2384            }) else {
2385                return node;
2386            };
2387            node = target;
2388        }
2389        node
2390    }
2391
2392    fn flatten_empty_value_continuations(&mut self, fields: Vec<ParsedField>) -> Vec<ParsedField> {
2393        let Some(target_column) = fields.first().map(|field| self.source_column(field.name.span.start())) else {
2394            return fields;
2395        };
2396        self.recover_fields(fields, target_column)
2397    }
2398
2399    fn recover_fields(&mut self, fields: Vec<ParsedField>, target_column: usize) -> Vec<ParsedField> {
2400        let mut flattened = Vec::new();
2401        for mut field in fields {
2402            let field_column = self.source_column(field.name.span.start());
2403            let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
2404            let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
2405                !self.is_flow_mapping(mapping)
2406                    && mapping
2407                        .entries()
2408                        .find_map(|entry| {
2409                            let key = entry.key_node()?;
2410                            let scalar = key.as_scalar()?;
2411                            Some(scalar.byte_range().start as usize)
2412                        })
2413                        .is_some_and(|key_start| self.source_column(key_start) <= field_column)
2414            });
2415
2416            if continuation {
2417                field.value = None;
2418                field.value_span = None;
2419                field.span = field.name.span;
2420            }
2421            if field_column == target_column {
2422                flattened.push(field);
2423            }
2424            if let Some(mapping) = nested_mapping.filter(|mapping| !self.is_flow_mapping(mapping)) {
2425                let nested = self.raw_fields(&mapping);
2426                flattened.extend(self.recover_fields(nested, target_column));
2427            }
2428        }
2429        flattened
2430    }
2431
2432    fn is_flow_mapping(&self, mapping: &Mapping) -> bool {
2433        let position = mapping.byte_range();
2434        self.source
2435            .get(position.start as usize..position.end as usize)
2436            .is_some_and(|text| text.trim_start().starts_with('{'))
2437    }
2438
2439    fn record_duplicate(&mut self, seen: &mut BTreeMap<String, SourceSpan>, field: &ParsedField) -> bool {
2440        if let Some(first) = seen.get(field.name.value()) {
2441            self.diagnostics.push(
2442                Diagnostic::new(
2443                    DUPLICATE_FIELD,
2444                    Severity::Error,
2445                    "Compose mapping fields must be unique",
2446                )
2447                .with_label(DiagnosticLabel::primary(field.name.span, "duplicate field"))
2448                .with_label(DiagnosticLabel::secondary(*first, "first field")),
2449            );
2450            true
2451        } else {
2452            seen.insert(field.name.value.clone(), field.name.span);
2453            false
2454        }
2455    }
2456
2457    fn expected(&mut self, code: DiagnosticCode, field: &ParsedField, message: impl Into<String>) {
2458        self.diagnostics.push(
2459            Diagnostic::new(code, Severity::Error, message)
2460                .with_label(DiagnosticLabel::primary(field.span, "unexpected value form")),
2461        );
2462    }
2463
2464    fn missing(&mut self, code: DiagnosticCode, span: SourceSpan, message: &'static str) {
2465        self.diagnostics.push(
2466            Diagnostic::new(code, Severity::Error, message)
2467                .with_label(DiagnosticLabel::primary(span, "incomplete long syntax")),
2468        );
2469    }
2470}
2471
2472fn unwrap_processing_tag(node: YamlNode) -> YamlNode {
2473    let YamlNode::TaggedNode(tagged) = &node else {
2474        return node;
2475    };
2476    if !matches!(tagged.tag().as_deref(), Some("!reset" | "!override")) {
2477        return node;
2478    }
2479    tagged
2480        .as_node()
2481        .and_then(|syntax| syntax.children().find_map(YamlNode::from_syntax))
2482        .unwrap_or(node)
2483}
2484
2485#[derive(Debug, Clone)]
2486enum ParsedGrant {
2487    Short(Located<String>),
2488    Long(Box<LongGrant>),
2489}
2490
2491#[derive(Debug, Clone)]
2492struct ParsedField {
2493    name: Located<String>,
2494    value: Option<YamlNode>,
2495    value_span: Option<SourceSpan>,
2496    span: SourceSpan,
2497}
2498
2499impl ParsedField {
2500    fn reference(&self) -> FieldReference {
2501        FieldReference {
2502            name: self.name.clone(),
2503            span: self.span,
2504            value_span: self.value_span,
2505        }
2506    }
2507}
2508
2509fn node_span(source_id: SourceId, node: &YamlNode) -> Option<SourceSpan> {
2510    let position = match node {
2511        YamlNode::Scalar(value) => value.byte_range(),
2512        YamlNode::Mapping(value) => value.byte_range(),
2513        YamlNode::Sequence(value) => value.byte_range(),
2514        YamlNode::Alias(_) | YamlNode::TaggedNode(_) => {
2515            let range = node.as_node()?.text_range();
2516            return Some(SourceSpan::from_valid_offsets(
2517                source_id,
2518                u32::from(range.start()) as usize,
2519                u32::from(range.end()) as usize,
2520            ));
2521        }
2522    };
2523    Some(span_from_position(source_id, position))
2524}
2525
2526fn span_from_position(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
2527    SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
2528}
2529
2530fn union(left: SourceSpan, right: SourceSpan) -> SourceSpan {
2531    SourceSpan::from_valid_offsets(
2532        left.source_id(),
2533        left.start().min(right.start()),
2534        left.end().max(right.end()),
2535    )
2536}