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