Skip to main content

compose_lens/model/
mod.rs

1//! Source-aware native Compose document types.
2
3mod annotation;
4mod capability;
5mod command;
6mod dependency;
7mod device;
8mod dns;
9mod dns_option;
10mod dns_search;
11mod entrypoint;
12mod environment;
13mod expose;
14mod host;
15mod hostname;
16mod identity;
17mod image;
18mod lifecycle;
19mod memory;
20mod network;
21mod pids;
22mod port;
23mod pull;
24mod resource;
25mod restart;
26mod sections;
27mod security_option;
28mod shm;
29mod sysctl;
30mod tmpfs;
31mod ulimit;
32mod value;
33mod volume;
34
35pub use annotation::{Annotations, AnnotationsForm};
36pub use capability::{CapabilityAdd, CapabilityAddItem, CapabilityDrop, CapabilityDropItem};
37pub use command::Command;
38pub use dependency::{
39    DependencyCondition, DependsOn, Healthcheck, HealthcheckDuration, HealthcheckRetries, HealthcheckTest,
40    HealthcheckTestKind, ServiceDependency,
41};
42pub(crate) use device::valid_generated_device_string;
43pub use device::{Device, Devices, LongDevice, ShortDevice, ShortDeviceKind};
44pub use dns::{Dns, DnsForm};
45pub use dns_option::DnsOptions;
46pub use dns_search::{DnsSearch, DnsSearchForm};
47pub use entrypoint::Entrypoint;
48pub use environment::{
49    Environment, EnvironmentFile, EnvironmentFileFormat, EnvironmentFileFormatKind, EnvironmentListEntry,
50    EnvironmentMapEntry, LongEnvironmentFile,
51};
52pub use expose::{Expose, ExposeItem, ExposeItemKind, ExposePort, ExposeProtocol, ExposeScalarKind};
53pub(crate) use expose::{classify_expose_item, valid_generated_expose_item};
54pub use host::{ExtraHostSeparator, ExtraHosts, HostAddress, HostAddressKind, LongExtraHost, ShortExtraHost};
55pub(crate) use hostname::valid_hostname;
56pub use hostname::{Hostname, HostnameKind};
57pub use identity::{IdentityComponent, UserNamespaceMode, UserNamespaceModeKind, UserSpec};
58pub use image::{ImageDigest, ImageReference};
59pub use lifecycle::StopGracePeriod;
60pub(crate) use memory::valid_generated_mem_amount;
61pub use memory::{MemLimit, MemLimitKind, MemLimitScalarKind, MemLimitUnit};
62pub use network::{Ipam, IpamConfig, NetworkDefinition, ServiceNetwork, ServiceNetworks};
63pub(crate) use pids::valid_positive_pids_decimal;
64pub use pids::{PidsLimit, PidsLimitKind};
65pub use port::{LongPort, Port, ShortPort};
66pub(crate) use pull::valid_pull_policy_duration;
67pub use pull::{PullPolicy, PullPolicyKind};
68pub use resource::{ConfigDefinition, ConfigGrant, LongGrant, SecretDefinition, SecretGrant, VolumeDefinition};
69pub use restart::{RestartPolicy, RestartPolicyKind};
70pub use sections::{
71    Build, BuildDefinition, BuildField, BuildFieldKind, DeployDefinition, DeployField, DeployFieldKind,
72};
73pub(crate) use security_option::{SecurityOptionCandidateCounts, classify_security_option};
74pub use security_option::{SecurityOptionItem, SecurityOptionKind, SecurityOptions};
75pub(crate) use shm::valid_generated_shm_amount;
76pub use shm::{ShmSize, ShmSizeKind, ShmSizeScalarKind, ShmSizeUnit};
77pub use sysctl::{Sysctls, SysctlsForm};
78pub(crate) use tmpfs::valid_generated_tmpfs_item;
79pub use tmpfs::{Tmpfs, TmpfsForm, TmpfsItem, TmpfsItemKind};
80pub(crate) use ulimit::valid_ulimit_name;
81pub use ulimit::{LimitValue, Ulimit, UlimitRange, UlimitValue, Ulimits};
82pub use value::{BooleanValue, ComposeScalar, KeyValueEntry, Labels};
83pub use volume::{
84    BindOptions, ContainerPath, ContainerPathKind, LongVolumeMount, MountType, SelinuxRelabel, ShortVolumeMount,
85    VolumeMount, VolumeSyntax,
86};
87
88use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
89use crate::source::{SourceId, SourceSpan};
90use crate::syntax::{SyntaxDocument, scalar_string_from_source};
91use std::collections::{BTreeMap, BTreeSet};
92use yaml_edit::{AnchorRegistry, AsYaml, Mapping, ScalarType, ScalarValue, YamlNode};
93
94/// A Compose document root must be a mapping.
95pub const DOCUMENT_ROOT_TYPE: DiagnosticCode = DiagnosticCode::new("compose.document.expected-mapping");
96
97/// `ComposeLens` currently types the first document in a multi-document YAML stream.
98pub const MULTIPLE_DOCUMENTS: DiagnosticCode = DiagnosticCode::new("compose.document.multiple-documents");
99
100/// A mapping contains a duplicate field.
101pub const DUPLICATE_FIELD: DiagnosticCode = DiagnosticCode::new("compose.model.duplicate-field");
102
103/// A Compose value has to be a mapping at this location.
104pub const EXPECTED_MAPPING: DiagnosticCode = DiagnosticCode::new("compose.model.expected-mapping");
105
106/// A Compose value has to be a sequence at this location.
107pub const EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.model.expected-sequence");
108
109/// A Compose value has to be a scalar at this location.
110pub const EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.model.expected-scalar");
111
112/// A Compose value has to be a boolean at this location.
113pub const EXPECTED_BOOLEAN: DiagnosticCode = DiagnosticCode::new("compose.model.expected-boolean");
114
115/// A field supports multiple Compose syntax forms, but the authored form is invalid here.
116pub const EXPECTED_FIELD_FORM: DiagnosticCode = DiagnosticCode::new("compose.model.expected-field-form");
117
118/// A service port is neither scalar short syntax nor mapping long syntax.
119pub const PORT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.port.expected-short-or-long");
120
121/// A long-syntax service port is missing `target`.
122pub const PORT_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.port.long.missing-target");
123
124/// A service config or secret grant is neither scalar short syntax nor mapping long syntax.
125pub const GRANT_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.grant.expected-short-or-long");
126
127/// A long-syntax service config or secret grant is missing `source`.
128pub const GRANT_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.grant.long.missing-source");
129
130/// A top-level resource definition must be a mapping or an explicit null.
131pub const RESOURCE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.resource.expected-mapping-or-null");
132
133/// A service-volume item is neither short nor long syntax.
134pub const VOLUME_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.volume.expected-short-or-long");
135
136/// A long-syntax service volume is missing `type`.
137pub const VOLUME_MISSING_TYPE: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-type");
138
139/// A long-syntax service volume is missing `target`.
140pub const VOLUME_MISSING_TARGET: DiagnosticCode = DiagnosticCode::new("compose.volume.long.missing-target");
141
142/// A long-syntax bind mount has an invalid `SELinux` value.
143pub const VOLUME_INVALID_SELINUX: DiagnosticCode = DiagnosticCode::new("compose.volume.bind.invalid-selinux");
144
145/// A short `extra_hosts` entry does not contain a hostname/address separator.
146pub const EXTRA_HOST_INVALID_ENTRY: DiagnosticCode = DiagnosticCode::new("compose.extra-hosts.invalid-entry");
147
148/// A service limit is neither unlimited, a non-negative integer, nor deferred.
149pub const ULIMIT_INVALID_VALUE: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-value");
150
151/// A service limit name is outside Compose's portable lowercase-name grammar.
152pub const ULIMIT_INVALID_NAME: DiagnosticCode = DiagnosticCode::new("compose.ulimits.invalid-name");
153
154/// A service limit range is missing its required `soft` or `hard` member.
155pub const ULIMIT_MISSING_RANGE_MEMBER: DiagnosticCode = DiagnosticCode::new("compose.ulimits.missing-range-member");
156
157/// A health-check list has no valid command-mode token.
158pub const HEALTHCHECK_INVALID_TEST: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-test");
159
160/// A health-check duration does not follow Compose duration syntax.
161pub const HEALTHCHECK_INVALID_DURATION: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-duration");
162
163/// A health-check retry count is not a non-negative integer or deferred expression.
164pub const HEALTHCHECK_INVALID_RETRIES: DiagnosticCode = DiagnosticCode::new("compose.healthcheck.invalid-retries");
165
166/// A service-level restart policy is not one of the Compose-defined forms or an expression.
167pub const RESTART_INVALID_POLICY: DiagnosticCode = DiagnosticCode::new("compose.restart.invalid-policy");
168
169/// A service hostname is not authored as a YAML string scalar.
170pub const HOSTNAME_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.hostname.expected-string");
171
172/// A resolved service hostname does not satisfy the conservative RFC-1123 grammar.
173pub const HOSTNAME_INVALID: DiagnosticCode = DiagnosticCode::new("compose.hostname.invalid-value");
174
175/// A service PID limit is not a number or string scalar.
176pub const PIDS_LIMIT_EXPECTED_VALUE: DiagnosticCode =
177    DiagnosticCode::new("compose.pids-limit.expected-number-or-string");
178
179/// A service PID limit is neither unlimited, positive integral decimal, nor deferred.
180pub const PIDS_LIMIT_INVALID: DiagnosticCode = DiagnosticCode::new("compose.pids-limit.invalid-value");
181
182/// A zero service PID limit has ambiguous and unportable native semantics.
183pub const PIDS_LIMIT_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.pids-limit.ambiguous-zero");
184
185/// A service shared-memory size is not a number or string scalar.
186pub const SHM_SIZE_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.shm-size.expected-number-or-string");
187
188/// A zero service shared-memory size has no defined Compose semantics.
189pub const SHM_SIZE_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.shm-size.ambiguous-zero");
190
191/// A schema-accepted numeric shared-memory size lacks a documented explicit unit.
192pub const SHM_SIZE_PROVIDER_DEPENDENT_NUMBER: DiagnosticCode =
193    DiagnosticCode::new("compose.shm-size.provider-dependent-number");
194
195/// A schema-accepted string shared-memory size is outside the documented lowercase suffix family.
196pub const SHM_SIZE_PROVIDER_DEPENDENT_STRING: DiagnosticCode =
197    DiagnosticCode::new("compose.shm-size.provider-dependent-string");
198
199/// A service memory limit is not a number or string scalar.
200pub const MEM_LIMIT_EXPECTED_VALUE: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.expected-number-or-string");
201
202/// A zero service memory limit has no portable cross-provider meaning inferred by `ComposeLens`.
203pub const MEM_LIMIT_AMBIGUOUS_ZERO: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.ambiguous-zero");
204
205/// A schema-accepted numeric memory limit lacks a documented explicit unit.
206pub const MEM_LIMIT_SCHEMA_NUMBER: DiagnosticCode = DiagnosticCode::new("compose.mem-limit.schema-number");
207
208/// A schema-accepted string memory limit is outside the documented lowercase suffix family.
209pub const MEM_LIMIT_PROVIDER_DEPENDENT_STRING: DiagnosticCode =
210    DiagnosticCode::new("compose.mem-limit.provider-dependent-string");
211
212/// A service image pull policy is not documented, schema-recognized, or deferred.
213pub const PULL_POLICY_INVALID: DiagnosticCode = DiagnosticCode::new("compose.pull-policy.invalid-policy");
214
215/// A service stop grace period does not match the raw-preserving policy based on documented Compose units.
216pub const STOP_GRACE_PERIOD_INVALID: DiagnosticCode =
217    DiagnosticCode::new("compose.lifecycle.invalid-stop-grace-period");
218
219/// A service `cap_drop` value is not a YAML sequence.
220pub const CAP_DROP_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.expected-sequence");
221
222/// A service `cap_drop` item is not a YAML string scalar.
223pub const CAP_DROP_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.expected-string");
224
225/// A service `cap_drop` sequence contains an exact duplicate string.
226pub const CAP_DROP_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.cap-drop.duplicate-item");
227
228/// A service `cap_add` value is not a YAML sequence.
229pub const CAP_ADD_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.cap-add.expected-sequence");
230
231/// A service `cap_add` item is not a YAML string scalar.
232pub const CAP_ADD_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.cap-add.expected-string");
233
234/// A service `cap_add` sequence contains an exact duplicate string.
235pub const CAP_ADD_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.cap-add.duplicate-item");
236
237/// A service `devices` value is not a YAML sequence.
238pub const DEVICES_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-sequence");
239
240/// A service device item is neither a string scalar nor a mapping.
241pub const DEVICE_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-short-or-long");
242
243/// A short device or long-device member is not a YAML string scalar.
244pub const DEVICE_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.devices.expected-string");
245
246/// A long-syntax service device is missing its required `source` string.
247pub const DEVICE_MISSING_SOURCE: DiagnosticCode = DiagnosticCode::new("compose.devices.long.missing-source");
248
249/// A service `dns` value is neither a YAML string scalar nor a sequence.
250pub const DNS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.dns.expected-string-or-list");
251
252/// A service `dns` list item is not a YAML string scalar.
253pub const DNS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns.expected-string");
254
255/// A service `dns_opt` value is not a YAML sequence.
256pub const DNS_OPT_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.expected-sequence");
257
258/// A service `dns_opt` item is not a YAML string scalar.
259pub const DNS_OPT_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.expected-string");
260
261/// A service `dns_opt` sequence contains an exact duplicate string.
262pub const DNS_OPT_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.dns-opt.duplicate-item");
263
264/// A service `dns_search` value is neither a YAML string scalar nor a sequence.
265pub const DNS_SEARCH_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.dns-search.expected-string-or-list");
266
267/// A service `dns_search` list item is not a YAML string scalar.
268pub const DNS_SEARCH_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.dns-search.expected-string");
269
270/// A service `dns_search` list contains an exact duplicate string.
271pub const DNS_SEARCH_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.dns-search.duplicate-item");
272
273/// A service `expose` value is not a YAML sequence.
274pub const EXPOSE_EXPECTED_SEQUENCE: DiagnosticCode = DiagnosticCode::new("compose.expose.expected-sequence");
275
276/// A service `expose` item is not a YAML string or number scalar.
277pub const EXPOSE_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.expose.expected-string-or-number");
278
279/// A service `expose` item does not match the documented decimal port/range grammar.
280pub const EXPOSE_INVALID_ITEM: DiagnosticCode = DiagnosticCode::new("compose.expose.invalid-item");
281
282/// A service `expose` item uses a protocol outside the documented portable set.
283pub const EXPOSE_PROVIDER_DEPENDENT: DiagnosticCode = DiagnosticCode::new("compose.expose.provider-dependent-protocol");
284
285/// A service `expose` sequence contains an exact duplicate scalar identity.
286pub const EXPOSE_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.expose.duplicate-item");
287
288/// A service `security_opt` value is not a YAML sequence.
289pub const SECURITY_OPT_EXPECTED_SEQUENCE: DiagnosticCode =
290    DiagnosticCode::new("compose.security-opt.expected-sequence");
291
292/// A service `security_opt` item is not a YAML string scalar.
293pub const SECURITY_OPT_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.security-opt.expected-string");
294
295/// A service `security_opt` item is an explicitly empty string.
296pub const SECURITY_OPT_EMPTY_ITEM: DiagnosticCode = DiagnosticCode::new("compose.security-opt.empty-item");
297
298/// An AppArmor-shaped service `security_opt` item is not the exact narrow candidate form.
299pub const SECURITY_OPT_APPARMOR_NEAR_MISS: DiagnosticCode =
300    DiagnosticCode::new("compose.security-opt.apparmor-near-miss");
301
302/// More than one exact `AppArmor` candidate remains in a service `security_opt` sequence.
303pub const SECURITY_OPT_APPARMOR_CONFLICT: DiagnosticCode =
304    DiagnosticCode::new("compose.security-opt.apparmor-conflict");
305
306/// A seccomp-shaped service `security_opt` item is not the exact narrow candidate form.
307pub const SECURITY_OPT_SECCOMP_NEAR_MISS: DiagnosticCode =
308    DiagnosticCode::new("compose.security-opt.seccomp-near-miss");
309
310/// More than one exact seccomp candidate remains in a service `security_opt` sequence.
311pub const SECURITY_OPT_SECCOMP_CONFLICT: DiagnosticCode = DiagnosticCode::new("compose.security-opt.seccomp-conflict");
312
313/// A no-new-privileges-shaped item is not an exact lowercase boolean candidate.
314pub const SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS: DiagnosticCode =
315    DiagnosticCode::new("compose.security-opt.no-new-privileges-near-miss");
316
317/// More than one exact no-new-privileges candidate remains in one effective sequence.
318pub const SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT: DiagnosticCode =
319    DiagnosticCode::new("compose.security-opt.no-new-privileges-conflict");
320
321/// A mask-shaped service `security_opt` item is not the exact narrow candidate form.
322pub const SECURITY_OPT_MASK_NEAR_MISS: DiagnosticCode = DiagnosticCode::new("compose.security-opt.mask-near-miss");
323
324/// An unmask-shaped service `security_opt` item is not the exact narrow candidate form.
325pub const SECURITY_OPT_UNMASK_NEAR_MISS: DiagnosticCode = DiagnosticCode::new("compose.security-opt.unmask-near-miss");
326
327pub(crate) fn security_path_option_diagnostic(kind: &SecurityOptionKind, span: SourceSpan) -> Option<Diagnostic> {
328    let (code, message) = match kind {
329        SecurityOptionKind::MaskNearMiss => (
330            SECURITY_OPT_MASK_NEAR_MISS,
331            "mask candidates require exact lowercase `mask=<paths>` spelling with a non-empty whitespace-free payload",
332        ),
333        SecurityOptionKind::UnmaskNearMiss => (
334            SECURITY_OPT_UNMASK_NEAR_MISS,
335            "unmask candidates require exact lowercase `unmask=ALL` or colon-separated slash-prefixed paths without whitespace",
336        ),
337        _ => return None,
338    };
339    Some(
340        Diagnostic::new(code, Severity::Warning, message)
341            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
342    )
343}
344
345/// A `SELinux` label-disable-shaped item is not the exact lowercase candidate.
346pub const SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS: DiagnosticCode =
347    DiagnosticCode::new("compose.security-opt.security-label-disable-near-miss");
348
349/// More than one exact `SELinux` label-disable candidate remains in one effective sequence.
350pub const SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT: DiagnosticCode =
351    DiagnosticCode::new("compose.security-opt.security-label-disable-conflict");
352
353/// A `SELinux` label-filetype-shaped item is not the exact lowercase candidate.
354pub const SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS: DiagnosticCode =
355    DiagnosticCode::new("compose.security-opt.security-label-filetype-near-miss");
356
357/// More than one exact `SELinux` label-filetype candidate remains in one effective sequence.
358pub const SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT: DiagnosticCode =
359    DiagnosticCode::new("compose.security-opt.security-label-filetype-conflict");
360
361/// A `SELinux` label-level-shaped item is not the exact lowercase candidate.
362pub const SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS: DiagnosticCode =
363    DiagnosticCode::new("compose.security-opt.security-label-level-near-miss");
364
365/// More than one exact `SELinux` label-level candidate remains in one effective sequence.
366pub const SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT: DiagnosticCode =
367    DiagnosticCode::new("compose.security-opt.security-label-level-conflict");
368
369/// A `SELinux` label-nested-shaped item is not the exact lowercase candidate.
370pub const SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS: DiagnosticCode =
371    DiagnosticCode::new("compose.security-opt.security-label-nested-near-miss");
372
373/// More than one exact `SELinux` label-nested candidate remains in one effective sequence.
374pub const SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT: DiagnosticCode =
375    DiagnosticCode::new("compose.security-opt.security-label-nested-conflict");
376
377/// A `SELinux` label-type-shaped item is not the exact lowercase candidate.
378pub const SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS: DiagnosticCode =
379    DiagnosticCode::new("compose.security-opt.security-label-type-near-miss");
380
381/// More than one exact `SELinux` label-type candidate remains in one effective sequence.
382pub const SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT: DiagnosticCode =
383    DiagnosticCode::new("compose.security-opt.security-label-type-conflict");
384
385fn authored_security_label_diagnostic(
386    kind: &SecurityOptionKind,
387    span: SourceSpan,
388    candidates: &mut SecurityOptionCandidateCounts,
389) -> Option<Diagnostic> {
390    match kind {
391        SecurityOptionKind::SecurityLabelDisable { .. } => {
392            candidates.security_label_disable += 1;
393            (candidates.security_label_disable > 1).then(|| {
394                Diagnostic::new(
395                    SECURITY_OPT_SECURITY_LABEL_DISABLE_CONFLICT,
396                    Severity::Warning,
397                    "multiple SELinux label-disable candidates are retained; a consumer must resolve the conflict explicitly",
398                )
399                .with_label(DiagnosticLabel::primary(
400                    span,
401                    "additional SELinux label-disable candidate retained",
402                ))
403            })
404        }
405        SecurityOptionKind::SecurityLabelDisableNearMiss => Some(
406            Diagnostic::new(
407                SECURITY_OPT_SECURITY_LABEL_DISABLE_NEAR_MISS,
408                Severity::Warning,
409                "SELinux label-disable candidates require exact lowercase `label:disable` spelling without whitespace",
410            )
411            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
412        ),
413        SecurityOptionKind::SecurityLabelFileType { .. } => {
414            candidates.security_label_filetype += 1;
415            (candidates.security_label_filetype > 1).then(|| {
416                Diagnostic::new(
417                    SECURITY_OPT_SECURITY_LABEL_FILETYPE_CONFLICT,
418                    Severity::Warning,
419                    "multiple SELinux label-filetype candidates are retained; a consumer must resolve the conflict explicitly",
420                )
421                .with_label(DiagnosticLabel::primary(
422                    span,
423                    "additional SELinux label-filetype candidate retained",
424                ))
425            })
426        }
427        SecurityOptionKind::SecurityLabelFileTypeNearMiss => Some(
428            Diagnostic::new(
429                SECURITY_OPT_SECURITY_LABEL_FILETYPE_NEAR_MISS,
430                Severity::Warning,
431                "SELinux label-filetype candidates require exact lowercase `label:filetype:<type>` spelling without whitespace",
432            )
433            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
434        ),
435        SecurityOptionKind::SecurityLabelLevel { .. } => {
436            candidates.security_label_level += 1;
437            (candidates.security_label_level > 1).then(|| {
438                Diagnostic::new(
439                    SECURITY_OPT_SECURITY_LABEL_LEVEL_CONFLICT,
440                    Severity::Warning,
441                    "multiple SELinux label-level candidates are retained; a consumer must resolve the conflict explicitly",
442                )
443                .with_label(DiagnosticLabel::primary(
444                    span,
445                    "additional SELinux label-level candidate retained",
446                ))
447            })
448        }
449        SecurityOptionKind::SecurityLabelLevelNearMiss => Some(
450            Diagnostic::new(
451                SECURITY_OPT_SECURITY_LABEL_LEVEL_NEAR_MISS,
452                Severity::Warning,
453                "SELinux label-level candidates require exact lowercase `label:level:<level>` spelling without whitespace",
454            )
455            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
456        ),
457        SecurityOptionKind::SecurityLabelNested { .. } => {
458            candidates.security_label_nested += 1;
459            (candidates.security_label_nested > 1).then(|| {
460                Diagnostic::new(
461                    SECURITY_OPT_SECURITY_LABEL_NESTED_CONFLICT,
462                    Severity::Warning,
463                    "multiple SELinux label-nested candidates are retained; a consumer must resolve the conflict explicitly",
464                )
465                .with_label(DiagnosticLabel::primary(
466                    span,
467                    "additional SELinux label-nested candidate retained",
468                ))
469            })
470        }
471        SecurityOptionKind::SecurityLabelNestedNearMiss => Some(
472            Diagnostic::new(
473                SECURITY_OPT_SECURITY_LABEL_NESTED_NEAR_MISS,
474                Severity::Warning,
475                "SELinux label-nested candidates require exact lowercase `label:nested` spelling without whitespace",
476            )
477            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
478        ),
479        SecurityOptionKind::SecurityLabelType { .. } | SecurityOptionKind::SecurityLabelTypeNearMiss => {
480            authored_security_label_type_diagnostic(kind, span, &mut candidates.security_label_type)
481        }
482        _ => None,
483    }
484}
485
486fn authored_security_label_type_diagnostic(
487    kind: &SecurityOptionKind,
488    span: SourceSpan,
489    candidates: &mut usize,
490) -> Option<Diagnostic> {
491    match kind {
492        SecurityOptionKind::SecurityLabelType { .. } => {
493            *candidates += 1;
494            (*candidates > 1).then(|| {
495                Diagnostic::new(
496                    SECURITY_OPT_SECURITY_LABEL_TYPE_CONFLICT,
497                    Severity::Warning,
498                    "multiple SELinux label-type candidates are retained; a consumer must resolve the conflict explicitly",
499                )
500                .with_label(DiagnosticLabel::primary(
501                    span,
502                    "additional SELinux label-type candidate retained",
503                ))
504            })
505        }
506        SecurityOptionKind::SecurityLabelTypeNearMiss => Some(
507            Diagnostic::new(
508                SECURITY_OPT_SECURITY_LABEL_TYPE_NEAR_MISS,
509                Severity::Warning,
510                "SELinux label-type candidates require exact lowercase `label:type:<type>` spelling with one non-empty whitespace-free type",
511            )
512            .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
513        ),
514        _ => None,
515    }
516}
517
518/// A service `annotations` value is neither mapping nor list syntax.
519pub const ANNOTATIONS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.annotations.expected-map-or-list");
520
521/// A service annotation list item is not a YAML string scalar.
522pub const ANNOTATIONS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.annotations.expected-string");
523
524/// A service annotation has an empty semantic name.
525pub const ANNOTATIONS_EMPTY_NAME: DiagnosticCode = DiagnosticCode::new("compose.annotations.empty-name");
526
527/// A key-only service annotation list item has no defined explicit value.
528pub const ANNOTATIONS_KEY_ONLY: DiagnosticCode = DiagnosticCode::new("compose.annotations.key-only");
529
530/// More than one authored service annotation resolves to the same semantic name.
531pub const ANNOTATIONS_DUPLICATE_NAME: DiagnosticCode = DiagnosticCode::new("compose.annotations.duplicate-name");
532
533/// A service-level `tmpfs` value is neither a string scalar nor a sequence.
534pub const TMPFS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.expected-string-or-list");
535
536/// A service-level `tmpfs` sequence item is not a YAML string scalar.
537pub const TMPFS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.expected-string");
538
539/// A service-level `tmpfs` item is malformed or depends on provider- or target-specific behavior.
540pub const TMPFS_PROVIDER_DEPENDENT: DiagnosticCode = DiagnosticCode::new("compose.tmpfs.provider-dependent-item");
541
542/// A service `sysctls` value is neither a mapping nor a sequence.
543pub const SYSCTLS_EXPECTED_FORM: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-map-or-list");
544
545/// A service `sysctls` mapping contains an empty key.
546pub const SYSCTLS_EMPTY_KEY: DiagnosticCode = DiagnosticCode::new("compose.sysctls.empty-key");
547
548/// A service `sysctls` mapping value is not a scalar or null.
549pub const SYSCTLS_EXPECTED_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-scalar");
550
551/// A service `sysctls` list item is not a YAML string scalar.
552pub const SYSCTLS_EXPECTED_STRING: DiagnosticCode = DiagnosticCode::new("compose.sysctls.expected-string");
553
554/// A service `sysctls` list contains an exact duplicate string.
555pub const SYSCTLS_DUPLICATE_ITEM: DiagnosticCode = DiagnosticCode::new("compose.sysctls.duplicate-item");
556
557/// A service environment-file item is neither scalar short syntax nor mapping long syntax.
558pub const ENVIRONMENT_FILE_EXPECTED_FORM: DiagnosticCode =
559    DiagnosticCode::new("compose.environment-file.expected-short-or-long");
560
561/// A long-syntax service environment-file entry is missing `path`.
562pub const ENVIRONMENT_FILE_MISSING_PATH: DiagnosticCode =
563    DiagnosticCode::new("compose.environment-file.long.missing-path");
564
565/// A long-syntax service environment-file format is not defined by Compose.
566pub const ENVIRONMENT_FILE_INVALID_FORMAT: DiagnosticCode =
567    DiagnosticCode::new("compose.environment-file.invalid-format");
568
569/// A long dependency uses an unrecognized condition.
570pub const DEPENDENCY_INVALID_CONDITION: DiagnosticCode = DiagnosticCode::new("compose.dependencies.invalid-condition");
571
572/// A typed dependency names a service missing from the same document.
573pub const DEPENDENCY_MISSING_SERVICE: DiagnosticCode = DiagnosticCode::new("compose.dependencies.missing-service");
574
575/// A `service_healthy` dependency has no enabled health check.
576pub const DEPENDENCY_MISSING_HEALTHCHECK: DiagnosticCode =
577    DiagnosticCode::new("compose.dependencies.missing-healthcheck");
578
579/// A `service_healthy` dependency may rely on health metadata from its image.
580pub const DEPENDENCY_HEALTHCHECK_UNVERIFIED: DiagnosticCode =
581    DiagnosticCode::new("compose.dependencies.healthcheck-unverified");
582
583/// A typed value and the exact source span from which it was read.
584#[derive(Debug, Clone, PartialEq, Eq)]
585pub struct Located<T> {
586    value: T,
587    span: SourceSpan,
588}
589
590impl<T> Located<T> {
591    pub(crate) const fn new(value: T, span: SourceSpan) -> Self {
592        Self { value, span }
593    }
594
595    /// Returns the typed value.
596    #[must_use]
597    pub const fn value(&self) -> &T {
598        &self.value
599    }
600
601    /// Returns the value's source span.
602    #[must_use]
603    pub const fn span(&self) -> SourceSpan {
604        self.span
605    }
606
607    /// Removes the source wrapper and returns the typed value.
608    #[must_use]
609    pub fn into_value(self) -> T {
610        self.value
611    }
612}
613
614/// Source provenance for an extension or not-yet-typed field.
615///
616/// The loss-aware [`SyntaxDocument`] retains the actual value and spelling. This reference lets
617/// typed callers locate it without exposing the private YAML implementation.
618#[derive(Debug, Clone, PartialEq, Eq)]
619pub struct FieldReference {
620    name: Located<String>,
621    span: SourceSpan,
622    value_span: Option<SourceSpan>,
623}
624
625impl FieldReference {
626    /// Returns the semantic field name and its source span.
627    #[must_use]
628    pub const fn name(&self) -> &Located<String> {
629        &self.name
630    }
631
632    /// Returns the span covering the key and value when both are available.
633    #[must_use]
634    pub const fn span(&self) -> SourceSpan {
635        self.span
636    }
637
638    /// Returns the value span when the YAML node exposes one.
639    #[must_use]
640    pub const fn value_span(&self) -> Option<SourceSpan> {
641        self.value_span
642    }
643}
644
645/// A source-aware typed Compose service.
646#[derive(Debug, Clone, PartialEq, Eq)]
647pub struct Service {
648    name: Located<String>,
649    span: SourceSpan,
650    hostname: Option<Hostname>,
651    container_name: Option<Located<String>>,
652    image: Option<Located<ImageReference>>,
653    entrypoint: Option<Entrypoint>,
654    command: Option<Command>,
655    init: Option<Located<BooleanValue>>,
656    environment: Option<Environment>,
657    environment_files: Vec<EnvironmentFile>,
658    labels: Option<Labels>,
659    annotations: Option<Annotations>,
660    extra_hosts: Option<ExtraHosts>,
661    user: Option<UserSpec>,
662    userns_mode: Option<UserNamespaceMode>,
663    group_add: Vec<Located<String>>,
664    cap_add: Option<CapabilityAdd>,
665    cap_drop: Option<CapabilityDrop>,
666    devices: Option<Devices>,
667    dns: Option<Dns>,
668    dns_options: Option<DnsOptions>,
669    dns_search: Option<DnsSearch>,
670    expose: Option<Expose>,
671    security_options: Option<SecurityOptions>,
672    working_dir: Option<Located<String>>,
673    read_only: Option<Located<BooleanValue>>,
674    pids_limit: Option<PidsLimit>,
675    shm_size: Option<ShmSize>,
676    mem_limit: Option<MemLimit>,
677    tmpfs: Option<Tmpfs>,
678    sysctls: Option<Sysctls>,
679    pull_policy: Option<PullPolicy>,
680    restart: Option<RestartPolicy>,
681    stop_signal: Option<Located<String>>,
682    stop_grace_period: Option<Located<StopGracePeriod>>,
683    ulimits: Option<Ulimits>,
684    depends_on: Option<DependsOn>,
685    healthcheck: Option<Healthcheck>,
686    build: Option<Build>,
687    deploy: Option<DeployDefinition>,
688    ports: Vec<Port>,
689    volumes: Vec<VolumeMount>,
690    networks: Option<ServiceNetworks>,
691    profiles: Vec<Located<String>>,
692    configs: Vec<ConfigGrant>,
693    secrets: Vec<SecretGrant>,
694    extension_fields: Vec<FieldReference>,
695    unknown_fields: Vec<FieldReference>,
696}
697
698impl Service {
699    fn new(name: Located<String>, span: SourceSpan) -> Self {
700        Self {
701            name,
702            span,
703            hostname: None,
704            container_name: None,
705            image: None,
706            entrypoint: None,
707            command: None,
708            init: None,
709            environment: None,
710            environment_files: Vec::new(),
711            labels: None,
712            annotations: None,
713            extra_hosts: None,
714            user: None,
715            userns_mode: None,
716            group_add: Vec::new(),
717            cap_add: None,
718            cap_drop: None,
719            devices: None,
720            dns: None,
721            dns_options: None,
722            dns_search: None,
723            expose: None,
724            security_options: None,
725            working_dir: None,
726            read_only: None,
727            pids_limit: None,
728            shm_size: None,
729            mem_limit: None,
730            tmpfs: None,
731            sysctls: None,
732            pull_policy: None,
733            restart: None,
734            stop_signal: None,
735            stop_grace_period: None,
736            ulimits: None,
737            depends_on: None,
738            healthcheck: None,
739            build: None,
740            deploy: None,
741            ports: Vec::new(),
742            volumes: Vec::new(),
743            networks: None,
744            profiles: Vec::new(),
745            configs: Vec::new(),
746            secrets: Vec::new(),
747            extension_fields: Vec::new(),
748            unknown_fields: Vec::new(),
749        }
750    }
751
752    /// Returns the service name.
753    #[must_use]
754    pub const fn name(&self) -> &Located<String> {
755        &self.name
756    }
757
758    /// Returns the complete service definition span.
759    #[must_use]
760    pub const fn span(&self) -> SourceSpan {
761        self.span
762    }
763
764    /// Returns the explicitly authored raw-preserving service hostname.
765    #[must_use]
766    pub const fn hostname(&self) -> Option<&Hostname> {
767        self.hostname.as_ref()
768    }
769
770    /// Returns the explicitly authored runtime container name.
771    #[must_use]
772    pub const fn container_name(&self) -> Option<&Located<String>> {
773        self.container_name.as_ref()
774    }
775
776    /// Returns the explicitly authored image reference.
777    #[must_use]
778    pub const fn image(&self) -> Option<&Located<ImageReference>> {
779        self.image.as_ref()
780    }
781
782    /// Returns the entrypoint without normalizing its authored form.
783    #[must_use]
784    pub const fn entrypoint(&self) -> Option<&Entrypoint> {
785        self.entrypoint.as_ref()
786    }
787
788    /// Returns the command without normalizing its authored form.
789    #[must_use]
790    pub const fn command(&self) -> Option<&Command> {
791        self.command.as_ref()
792    }
793
794    /// Returns whether Compose should run its platform-specific init process.
795    #[must_use]
796    pub const fn init(&self) -> Option<&Located<BooleanValue>> {
797        self.init.as_ref()
798    }
799
800    /// Returns environment variables with list and mapping forms kept distinct.
801    #[must_use]
802    pub const fn environment(&self) -> Option<&Environment> {
803        self.environment.as_ref()
804    }
805
806    /// Returns service environment files in authored order with syntax retained.
807    #[must_use]
808    pub fn environment_files(&self) -> &[EnvironmentFile] {
809        &self.environment_files
810    }
811
812    /// Returns service metadata labels with list and mapping forms kept distinct.
813    #[must_use]
814    pub const fn labels(&self) -> Option<&Labels> {
815        self.labels.as_ref()
816    }
817
818    /// Returns service annotations with list and mapping forms kept distinct.
819    #[must_use]
820    pub const fn annotations(&self) -> Option<&Annotations> {
821        self.annotations.as_ref()
822    }
823
824    /// Returns additional host mappings with short and long forms retained.
825    #[must_use]
826    pub const fn extra_hosts(&self) -> Option<&ExtraHosts> {
827        self.extra_hosts.as_ref()
828    }
829
830    /// Returns the raw-preserving container user/group value.
831    #[must_use]
832    pub const fn user(&self) -> Option<&UserSpec> {
833        self.user.as_ref()
834    }
835
836    /// Returns the raw-preserving user-namespace mode.
837    #[must_use]
838    pub const fn userns_mode(&self) -> Option<&UserNamespaceMode> {
839        self.userns_mode.as_ref()
840    }
841
842    /// Returns supplementary groups in authored order without resolving names or IDs.
843    #[must_use]
844    pub fn group_add(&self) -> &[Located<String>] {
845        &self.group_add
846    }
847
848    /// Returns the explicitly authored capability-add sequence, including an explicit empty one.
849    #[must_use]
850    pub const fn cap_add(&self) -> Option<&CapabilityAdd> {
851        self.cap_add.as_ref()
852    }
853
854    /// Returns the explicitly authored capability-drop sequence, including an explicit empty one.
855    #[must_use]
856    pub const fn cap_drop(&self) -> Option<&CapabilityDrop> {
857        self.cap_drop.as_ref()
858    }
859
860    /// Returns the explicitly authored ordered device sequence, including an explicit empty one.
861    #[must_use]
862    pub const fn devices(&self) -> Option<&Devices> {
863        self.devices.as_ref()
864    }
865
866    /// Returns raw service DNS servers with scalar and ordered-list forms retained.
867    #[must_use]
868    pub const fn dns(&self) -> Option<&Dns> {
869        self.dns.as_ref()
870    }
871
872    /// Returns the explicitly authored ordered DNS resolver-option sequence.
873    #[must_use]
874    pub const fn dns_options(&self) -> Option<&DnsOptions> {
875        self.dns_options.as_ref()
876    }
877
878    /// Returns raw DNS search domains with scalar and ordered-list forms retained.
879    #[must_use]
880    pub const fn dns_search(&self) -> Option<&DnsSearch> {
881        self.dns_search.as_ref()
882    }
883
884    /// Returns the explicitly authored ordered exposed-port sequence.
885    #[must_use]
886    pub const fn expose(&self) -> Option<&Expose> {
887        self.expose.as_ref()
888    }
889
890    /// Returns the explicitly authored ordered raw service security options.
891    #[must_use]
892    pub const fn security_options(&self) -> Option<&SecurityOptions> {
893        self.security_options.as_ref()
894    }
895
896    /// Returns the container working-directory override.
897    #[must_use]
898    pub const fn working_dir(&self) -> Option<&Located<String>> {
899        self.working_dir.as_ref()
900    }
901
902    /// Returns the explicit read-only root-filesystem choice.
903    #[must_use]
904    pub const fn read_only(&self) -> Option<&Located<BooleanValue>> {
905        self.read_only.as_ref()
906    }
907
908    /// Returns the raw-preserving service PID limit.
909    #[must_use]
910    pub const fn pids_limit(&self) -> Option<&PidsLimit> {
911        self.pids_limit.as_ref()
912    }
913
914    /// Returns the raw-preserving service shared-memory size.
915    #[must_use]
916    pub const fn shm_size(&self) -> Option<&ShmSize> {
917        self.shm_size.as_ref()
918    }
919
920    /// Returns the raw-preserving service memory limit.
921    #[must_use]
922    pub const fn mem_limit(&self) -> Option<&MemLimit> {
923        self.mem_limit.as_ref()
924    }
925
926    /// Returns service-level temporary filesystems with scalar and list forms retained.
927    #[must_use]
928    pub const fn tmpfs(&self) -> Option<&Tmpfs> {
929        self.tmpfs.as_ref()
930    }
931
932    /// Returns service sysctls with mapping/list form and scalar spelling retained.
933    #[must_use]
934    pub const fn sysctls(&self) -> Option<&Sysctls> {
935        self.sysctls.as_ref()
936    }
937
938    /// Returns the raw-preserving service image pull policy.
939    #[must_use]
940    pub const fn pull_policy(&self) -> Option<&PullPolicy> {
941        self.pull_policy.as_ref()
942    }
943
944    /// Returns the service-level container restart policy.
945    #[must_use]
946    pub const fn restart(&self) -> Option<&RestartPolicy> {
947        self.restart.as_ref()
948    }
949
950    /// Returns the explicitly authored signal used to stop the service.
951    #[must_use]
952    pub const fn stop_signal(&self) -> Option<&Located<String>> {
953        self.stop_signal.as_ref()
954    }
955
956    /// Returns the raw-preserving service stop grace period.
957    #[must_use]
958    pub const fn stop_grace_period(&self) -> Option<&Located<StopGracePeriod>> {
959        self.stop_grace_period.as_ref()
960    }
961
962    /// Returns explicitly authored service resource limits.
963    #[must_use]
964    pub const fn ulimits(&self) -> Option<&Ulimits> {
965        self.ulimits.as_ref()
966    }
967
968    /// Returns service dependencies with short and long forms retained.
969    #[must_use]
970    pub const fn depends_on(&self) -> Option<&DependsOn> {
971        self.depends_on.as_ref()
972    }
973
974    /// Returns the service health-check definition.
975    #[must_use]
976    pub const fn healthcheck(&self) -> Option<&Healthcheck> {
977        self.healthcheck.as_ref()
978    }
979
980    /// Returns the build declaration with short and long forms retained.
981    #[must_use]
982    pub const fn build(&self) -> Option<&Build> {
983        self.build.as_ref()
984    }
985
986    /// Returns independently classified deploy subfields.
987    #[must_use]
988    pub const fn deploy(&self) -> Option<&DeployDefinition> {
989        self.deploy.as_ref()
990    }
991
992    /// Returns published ports in authored order.
993    #[must_use]
994    pub fn ports(&self) -> &[Port] {
995        &self.ports
996    }
997
998    /// Returns service-volume mounts in authored order.
999    #[must_use]
1000    pub fn volumes(&self) -> &[VolumeMount] {
1001        &self.volumes
1002    }
1003
1004    /// Returns service network attachments with short and long forms kept distinct.
1005    #[must_use]
1006    pub const fn networks(&self) -> Option<&ServiceNetworks> {
1007        self.networks.as_ref()
1008    }
1009
1010    /// Returns explicitly authored profile names.
1011    #[must_use]
1012    pub fn profiles(&self) -> &[Located<String>] {
1013        &self.profiles
1014    }
1015
1016    /// Returns service config grants in authored order.
1017    #[must_use]
1018    pub fn configs(&self) -> &[ConfigGrant] {
1019        &self.configs
1020    }
1021
1022    /// Returns service secret grants in authored order.
1023    #[must_use]
1024    pub fn secrets(&self) -> &[SecretGrant] {
1025        &self.secrets
1026    }
1027
1028    /// Returns retained service `x-` extension fields.
1029    #[must_use]
1030    pub fn extension_fields(&self) -> &[FieldReference] {
1031        &self.extension_fields
1032    }
1033
1034    /// Returns service fields not yet represented by the typed subset.
1035    #[must_use]
1036    pub fn unknown_fields(&self) -> &[FieldReference] {
1037        &self.unknown_fields
1038    }
1039}
1040
1041/// A source-aware native Compose document.
1042#[derive(Debug, Clone, PartialEq, Eq)]
1043pub struct ComposeDocument {
1044    source_id: SourceId,
1045    span: SourceSpan,
1046    name: Option<Located<String>>,
1047    services: Vec<Service>,
1048    networks: Vec<NetworkDefinition>,
1049    volumes: Vec<VolumeDefinition>,
1050    configs: Vec<ConfigDefinition>,
1051    secrets: Vec<SecretDefinition>,
1052    extension_fields: Vec<FieldReference>,
1053    unknown_fields: Vec<FieldReference>,
1054}
1055
1056impl ComposeDocument {
1057    /// Extracts the initial typed Compose subset from a loss-aware syntax document.
1058    ///
1059    /// Parsing does not interpolate values, apply defaults, normalize short and long forms, or
1060    /// access the environment. Structural problems produce diagnostics and as much typed data as
1061    /// can be recovered.
1062    #[must_use]
1063    pub fn parse(syntax: &SyntaxDocument) -> ModelParse {
1064        Parser::new(syntax).parse()
1065    }
1066
1067    /// Returns the source identifier.
1068    #[must_use]
1069    pub const fn source_id(&self) -> SourceId {
1070        self.source_id
1071    }
1072
1073    /// Returns the typed root mapping span.
1074    #[must_use]
1075    pub const fn span(&self) -> SourceSpan {
1076        self.span
1077    }
1078
1079    /// Returns the explicitly authored project name.
1080    #[must_use]
1081    pub const fn name(&self) -> Option<&Located<String>> {
1082        self.name.as_ref()
1083    }
1084
1085    /// Returns services in authored order.
1086    #[must_use]
1087    pub fn services(&self) -> &[Service] {
1088        &self.services
1089    }
1090
1091    /// Finds the first service with the requested name.
1092    #[must_use]
1093    pub fn service(&self, name: &str) -> Option<&Service> {
1094        self.services.iter().find(|service| service.name.value == name)
1095    }
1096
1097    /// Validates dependency targets and `service_healthy` health-check requirements in this document.
1098    ///
1099    /// Multi-file callers should validate the merged project view through
1100    /// [`crate::resolution::validate_references`] instead.
1101    #[must_use]
1102    pub fn validate_dependencies(&self) -> Vec<Diagnostic> {
1103        let mut diagnostics = Vec::new();
1104        for service in &self.services {
1105            let Some(depends_on) = service.depends_on() else {
1106                continue;
1107            };
1108            match depends_on {
1109                DependsOn::Short { services, .. } => {
1110                    for target in services {
1111                        if self.service(target.value()).is_none() {
1112                            diagnostics.push(missing_dependency_diagnostic(target.span(), false, true));
1113                        }
1114                    }
1115                }
1116                DependsOn::Long { services, .. } => {
1117                    for dependency in services {
1118                        let required = !matches!(
1119                            dependency.required().map(Located::value),
1120                            Some(BooleanValue::Literal(false))
1121                        );
1122                        let Some(target) = self.service(dependency.service().value()) else {
1123                            diagnostics.push(missing_dependency_diagnostic(
1124                                dependency.service().span(),
1125                                false,
1126                                required,
1127                            ));
1128                            continue;
1129                        };
1130                        let needs_healthcheck = matches!(
1131                            dependency.condition().map(Located::value),
1132                            Some(DependencyCondition::ServiceHealthy)
1133                        );
1134                        if needs_healthcheck && target.healthcheck().is_none() {
1135                            let span = dependency
1136                                .condition()
1137                                .map_or_else(|| dependency.service().span(), Located::span);
1138                            diagnostics.push(unverified_healthcheck_diagnostic(span));
1139                        } else if needs_healthcheck && target.healthcheck().is_some_and(Healthcheck::is_disabled) {
1140                            let span = dependency
1141                                .condition()
1142                                .map_or_else(|| dependency.service().span(), Located::span);
1143                            diagnostics.push(missing_dependency_diagnostic(span, true, required));
1144                        }
1145                    }
1146                }
1147            }
1148        }
1149        diagnostics
1150    }
1151
1152    /// Returns top-level network definitions in authored order.
1153    #[must_use]
1154    pub fn networks(&self) -> &[NetworkDefinition] {
1155        &self.networks
1156    }
1157
1158    /// Returns top-level volume definitions in authored order.
1159    #[must_use]
1160    pub fn volumes(&self) -> &[VolumeDefinition] {
1161        &self.volumes
1162    }
1163
1164    /// Returns top-level config definitions in authored order.
1165    #[must_use]
1166    pub fn configs(&self) -> &[ConfigDefinition] {
1167        &self.configs
1168    }
1169
1170    /// Returns top-level secret definitions in authored order.
1171    #[must_use]
1172    pub fn secrets(&self) -> &[SecretDefinition] {
1173        &self.secrets
1174    }
1175
1176    /// Returns retained top-level `x-` extension fields.
1177    #[must_use]
1178    pub fn extension_fields(&self) -> &[FieldReference] {
1179        &self.extension_fields
1180    }
1181
1182    /// Returns top-level fields not yet represented by the typed subset.
1183    #[must_use]
1184    pub fn unknown_fields(&self) -> &[FieldReference] {
1185        &self.unknown_fields
1186    }
1187}
1188
1189/// A recoverable typed-model parse result.
1190#[derive(Debug, Clone, PartialEq, Eq)]
1191pub struct ModelParse {
1192    document: Option<ComposeDocument>,
1193    diagnostics: Vec<Diagnostic>,
1194}
1195
1196impl ModelParse {
1197    /// Returns the typed document when the root could be interpreted.
1198    #[must_use]
1199    pub const fn document(&self) -> Option<&ComposeDocument> {
1200        self.document.as_ref()
1201    }
1202
1203    /// Returns structural typed-model diagnostics in source order.
1204    #[must_use]
1205    pub fn diagnostics(&self) -> &[Diagnostic] {
1206        &self.diagnostics
1207    }
1208
1209    /// Reports whether no error diagnostics were emitted.
1210    #[must_use]
1211    pub fn is_valid(&self) -> bool {
1212        !self
1213            .diagnostics
1214            .iter()
1215            .any(|diagnostic| diagnostic.severity() == Severity::Error)
1216    }
1217
1218    /// Separates the recovered document and diagnostics.
1219    #[must_use]
1220    pub fn into_parts(self) -> (Option<ComposeDocument>, Vec<Diagnostic>) {
1221        (self.document, self.diagnostics)
1222    }
1223}
1224
1225fn missing_dependency_diagnostic(span: SourceSpan, healthcheck: bool, required: bool) -> Diagnostic {
1226    let severity = if required { Severity::Error } else { Severity::Warning };
1227    if healthcheck {
1228        Diagnostic::new(
1229            DEPENDENCY_MISSING_HEALTHCHECK,
1230            severity,
1231            if required {
1232                "service_healthy dependency requires an enabled health check"
1233            } else {
1234                "optional service_healthy dependency has no enabled health check"
1235            },
1236        )
1237        .with_label(DiagnosticLabel::primary(span, "dependency cannot become healthy"))
1238    } else {
1239        Diagnostic::new(
1240            DEPENDENCY_MISSING_SERVICE,
1241            severity,
1242            if required {
1243                "service dependency is not declared in this Compose document"
1244            } else {
1245                "optional service dependency is not declared in this Compose document"
1246            },
1247        )
1248        .with_label(DiagnosticLabel::primary(span, "missing dependency service"))
1249    }
1250}
1251
1252fn unverified_healthcheck_diagnostic(span: SourceSpan) -> Diagnostic {
1253    Diagnostic::new(
1254        DEPENDENCY_HEALTHCHECK_UNVERIFIED,
1255        Severity::Warning,
1256        "service_healthy dependency has no Compose healthcheck to validate",
1257    )
1258    .with_label(DiagnosticLabel::primary(span, "image health metadata is not available"))
1259    .with_note("the dependency image may still define a health check; verify it at build or runtime")
1260}
1261
1262fn annotation_diagnostic(
1263    code: DiagnosticCode,
1264    severity: Severity,
1265    span: SourceSpan,
1266    message: &'static str,
1267    label: &'static str,
1268) -> Diagnostic {
1269    Diagnostic::new(code, severity, message).with_label(DiagnosticLabel::primary(span, label))
1270}
1271
1272#[derive(Debug)]
1273struct Parser {
1274    source_id: SourceId,
1275    source_span: SourceSpan,
1276    source: String,
1277    tree: yaml_edit::YamlFile,
1278    anchors: AnchorRegistry,
1279    diagnostics: Vec<Diagnostic>,
1280}
1281
1282impl Parser {
1283    fn new(syntax: &SyntaxDocument) -> Self {
1284        let tree = syntax.yaml_file();
1285        let anchors = tree
1286            .document()
1287            .map_or_else(AnchorRegistry::new, |document| AnchorRegistry::from_document(&document));
1288        Self {
1289            source_id: syntax.source_id(),
1290            source_span: syntax.source_span(),
1291            source: syntax.source_text().to_owned(),
1292            tree,
1293            anchors,
1294            diagnostics: Vec::new(),
1295        }
1296    }
1297
1298    fn parse(mut self) -> ModelParse {
1299        if self.tree.documents().count() > 1 {
1300            self.diagnostics.push(
1301                Diagnostic::new(
1302                    MULTIPLE_DOCUMENTS,
1303                    Severity::Error,
1304                    "Compose input must contain one YAML document",
1305                )
1306                .with_label(DiagnosticLabel::primary(self.source_span, "multiple YAML documents")),
1307            );
1308        }
1309
1310        let Some(root) = self.tree.document() else {
1311            self.diagnostics.push(
1312                Diagnostic::new(
1313                    DOCUMENT_ROOT_TYPE,
1314                    Severity::Error,
1315                    "Compose document root must be a mapping",
1316                )
1317                .with_label(DiagnosticLabel::primary(self.source_span, "empty document")),
1318            );
1319            return ModelParse {
1320                document: None,
1321                diagnostics: self.diagnostics,
1322            };
1323        };
1324        let root_span = span_from_position(self.source_id, root.byte_range());
1325        let Some(mapping) = root.as_mapping() else {
1326            self.diagnostics.push(
1327                Diagnostic::new(
1328                    DOCUMENT_ROOT_TYPE,
1329                    Severity::Error,
1330                    "Compose document root must be a mapping",
1331                )
1332                .with_label(DiagnosticLabel::primary(root_span, "not a mapping")),
1333            );
1334            return ModelParse {
1335                document: None,
1336                diagnostics: self.diagnostics,
1337            };
1338        };
1339
1340        let document = self.parse_root(&mapping, root_span);
1341        ModelParse {
1342            document: Some(document),
1343            diagnostics: self.diagnostics,
1344        }
1345    }
1346
1347    fn parse_root(&mut self, mapping: &Mapping, span: SourceSpan) -> ComposeDocument {
1348        let mut document = ComposeDocument {
1349            source_id: self.source_id,
1350            span,
1351            name: None,
1352            services: Vec::new(),
1353            networks: Vec::new(),
1354            volumes: Vec::new(),
1355            configs: Vec::new(),
1356            secrets: Vec::new(),
1357            extension_fields: Vec::new(),
1358            unknown_fields: Vec::new(),
1359        };
1360        let mut seen = BTreeMap::new();
1361
1362        for field in self.fields(mapping) {
1363            let duplicate = self.record_duplicate(&mut seen, &field);
1364            match field.name.value.as_str() {
1365                "name" if !duplicate => {
1366                    document.name = self.parse_string(&field, "project name");
1367                }
1368                "services" if !duplicate => {
1369                    document.services = self.parse_services(&field);
1370                }
1371                "networks" if !duplicate => {
1372                    document.networks = self.parse_network_definitions(&field);
1373                }
1374                "volumes" if !duplicate => {
1375                    document.volumes = self.parse_volume_definitions(&field);
1376                }
1377                "configs" if !duplicate => {
1378                    document.configs = self.parse_config_definitions(&field);
1379                }
1380                "secrets" if !duplicate => {
1381                    document.secrets = self.parse_secret_definitions(&field);
1382                }
1383                name if name.starts_with("x-") => {
1384                    document.extension_fields.push(field.reference());
1385                }
1386                _ if duplicate => {}
1387                _ => document.unknown_fields.push(field.reference()),
1388            }
1389        }
1390        document
1391    }
1392
1393    fn parse_services(&mut self, field: &ParsedField) -> Vec<Service> {
1394        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
1395            self.expected(EXPECTED_MAPPING, field, "services must be a mapping");
1396            return Vec::new();
1397        };
1398        let mut services = Vec::new();
1399        let mut seen = BTreeMap::new();
1400        for service_field in self.fields(mapping) {
1401            self.record_duplicate(&mut seen, &service_field);
1402            let Some(service_mapping) = service_field.value.as_ref().and_then(YamlNode::as_mapping) else {
1403                self.expected(EXPECTED_MAPPING, &service_field, "service definition must be a mapping");
1404                continue;
1405            };
1406            services.push(self.parse_service(&service_field, service_mapping));
1407        }
1408        services
1409    }
1410
1411    fn parse_service(&mut self, field: &ParsedField, mapping: &Mapping) -> Service {
1412        let mut service = Service::new(field.name.clone(), field.span);
1413        let mut seen = BTreeMap::new();
1414        for service_field in self.fields(mapping) {
1415            let duplicate = self.record_duplicate(&mut seen, &service_field);
1416            match service_field.name.value.as_str() {
1417                "hostname" if !duplicate => service.hostname = self.parse_hostname(&service_field),
1418                "container_name" if !duplicate => {
1419                    service.container_name = self.parse_string(&service_field, "container name");
1420                }
1421                "image" if !duplicate => service.image = self.parse_image(&service_field),
1422                "entrypoint" if !duplicate => service.entrypoint = self.parse_entrypoint(&service_field),
1423                "command" if !duplicate => service.command = self.parse_command(&service_field),
1424                "init" if !duplicate => service.init = self.parse_boolean(&service_field, "service init"),
1425                "environment" if !duplicate => service.environment = self.parse_environment(&service_field),
1426                "env_file" if !duplicate => {
1427                    service.environment_files = self.parse_environment_files(&service_field);
1428                }
1429                "labels" if !duplicate => service.labels = self.parse_labels(&service_field),
1430                "annotations" if !duplicate => service.annotations = self.parse_annotations(&service_field),
1431                "extra_hosts" if !duplicate => service.extra_hosts = self.parse_extra_hosts(&service_field),
1432                "user" if !duplicate => {
1433                    service.user = self.parse_string(&service_field, "service user").map(UserSpec::parse);
1434                }
1435                "userns_mode" if !duplicate => {
1436                    service.userns_mode = self
1437                        .parse_string(&service_field, "service user namespace mode")
1438                        .map(UserNamespaceMode::parse);
1439                }
1440                "group_add" if !duplicate => {
1441                    service.group_add = self.parse_string_sequence(&service_field, "service supplementary groups");
1442                }
1443                "cap_add" if !duplicate => service.cap_add = self.parse_cap_add(&service_field),
1444                "cap_drop" if !duplicate => service.cap_drop = self.parse_cap_drop(&service_field),
1445                "devices" if !duplicate => service.devices = self.parse_devices(&service_field),
1446                "dns" if !duplicate => service.dns = self.parse_dns(&service_field),
1447                "dns_opt" if !duplicate => service.dns_options = self.parse_dns_options(&service_field),
1448                "dns_search" if !duplicate => service.dns_search = self.parse_dns_search(&service_field),
1449                "expose" if !duplicate => service.expose = self.parse_expose(&service_field),
1450                "security_opt" if !duplicate => service.security_options = self.parse_security_options(&service_field),
1451                "working_dir" if !duplicate => {
1452                    service.working_dir = self.parse_string(&service_field, "service working directory");
1453                }
1454                "read_only" if !duplicate => {
1455                    service.read_only = self.parse_boolean(&service_field, "service read_only");
1456                }
1457                "pids_limit" if !duplicate => service.pids_limit = self.parse_pids_limit(&service_field),
1458                "shm_size" if !duplicate => service.shm_size = self.parse_shm_size(&service_field),
1459                "mem_limit" if !duplicate => service.mem_limit = self.parse_mem_limit(&service_field),
1460                "tmpfs" if !duplicate => service.tmpfs = self.parse_tmpfs(&service_field),
1461                "sysctls" if !duplicate => service.sysctls = self.parse_sysctls(&service_field),
1462                "pull_policy" if !duplicate => service.pull_policy = self.parse_pull_policy(&service_field),
1463                "restart" if !duplicate => service.restart = self.parse_restart_policy(&service_field),
1464                "stop_signal" if !duplicate => {
1465                    service.stop_signal = self.parse_string(&service_field, "service stop signal");
1466                }
1467                "stop_grace_period" if !duplicate => {
1468                    service.stop_grace_period = self.parse_stop_grace_period(&service_field);
1469                }
1470                "ulimits" if !duplicate => {
1471                    service.ulimits = self.parse_ulimits(&service_field);
1472                }
1473                "depends_on" if !duplicate => {
1474                    service.depends_on = self.parse_depends_on(&service_field);
1475                }
1476                "healthcheck" if !duplicate => {
1477                    service.healthcheck = self.parse_healthcheck(&service_field);
1478                }
1479                "build" if !duplicate => {
1480                    service.build = self.parse_build(&service_field);
1481                }
1482                "deploy" if !duplicate => {
1483                    service.deploy = self.parse_deploy(&service_field);
1484                }
1485                "ports" if !duplicate => {
1486                    service.ports = self.parse_service_ports(&service_field);
1487                }
1488                "volumes" if !duplicate => {
1489                    service.volumes = self.parse_service_volumes(&service_field);
1490                }
1491                "networks" if !duplicate => {
1492                    service.networks = self.parse_service_networks(&service_field);
1493                }
1494                "profiles" if !duplicate => {
1495                    service.profiles = self.parse_string_sequence(&service_field, "service profiles");
1496                }
1497                "configs" if !duplicate => {
1498                    service.configs = self.parse_config_grants(&service_field);
1499                }
1500                "secrets" if !duplicate => {
1501                    service.secrets = self.parse_secret_grants(&service_field);
1502                }
1503                name if name.starts_with("x-") => {
1504                    service.extension_fields.push(service_field.reference());
1505                }
1506                _ if duplicate => {}
1507                _ => service.unknown_fields.push(service_field.reference()),
1508            }
1509        }
1510        service
1511    }
1512
1513    fn parse_hostname(&mut self, field: &ParsedField) -> Option<Hostname> {
1514        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
1515            self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
1516            return None;
1517        };
1518        if ScalarValue::from_scalar(scalar).scalar_type() != ScalarType::String {
1519            self.expected(HOSTNAME_EXPECTED_STRING, field, "hostname must be a YAML string scalar");
1520            return None;
1521        }
1522        let span = span_from_position(self.source_id, scalar.byte_range());
1523        let hostname = Hostname::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
1524        if hostname.kind() == &HostnameKind::Invalid {
1525            self.diagnostics.push(
1526                Diagnostic::new(
1527                    HOSTNAME_INVALID,
1528                    Severity::Error,
1529                    "hostname must be an ASCII RFC-1123 name of 1 to 253 characters with dot-separated labels of 1 to 63 alphanumeric or hyphen characters",
1530                )
1531                .with_label(DiagnosticLabel::primary(span, "invalid service hostname"))
1532                .with_note("each label must start and end with an ASCII letter or digit"),
1533            );
1534        }
1535        Some(hostname)
1536    }
1537
1538    fn parse_image(&mut self, field: &ParsedField) -> Option<Located<ImageReference>> {
1539        self.parse_string(field, "service image")
1540            .map(|value| Located::new(ImageReference::parse(value.value), value.span))
1541    }
1542
1543    fn parse_cap_drop(&mut self, field: &ParsedField) -> Option<CapabilityDrop> {
1544        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1545            self.expected(
1546                CAP_DROP_EXPECTED_SEQUENCE,
1547                field,
1548                "cap_drop must be a sequence of string scalars",
1549            );
1550            return None;
1551        };
1552        let span = span_from_position(self.source_id, sequence.byte_range());
1553        let mut items = Vec::new();
1554        let mut seen = BTreeMap::new();
1555        for node in sequence.values() {
1556            let YamlNode::Scalar(scalar) = node else {
1557                self.unsupported_sequence_item(
1558                    CAP_DROP_EXPECTED_STRING,
1559                    &node,
1560                    field.span,
1561                    "cap_drop entries must be string scalars",
1562                );
1563                continue;
1564            };
1565            let scalar_type = ScalarValue::from_scalar(&scalar).scalar_type();
1566            if !matches!(
1567                scalar_type,
1568                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1569            ) {
1570                self.unsupported_sequence_item(
1571                    CAP_DROP_EXPECTED_STRING,
1572                    &YamlNode::Scalar(scalar),
1573                    field.span,
1574                    "cap_drop entries must be string scalars",
1575                );
1576                continue;
1577            }
1578            let item_span = span_from_position(self.source_id, scalar.byte_range());
1579            let value = scalar_string_from_source(&self.source, &scalar);
1580            if let Some(first) = seen.get(&value) {
1581                self.diagnostics.push(
1582                    Diagnostic::new(
1583                        CAP_DROP_DUPLICATE_ITEM,
1584                        Severity::Error,
1585                        "cap_drop entries must be unique exact strings",
1586                    )
1587                    .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
1588                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
1589                );
1590            } else {
1591                seen.insert(value.clone(), item_span);
1592            }
1593            items.push(CapabilityDropItem::new(Located::new(value, item_span)));
1594        }
1595        Some(CapabilityDrop::new(span, items))
1596    }
1597
1598    fn parse_cap_add(&mut self, field: &ParsedField) -> Option<CapabilityAdd> {
1599        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1600            self.expected(
1601                CAP_ADD_EXPECTED_SEQUENCE,
1602                field,
1603                "cap_add must be a sequence of string scalars",
1604            );
1605            return None;
1606        };
1607        let span = span_from_position(self.source_id, sequence.byte_range());
1608        let mut items = Vec::new();
1609        let mut seen = BTreeMap::new();
1610        for node in sequence.values() {
1611            let YamlNode::Scalar(scalar) = node else {
1612                self.unsupported_sequence_item(
1613                    CAP_ADD_EXPECTED_STRING,
1614                    &node,
1615                    field.span,
1616                    "cap_add entries must be string scalars",
1617                );
1618                continue;
1619            };
1620            let scalar_type = ScalarValue::from_scalar(&scalar).scalar_type();
1621            if !matches!(
1622                scalar_type,
1623                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1624            ) {
1625                self.unsupported_sequence_item(
1626                    CAP_ADD_EXPECTED_STRING,
1627                    &YamlNode::Scalar(scalar),
1628                    field.span,
1629                    "cap_add entries must be string scalars",
1630                );
1631                continue;
1632            }
1633            let item_span = span_from_position(self.source_id, scalar.byte_range());
1634            let value = scalar_string_from_source(&self.source, &scalar);
1635            if let Some(first) = seen.get(&value) {
1636                self.diagnostics.push(
1637                    Diagnostic::new(
1638                        CAP_ADD_DUPLICATE_ITEM,
1639                        Severity::Error,
1640                        "cap_add entries must be unique exact strings",
1641                    )
1642                    .with_label(DiagnosticLabel::primary(item_span, "duplicate capability string"))
1643                    .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
1644                );
1645            } else {
1646                seen.insert(value.clone(), item_span);
1647            }
1648            items.push(CapabilityAddItem::new(Located::new(value, item_span)));
1649        }
1650        Some(CapabilityAdd::new(span, items))
1651    }
1652
1653    fn parse_devices(&mut self, field: &ParsedField) -> Option<Devices> {
1654        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1655            self.expected(
1656                DEVICES_EXPECTED_SEQUENCE,
1657                field,
1658                "service devices must be a sequence of string scalars or mappings",
1659            );
1660            return None;
1661        };
1662        let span = span_from_position(self.source_id, sequence.byte_range());
1663        let mut devices = Vec::new();
1664        for node in sequence.values() {
1665            match node {
1666                YamlNode::Scalar(scalar)
1667                    if matches!(
1668                        ScalarValue::from_scalar(&scalar).scalar_type(),
1669                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1670                    ) =>
1671                {
1672                    let item_span = span_from_position(self.source_id, scalar.byte_range());
1673                    let raw = Located::new(scalar_string_from_source(&self.source, &scalar), item_span);
1674                    devices.push(Device::Short(ShortDevice::new(raw)));
1675                }
1676                YamlNode::Mapping(mapping) => devices.push(Device::Long(self.parse_long_device(&mapping))),
1677                other => self.unsupported_sequence_item(
1678                    DEVICE_EXPECTED_FORM,
1679                    &other,
1680                    field.span,
1681                    "service device must use string short syntax or mapping long syntax",
1682                ),
1683            }
1684        }
1685        Some(Devices::new(span, devices))
1686    }
1687
1688    fn parse_long_device(&mut self, mapping: &Mapping) -> LongDevice {
1689        let span = span_from_position(self.source_id, mapping.byte_range());
1690        let mut device = LongDevice::new(span);
1691        let mut seen = BTreeMap::new();
1692        for field in self.fields(mapping) {
1693            let duplicate = self.record_duplicate(&mut seen, &field);
1694            match field.name.value.as_str() {
1695                "source" if !duplicate => self
1696                    .parse_device_string(&field, "device source")
1697                    .into_iter()
1698                    .for_each(|value| device.set_source(value)),
1699                "target" if !duplicate => self
1700                    .parse_device_string(&field, "device target")
1701                    .into_iter()
1702                    .for_each(|value| device.set_target(value)),
1703                "permissions" if !duplicate => self
1704                    .parse_device_string(&field, "device permissions")
1705                    .into_iter()
1706                    .for_each(|value| device.set_permissions(value)),
1707                name if name.starts_with("x-") => device.push_extension(field.reference()),
1708                _ if duplicate => {}
1709                _ => device.push_unknown(field.reference()),
1710            }
1711        }
1712        if device.source().is_none() {
1713            self.missing(
1714                DEVICE_MISSING_SOURCE,
1715                span,
1716                "long service device is missing required string `source`",
1717            );
1718        }
1719        device
1720    }
1721
1722    fn parse_device_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
1723        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
1724            self.expected(
1725                DEVICE_EXPECTED_STRING,
1726                field,
1727                format!("{description} must be a string scalar"),
1728            );
1729            return None;
1730        };
1731        if !matches!(
1732            ScalarValue::from_scalar(scalar).scalar_type(),
1733            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1734        ) {
1735            self.expected(
1736                DEVICE_EXPECTED_STRING,
1737                field,
1738                format!("{description} must be a string scalar"),
1739            );
1740            return None;
1741        }
1742        Some(Located::new(
1743            scalar_string_from_source(&self.source, scalar),
1744            span_from_position(self.source_id, scalar.byte_range()),
1745        ))
1746    }
1747
1748    fn parse_dns(&mut self, field: &ParsedField) -> Option<Dns> {
1749        let value = field.value.as_ref()?;
1750        if let Some(scalar) = value.as_scalar() {
1751            if !matches!(
1752                ScalarValue::from_scalar(scalar).scalar_type(),
1753                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1754            ) {
1755                self.expected(
1756                    DNS_EXPECTED_FORM,
1757                    field,
1758                    "dns must be a string scalar or a sequence of string scalars",
1759                );
1760                return None;
1761            }
1762            let span = span_from_position(self.source_id, scalar.byte_range());
1763            return Some(Dns::new(
1764                span,
1765                DnsForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
1766            ));
1767        }
1768
1769        let Some(sequence) = value.as_sequence() else {
1770            self.expected(
1771                DNS_EXPECTED_FORM,
1772                field,
1773                "dns must be a string scalar or a sequence of string scalars",
1774            );
1775            return None;
1776        };
1777        let span = span_from_position(self.source_id, sequence.byte_range());
1778        let mut items = Vec::new();
1779        for node in sequence.values() {
1780            let YamlNode::Scalar(scalar) = node else {
1781                self.unsupported_sequence_item(
1782                    DNS_EXPECTED_STRING,
1783                    &node,
1784                    field.span,
1785                    "dns entries must be string scalars",
1786                );
1787                continue;
1788            };
1789            if !matches!(
1790                ScalarValue::from_scalar(&scalar).scalar_type(),
1791                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1792            ) {
1793                self.unsupported_sequence_item(
1794                    DNS_EXPECTED_STRING,
1795                    &YamlNode::Scalar(scalar),
1796                    field.span,
1797                    "dns entries must be string scalars",
1798                );
1799                continue;
1800            }
1801            let item_span = span_from_position(self.source_id, scalar.byte_range());
1802            items.push(Located::new(
1803                scalar_string_from_source(&self.source, &scalar),
1804                item_span,
1805            ));
1806        }
1807        Some(Dns::new(span, DnsForm::List(items)))
1808    }
1809
1810    fn parse_dns_options(&mut self, field: &ParsedField) -> Option<DnsOptions> {
1811        let value = field.value.as_ref()?;
1812        let Some(sequence) = value.as_sequence() else {
1813            self.expected(
1814                DNS_OPT_EXPECTED_SEQUENCE,
1815                field,
1816                "dns_opt must be a sequence of string scalars",
1817            );
1818            return None;
1819        };
1820        let span = span_from_position(self.source_id, sequence.byte_range());
1821        let mut items = Vec::new();
1822        let mut seen = BTreeSet::new();
1823        for node in sequence.values() {
1824            let YamlNode::Scalar(scalar) = node else {
1825                self.unsupported_sequence_item(
1826                    DNS_OPT_EXPECTED_STRING,
1827                    &node,
1828                    field.span,
1829                    "dns_opt entries must be string scalars",
1830                );
1831                continue;
1832            };
1833            if !matches!(
1834                ScalarValue::from_scalar(&scalar).scalar_type(),
1835                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1836            ) {
1837                self.unsupported_sequence_item(
1838                    DNS_OPT_EXPECTED_STRING,
1839                    &YamlNode::Scalar(scalar),
1840                    field.span,
1841                    "dns_opt entries must be string scalars",
1842                );
1843                continue;
1844            }
1845            let item_span = span_from_position(self.source_id, scalar.byte_range());
1846            let option = scalar_string_from_source(&self.source, &scalar);
1847            if !seen.insert(option.clone()) {
1848                self.diagnostics.push(
1849                    Diagnostic::new(
1850                        DNS_OPT_DUPLICATE_ITEM,
1851                        Severity::Warning,
1852                        "dns_opt entries must be unique exact strings",
1853                    )
1854                    .with_label(DiagnosticLabel::primary(item_span, "duplicate DNS option retained")),
1855                );
1856            }
1857            items.push(Located::new(option, item_span));
1858        }
1859        Some(DnsOptions::new(span, items))
1860    }
1861
1862    fn parse_dns_search(&mut self, field: &ParsedField) -> Option<DnsSearch> {
1863        let value = field.value.as_ref()?;
1864        if let Some(scalar) = value.as_scalar() {
1865            if !matches!(
1866                ScalarValue::from_scalar(scalar).scalar_type(),
1867                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1868            ) {
1869                self.expected(
1870                    DNS_SEARCH_EXPECTED_FORM,
1871                    field,
1872                    "dns_search must be a string scalar or a sequence of string scalars",
1873                );
1874                return None;
1875            }
1876            let span = span_from_position(self.source_id, scalar.byte_range());
1877            return Some(DnsSearch::new(
1878                span,
1879                DnsSearchForm::Scalar(Located::new(scalar_string_from_source(&self.source, scalar), span)),
1880            ));
1881        }
1882
1883        let Some(sequence) = value.as_sequence() else {
1884            self.expected(
1885                DNS_SEARCH_EXPECTED_FORM,
1886                field,
1887                "dns_search must be a string scalar or a sequence of string scalars",
1888            );
1889            return None;
1890        };
1891        let span = span_from_position(self.source_id, sequence.byte_range());
1892        let mut items = Vec::new();
1893        let mut seen = BTreeSet::new();
1894        for node in sequence.values() {
1895            let YamlNode::Scalar(scalar) = node else {
1896                self.unsupported_sequence_item(
1897                    DNS_SEARCH_EXPECTED_STRING,
1898                    &node,
1899                    field.span,
1900                    "dns_search entries must be string scalars",
1901                );
1902                continue;
1903            };
1904            if !matches!(
1905                ScalarValue::from_scalar(&scalar).scalar_type(),
1906                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
1907            ) {
1908                self.unsupported_sequence_item(
1909                    DNS_SEARCH_EXPECTED_STRING,
1910                    &YamlNode::Scalar(scalar),
1911                    field.span,
1912                    "dns_search entries must be string scalars",
1913                );
1914                continue;
1915            }
1916            let item_span = span_from_position(self.source_id, scalar.byte_range());
1917            let search = scalar_string_from_source(&self.source, &scalar);
1918            if !seen.insert(search.clone()) {
1919                self.diagnostics.push(
1920                    Diagnostic::new(
1921                        DNS_SEARCH_DUPLICATE_ITEM,
1922                        Severity::Warning,
1923                        "dns_search schema entries are unique, but duplicate merge behavior is ambiguous",
1924                    )
1925                    .with_label(DiagnosticLabel::primary(
1926                        item_span,
1927                        "duplicate DNS search domain retained",
1928                    )),
1929                );
1930            }
1931            items.push(Located::new(search, item_span));
1932        }
1933        Some(DnsSearch::new(span, DnsSearchForm::List(items)))
1934    }
1935
1936    fn parse_expose(&mut self, field: &ParsedField) -> Option<Expose> {
1937        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
1938            self.expected(
1939                EXPOSE_EXPECTED_SEQUENCE,
1940                field,
1941                "expose must be a sequence of string or number scalars",
1942            );
1943            return None;
1944        };
1945        let span = span_from_position(self.source_id, sequence.byte_range());
1946        let mut items = Vec::new();
1947        let mut seen = Vec::new();
1948        for node in sequence.values() {
1949            let YamlNode::Scalar(scalar) = node else {
1950                self.unsupported_sequence_item(
1951                    EXPOSE_EXPECTED_SCALAR,
1952                    &node,
1953                    field.span,
1954                    "expose entries must be string or number scalars",
1955                );
1956                continue;
1957            };
1958            let scalar_kind = match ScalarValue::from_scalar(&scalar).scalar_type() {
1959                ScalarType::Integer | ScalarType::Float => ExposeScalarKind::Number,
1960                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ExposeScalarKind::String,
1961                ScalarType::Null | ScalarType::Boolean => {
1962                    self.unsupported_sequence_item(
1963                        EXPOSE_EXPECTED_SCALAR,
1964                        &YamlNode::Scalar(scalar),
1965                        field.span,
1966                        "expose entries must be string or number scalars",
1967                    );
1968                    continue;
1969                }
1970            };
1971            let item_span = span_from_position(self.source_id, scalar.byte_range());
1972            let raw = scalar_string_from_source(&self.source, &scalar);
1973            if seen.contains(&(scalar_kind, raw.clone())) {
1974                self.diagnostics.push(
1975                    Diagnostic::new(
1976                        EXPOSE_DUPLICATE_ITEM,
1977                        Severity::Warning,
1978                        "expose entries must be unique by exact scalar identity",
1979                    )
1980                    .with_label(DiagnosticLabel::primary(
1981                        item_span,
1982                        "duplicate exposed-port item retained",
1983                    )),
1984                );
1985            } else {
1986                seen.push((scalar_kind, raw.clone()));
1987            }
1988            let item = ExposeItem::parse(Located::new(raw, item_span), scalar_kind);
1989            self.diagnose_expose_item(&item);
1990            items.push(item);
1991        }
1992        Some(Expose::new(span, items))
1993    }
1994
1995    fn diagnose_expose_item(&mut self, item: &ExposeItem) {
1996        match item.kind() {
1997            ExposeItemKind::Documented { .. } | ExposeItemKind::Expression => {}
1998            ExposeItemKind::Sctp { .. } | ExposeItemKind::UnknownProtocol { .. } => {
1999                self.diagnostics.push(
2000                    Diagnostic::new(
2001                        EXPOSE_PROVIDER_DEPENDENT,
2002                        Severity::Warning,
2003                        "expose protocol is outside the documented portable `tcp` and `udp` set",
2004                    )
2005                    .with_label(DiagnosticLabel::primary(
2006                        item.span(),
2007                        "provider-dependent exposed-port protocol retained",
2008                    ))
2009                    .with_note("ComposeLens does not normalize or reject the raw protocol spelling"),
2010                );
2011            }
2012            ExposeItemKind::Malformed => {
2013                self.diagnostics.push(
2014                    Diagnostic::new(
2015                        EXPOSE_INVALID_ITEM,
2016                        Severity::Error,
2017                        "expose item must be a decimal port or range with an optional protocol",
2018                    )
2019                    .with_label(DiagnosticLabel::primary(
2020                        item.span(),
2021                        "malformed exposed-port item retained",
2022                    ))
2023                    .with_note("use `PORT`, `START-END`, `PORT/tcp`, or `PORT/udp` for documented portable syntax"),
2024                );
2025            }
2026        }
2027    }
2028
2029    fn parse_security_options(&mut self, field: &ParsedField) -> Option<SecurityOptions> {
2030        let value = field.value.as_ref()?;
2031        let Some(sequence) = value.as_sequence() else {
2032            self.expected(
2033                SECURITY_OPT_EXPECTED_SEQUENCE,
2034                field,
2035                "security_opt must be a sequence of string scalars",
2036            );
2037            return None;
2038        };
2039        let span = span_from_position(self.source_id, sequence.byte_range());
2040        let mut items = Vec::new();
2041        let mut candidates = SecurityOptionCandidateCounts::default();
2042        for node in sequence.values() {
2043            let YamlNode::Scalar(scalar) = node else {
2044                self.unsupported_sequence_item(
2045                    SECURITY_OPT_EXPECTED_STRING,
2046                    &node,
2047                    field.span,
2048                    "security_opt entries must be string scalars",
2049                );
2050                continue;
2051            };
2052            if !matches!(
2053                ScalarValue::from_scalar(&scalar).scalar_type(),
2054                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2055            ) {
2056                self.unsupported_sequence_item(
2057                    SECURITY_OPT_EXPECTED_STRING,
2058                    &YamlNode::Scalar(scalar),
2059                    field.span,
2060                    "security_opt entries must be string scalars",
2061                );
2062                continue;
2063            }
2064            let item_span = span_from_position(self.source_id, scalar.byte_range());
2065            let raw = scalar_string_from_source(&self.source, &scalar);
2066            let item = SecurityOptionItem::parse(Located::new(raw, item_span));
2067            self.diagnose_security_option_item(item.kind(), item_span, &mut candidates);
2068            items.push(item);
2069        }
2070        Some(SecurityOptions::new(span, items))
2071    }
2072
2073    fn diagnose_security_option_item(
2074        &mut self,
2075        kind: &SecurityOptionKind,
2076        span: SourceSpan,
2077        candidates: &mut SecurityOptionCandidateCounts,
2078    ) {
2079        let diagnostic = match kind {
2080            SecurityOptionKind::AppArmor { .. } => {
2081                candidates.apparmor += 1;
2082                (candidates.apparmor > 1).then(|| {
2083                    Diagnostic::new(
2084                        SECURITY_OPT_APPARMOR_CONFLICT,
2085                        Severity::Warning,
2086                        "multiple AppArmor candidates are retained; a consumer must resolve the conflict explicitly",
2087                    )
2088                    .with_label(DiagnosticLabel::primary(span, "additional AppArmor candidate retained"))
2089                })
2090            }
2091            SecurityOptionKind::AppArmorNearMiss => Some(
2092                Diagnostic::new(
2093                    SECURITY_OPT_APPARMOR_NEAR_MISS,
2094                    Severity::Warning,
2095                    "AppArmor candidates require exact lowercase `apparmor=<profile>` spelling without whitespace",
2096                )
2097                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2098            ),
2099            SecurityOptionKind::Seccomp { .. } => {
2100                candidates.seccomp += 1;
2101                (candidates.seccomp > 1).then(|| {
2102                    Diagnostic::new(
2103                        SECURITY_OPT_SECCOMP_CONFLICT,
2104                        Severity::Warning,
2105                        "multiple seccomp candidates are retained; a consumer must resolve the conflict explicitly",
2106                    )
2107                    .with_label(DiagnosticLabel::primary(span, "additional seccomp candidate retained"))
2108                })
2109            }
2110            SecurityOptionKind::SeccompNearMiss => Some(
2111                Diagnostic::new(
2112                    SECURITY_OPT_SECCOMP_NEAR_MISS,
2113                    Severity::Warning,
2114                    "seccomp candidates require exact lowercase `seccomp=<profile>` spelling without whitespace",
2115                )
2116                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2117            ),
2118            SecurityOptionKind::NoNewPrivileges { .. } => {
2119                candidates.no_new_privileges += 1;
2120                (candidates.no_new_privileges > 1).then(|| {
2121                    Diagnostic::new(
2122                        SECURITY_OPT_NO_NEW_PRIVILEGES_CONFLICT,
2123                        Severity::Warning,
2124                        "multiple no-new-privileges candidates are retained; a consumer must resolve the conflict explicitly",
2125                    )
2126                    .with_label(DiagnosticLabel::primary(
2127                        span,
2128                        "additional no-new-privileges candidate retained",
2129                    ))
2130                })
2131            }
2132            SecurityOptionKind::NoNewPrivilegesNearMiss => Some(
2133                Diagnostic::new(
2134                    SECURITY_OPT_NO_NEW_PRIVILEGES_NEAR_MISS,
2135                    Severity::Warning,
2136                    "no-new-privileges candidates require exact lowercase `no-new-privileges:true` or `no-new-privileges:false` spelling without whitespace",
2137                )
2138                .with_label(DiagnosticLabel::primary(span, "raw near-miss security option retained")),
2139            ),
2140            SecurityOptionKind::Mask { .. }
2141            | SecurityOptionKind::MaskNearMiss
2142            | SecurityOptionKind::Unmask { .. }
2143            | SecurityOptionKind::UnmaskNearMiss => security_path_option_diagnostic(kind, span),
2144            SecurityOptionKind::SecurityLabelDisable { .. }
2145            | SecurityOptionKind::SecurityLabelDisableNearMiss
2146            | SecurityOptionKind::SecurityLabelFileType { .. }
2147            | SecurityOptionKind::SecurityLabelFileTypeNearMiss
2148            | SecurityOptionKind::SecurityLabelLevel { .. }
2149            | SecurityOptionKind::SecurityLabelLevelNearMiss
2150            | SecurityOptionKind::SecurityLabelNested { .. }
2151            | SecurityOptionKind::SecurityLabelNestedNearMiss
2152            | SecurityOptionKind::SecurityLabelType { .. }
2153            | SecurityOptionKind::SecurityLabelTypeNearMiss => {
2154                authored_security_label_diagnostic(kind, span, candidates)
2155            }
2156            SecurityOptionKind::Empty => Some(
2157                Diagnostic::new(
2158                    SECURITY_OPT_EMPTY_ITEM,
2159                    Severity::Error,
2160                    "security_opt entries must not be empty strings",
2161                )
2162                .with_label(DiagnosticLabel::primary(span, "empty security option retained")),
2163            ),
2164            SecurityOptionKind::Expression | SecurityOptionKind::Other => None,
2165        };
2166        if let Some(diagnostic) = diagnostic {
2167            self.diagnostics.push(diagnostic);
2168        }
2169    }
2170
2171    fn parse_tmpfs(&mut self, field: &ParsedField) -> Option<Tmpfs> {
2172        let value = field.value.as_ref()?;
2173        if let Some(scalar) = value.as_scalar() {
2174            if !matches!(
2175                ScalarValue::from_scalar(scalar).scalar_type(),
2176                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2177            ) {
2178                self.expected(
2179                    TMPFS_EXPECTED_FORM,
2180                    field,
2181                    "tmpfs must be a string scalar or a sequence of string scalars",
2182                );
2183                return None;
2184            }
2185            let span = span_from_position(self.source_id, scalar.byte_range());
2186            let item = TmpfsItem::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
2187            self.diagnose_tmpfs_item(&item);
2188            return Some(Tmpfs::new(span, TmpfsForm::Scalar(item)));
2189        }
2190
2191        let Some(sequence) = value.as_sequence() else {
2192            self.expected(
2193                TMPFS_EXPECTED_FORM,
2194                field,
2195                "tmpfs must be a string scalar or a sequence of string scalars",
2196            );
2197            return None;
2198        };
2199        let span = span_from_position(self.source_id, sequence.byte_range());
2200        let mut items = Vec::new();
2201        for node in sequence.values() {
2202            let YamlNode::Scalar(scalar) = node else {
2203                self.unsupported_sequence_item(
2204                    TMPFS_EXPECTED_STRING,
2205                    &node,
2206                    field.span,
2207                    "tmpfs entries must be string scalars",
2208                );
2209                continue;
2210            };
2211            if !matches!(
2212                ScalarValue::from_scalar(&scalar).scalar_type(),
2213                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2214            ) {
2215                self.unsupported_sequence_item(
2216                    TMPFS_EXPECTED_STRING,
2217                    &YamlNode::Scalar(scalar),
2218                    field.span,
2219                    "tmpfs entries must be string scalars",
2220                );
2221                continue;
2222            }
2223            let item_span = span_from_position(self.source_id, scalar.byte_range());
2224            let raw = scalar_string_from_source(&self.source, &scalar);
2225            let item = TmpfsItem::parse(Located::new(raw, item_span));
2226            self.diagnose_tmpfs_item(&item);
2227            items.push(item);
2228        }
2229        Some(Tmpfs::new(span, TmpfsForm::List(items)))
2230    }
2231
2232    fn diagnose_tmpfs_item(&mut self, item: &TmpfsItem) {
2233        if item.kind() != TmpfsItemKind::ProviderDependent {
2234            return;
2235        }
2236        self.diagnostics.push(
2237            Diagnostic::new(
2238                TMPFS_PROVIDER_DEPENDENT,
2239                Severity::Warning,
2240                "tmpfs item is malformed or uses provider- or target-specific options",
2241            )
2242            .with_label(DiagnosticLabel::primary(
2243                item.span(),
2244                "provider-dependent temporary-filesystem item",
2245            ))
2246            .with_note("use a non-empty path with only non-empty `mode`, `uid`, or `gid` assignments for documented portable syntax"),
2247        );
2248    }
2249
2250    fn parse_sysctls(&mut self, field: &ParsedField) -> Option<Sysctls> {
2251        match field.value.as_ref() {
2252            Some(YamlNode::Mapping(mapping)) => {
2253                let span = span_from_position(self.source_id, mapping.byte_range());
2254                let mut entries = Vec::new();
2255                let mut seen = BTreeMap::new();
2256                for entry in self.fields(mapping) {
2257                    if self.record_duplicate(&mut seen, &entry) {
2258                        continue;
2259                    }
2260                    if entry.name.value.is_empty() {
2261                        self.diagnostics.push(
2262                            Diagnostic::new(
2263                                SYSCTLS_EMPTY_KEY,
2264                                Severity::Error,
2265                                "sysctls mapping keys must not be empty",
2266                            )
2267                            .with_label(DiagnosticLabel::primary(entry.name.span, "empty sysctl name")),
2268                        );
2269                        continue;
2270                    }
2271                    if entry.value.as_ref().is_some_and(|value| value.as_scalar().is_none()) {
2272                        self.diagnostics.push(
2273                            Diagnostic::new(
2274                                SYSCTLS_EXPECTED_SCALAR,
2275                                Severity::Error,
2276                                "sysctls mapping values must be scalar strings, numbers, booleans, or null",
2277                            )
2278                            .with_label(DiagnosticLabel::primary(
2279                                entry.value_span.unwrap_or(entry.span),
2280                                "non-scalar sysctl value",
2281                            )),
2282                        );
2283                        continue;
2284                    }
2285                    let Some(value) = self.parse_compose_scalar(&entry, "sysctls mapping values must be scalars")
2286                    else {
2287                        continue;
2288                    };
2289                    entries.push(KeyValueEntry::new(entry.name, value, entry.span));
2290                }
2291                Some(Sysctls::new(span, SysctlsForm::Map(entries)))
2292            }
2293            Some(YamlNode::Sequence(sequence)) => {
2294                let span = span_from_position(self.source_id, sequence.byte_range());
2295                let mut items = Vec::new();
2296                let mut seen = BTreeMap::new();
2297                for node in sequence.values() {
2298                    let YamlNode::Scalar(scalar) = node else {
2299                        self.unsupported_sequence_item(
2300                            SYSCTLS_EXPECTED_STRING,
2301                            &node,
2302                            field.span,
2303                            "sysctls list entries must be YAML string scalars",
2304                        );
2305                        continue;
2306                    };
2307                    if !matches!(
2308                        ScalarValue::from_scalar(&scalar).scalar_type(),
2309                        ScalarType::String | ScalarType::Timestamp | ScalarType::Regex
2310                    ) {
2311                        self.unsupported_sequence_item(
2312                            SYSCTLS_EXPECTED_STRING,
2313                            &YamlNode::Scalar(scalar),
2314                            field.span,
2315                            "sysctls list entries must be YAML string scalars",
2316                        );
2317                        continue;
2318                    }
2319                    let item_span = span_from_position(self.source_id, scalar.byte_range());
2320                    let value = scalar_string_from_source(&self.source, &scalar);
2321                    if let Some(first) = seen.get(&value) {
2322                        self.diagnostics.push(
2323                            Diagnostic::new(
2324                                SYSCTLS_DUPLICATE_ITEM,
2325                                Severity::Error,
2326                                "sysctls list entries must be unique exact strings",
2327                            )
2328                            .with_label(DiagnosticLabel::primary(item_span, "duplicate sysctl string"))
2329                            .with_label(DiagnosticLabel::secondary(*first, "first identical string")),
2330                        );
2331                    } else {
2332                        seen.insert(value.clone(), item_span);
2333                    }
2334                    items.push(Located::new(value, item_span));
2335                }
2336                Some(Sysctls::new(span, SysctlsForm::List(items)))
2337            }
2338            _ => {
2339                self.expected(
2340                    SYSCTLS_EXPECTED_FORM,
2341                    field,
2342                    "sysctls must be a mapping or a sequence of string scalars",
2343                );
2344                None
2345            }
2346        }
2347    }
2348
2349    fn parse_restart_policy(&mut self, field: &ParsedField) -> Option<RestartPolicy> {
2350        let value = self.parse_string(field, "service restart policy")?;
2351        let policy = RestartPolicy::parse(value);
2352        if !policy.is_valid() {
2353            self.diagnostics.push(
2354                Diagnostic::new(
2355                    RESTART_INVALID_POLICY,
2356                    Severity::Error,
2357                    "restart must be `no`, `always`, `on-failure[:max-retries]`, `unless-stopped`, or interpolation",
2358                )
2359                .with_label(DiagnosticLabel::primary(
2360                    policy.raw().span(),
2361                    "invalid service restart policy",
2362                )),
2363            );
2364        }
2365        Some(policy)
2366    }
2367
2368    fn parse_pids_limit(&mut self, field: &ParsedField) -> Option<PidsLimit> {
2369        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2370            self.expected(
2371                PIDS_LIMIT_EXPECTED_VALUE,
2372                field,
2373                "pids_limit must be a number or string scalar",
2374            );
2375            return None;
2376        };
2377        if matches!(
2378            ScalarValue::from_scalar(scalar).scalar_type(),
2379            ScalarType::Boolean | ScalarType::Null
2380        ) {
2381            self.expected(
2382                PIDS_LIMIT_EXPECTED_VALUE,
2383                field,
2384                "pids_limit must be a number or string scalar",
2385            );
2386            return None;
2387        }
2388        let span = span_from_position(self.source_id, scalar.byte_range());
2389        let limit = PidsLimit::parse(Located::new(scalar_string_from_source(&self.source, scalar), span));
2390        match limit.kind() {
2391            PidsLimitKind::Zero => self.diagnostics.push(
2392                Diagnostic::new(
2393                    PIDS_LIMIT_AMBIGUOUS_ZERO,
2394                    Severity::Warning,
2395                    "pids_limit zero is preserved as an ambiguous and unportable native state",
2396                )
2397                .with_label(DiagnosticLabel::primary(span, "ambiguous zero PID limit")),
2398            ),
2399            PidsLimitKind::Other => self.diagnostics.push(
2400                Diagnostic::new(
2401                    PIDS_LIMIT_INVALID,
2402                    Severity::Error,
2403                    "pids_limit must be `-1`, a positive integral decimal, or interpolation",
2404                )
2405                .with_label(DiagnosticLabel::primary(span, "unsupported service PID limit")),
2406            ),
2407            _ => {}
2408        }
2409        Some(limit)
2410    }
2411
2412    fn parse_shm_size(&mut self, field: &ParsedField) -> Option<ShmSize> {
2413        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2414            self.expected(
2415                SHM_SIZE_EXPECTED_VALUE,
2416                field,
2417                "shm_size must be a YAML number or string scalar",
2418            );
2419            return None;
2420        };
2421        let scalar_kind = match ScalarValue::from_scalar(scalar).scalar_type() {
2422            ScalarType::Integer | ScalarType::Float => ShmSizeScalarKind::Number,
2423            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => ShmSizeScalarKind::String,
2424            ScalarType::Boolean | ScalarType::Null => {
2425                self.expected(
2426                    SHM_SIZE_EXPECTED_VALUE,
2427                    field,
2428                    "shm_size must be a YAML number or string scalar",
2429                );
2430                return None;
2431            }
2432        };
2433        let span = span_from_position(self.source_id, scalar.byte_range());
2434        let size = ShmSize::parse(
2435            Located::new(scalar_string_from_source(&self.source, scalar), span),
2436            scalar_kind,
2437        );
2438        self.diagnose_shm_size(&size);
2439        Some(size)
2440    }
2441
2442    fn diagnose_shm_size(&mut self, size: &ShmSize) {
2443        let (code, message, label, note) = match size.kind() {
2444            ShmSizeKind::Zero { .. } => (
2445                SHM_SIZE_AMBIGUOUS_ZERO,
2446                "shm_size zero is preserved because Compose does not define its semantics",
2447                "ambiguous zero shared-memory size",
2448                "choose a positive size with an explicit documented lowercase unit",
2449            ),
2450            ShmSizeKind::ProviderDependentNumber => (
2451                SHM_SIZE_PROVIDER_DEPENDENT_NUMBER,
2452                "numeric shm_size is schema-accepted but lacks a documented explicit unit",
2453                "provider-dependent numeric shared-memory size",
2454                "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for portable intent",
2455            ),
2456            ShmSizeKind::ProviderDependentString => (
2457                SHM_SIZE_PROVIDER_DEPENDENT_STRING,
2458                "string shm_size is schema-accepted but falls outside the documented lowercase suffix family",
2459                "provider-dependent string shared-memory size",
2460                "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
2461            ),
2462            ShmSizeKind::Documented { .. } | ShmSizeKind::Expression => return,
2463        };
2464        self.diagnostics.push(
2465            Diagnostic::new(code, Severity::Warning, message)
2466                .with_label(DiagnosticLabel::primary(size.raw().span(), label))
2467                .with_note(note),
2468        );
2469    }
2470
2471    fn parse_mem_limit(&mut self, field: &ParsedField) -> Option<MemLimit> {
2472        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
2473            self.expected(
2474                MEM_LIMIT_EXPECTED_VALUE,
2475                field,
2476                "mem_limit must be a YAML number or string scalar",
2477            );
2478            return None;
2479        };
2480        let scalar_kind = match ScalarValue::from_scalar(scalar).scalar_type() {
2481            ScalarType::Integer | ScalarType::Float => MemLimitScalarKind::Number,
2482            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => MemLimitScalarKind::String,
2483            ScalarType::Boolean | ScalarType::Null => {
2484                self.expected(
2485                    MEM_LIMIT_EXPECTED_VALUE,
2486                    field,
2487                    "mem_limit must be a YAML number or string scalar",
2488                );
2489                return None;
2490            }
2491        };
2492        let span = span_from_position(self.source_id, scalar.byte_range());
2493        let limit = MemLimit::parse(
2494            Located::new(scalar_string_from_source(&self.source, scalar), span),
2495            scalar_kind,
2496        );
2497        self.diagnose_mem_limit(&limit);
2498        Some(limit)
2499    }
2500
2501    fn diagnose_mem_limit(&mut self, limit: &MemLimit) {
2502        let (code, message, label, note) = match limit.kind() {
2503            MemLimitKind::Zero { .. } => (
2504                MEM_LIMIT_AMBIGUOUS_ZERO,
2505                "mem_limit zero is preserved without inferring portable runtime behavior",
2506                "ambiguous zero memory limit",
2507                "choose a positive size with an explicit documented lowercase unit",
2508            ),
2509            MemLimitKind::SchemaNumber => (
2510                MEM_LIMIT_SCHEMA_NUMBER,
2511                "numeric mem_limit is schema-accepted but lacks a documented explicit unit",
2512                "schema-only numeric memory limit",
2513                "use a positive quoted value with `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` for explicit intent",
2514            ),
2515            MemLimitKind::ProviderDependentString => (
2516                MEM_LIMIT_PROVIDER_DEPENDENT_STRING,
2517                "string mem_limit is schema-accepted but falls outside the documented lowercase suffix family",
2518                "provider-dependent string memory limit",
2519                "use an explicit lowercase `b`, `k`, `kb`, `m`, `mb`, `g`, or `gb` suffix when that is the intended unit",
2520            ),
2521            MemLimitKind::Documented { .. } | MemLimitKind::Expression => return,
2522        };
2523        self.diagnostics.push(
2524            Diagnostic::new(code, Severity::Warning, message)
2525                .with_label(DiagnosticLabel::primary(limit.raw().span(), label))
2526                .with_note(note),
2527        );
2528    }
2529
2530    fn parse_pull_policy(&mut self, field: &ParsedField) -> Option<PullPolicy> {
2531        let value = self.parse_string(field, "service pull policy")?;
2532        let policy = PullPolicy::parse(value);
2533        if !policy.is_recognized() {
2534            self.diagnostics.push(
2535                Diagnostic::new(
2536                    PULL_POLICY_INVALID,
2537                    Severity::Error,
2538                    "pull_policy must be a documented Compose policy, the retained `if_not_present` alias, schema-only `refresh`, an `every_` interval matching integer `w`, `d`, `h`, `m`, and `s` components, or interpolation",
2539                )
2540                .with_label(DiagnosticLabel::primary(
2541                    policy.raw().span(),
2542                    "invalid or provider-specific service pull policy",
2543                )),
2544            );
2545        }
2546        Some(policy)
2547    }
2548
2549    fn parse_stop_grace_period(&mut self, field: &ParsedField) -> Option<Located<StopGracePeriod>> {
2550        let value = self.parse_string(field, "service stop grace period")?;
2551        let period = StopGracePeriod::parse(value.value);
2552        if !period.is_valid() {
2553            self.diagnostics.push(
2554                Diagnostic::new(
2555                    STOP_GRACE_PERIOD_INVALID,
2556                    Severity::Error,
2557                    "stop_grace_period must match the ComposeLens duration policy using `us`, `ms`, `s`, `m`, or `h`, or contain an interpolation marker",
2558                )
2559                .with_label(DiagnosticLabel::primary(
2560                    value.span,
2561                    "invalid service stop grace period",
2562                )),
2563            );
2564        }
2565        Some(Located::new(period, value.span))
2566    }
2567
2568    fn parse_command(&mut self, field: &ParsedField) -> Option<Command> {
2569        match field.value.as_ref() {
2570            Some(YamlNode::Scalar(scalar)) => {
2571                let span = span_from_position(self.source_id, scalar.byte_range());
2572                if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
2573                    Some(Command::Null(span))
2574                } else {
2575                    Some(Command::String(Located::new(
2576                        scalar_string_from_source(&self.source, scalar),
2577                        span,
2578                    )))
2579                }
2580            }
2581            Some(YamlNode::Sequence(sequence)) => {
2582                let span = span_from_position(self.source_id, sequence.byte_range());
2583                let values =
2584                    self.parse_scalar_nodes(sequence.values(), field.span, "command list items must be scalars");
2585                Some(Command::List { span, values })
2586            }
2587            _ => {
2588                self.expected(
2589                    EXPECTED_FIELD_FORM,
2590                    field,
2591                    "command must be null, a scalar, or a sequence",
2592                );
2593                None
2594            }
2595        }
2596    }
2597
2598    fn parse_entrypoint(&mut self, field: &ParsedField) -> Option<Entrypoint> {
2599        match field.value.as_ref() {
2600            Some(YamlNode::Scalar(scalar)) => {
2601                let span = span_from_position(self.source_id, scalar.byte_range());
2602                if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
2603                    Some(Entrypoint::Null(span))
2604                } else {
2605                    Some(Entrypoint::String(Located::new(
2606                        scalar_string_from_source(&self.source, scalar),
2607                        span,
2608                    )))
2609                }
2610            }
2611            Some(YamlNode::Sequence(sequence)) => {
2612                let span = span_from_position(self.source_id, sequence.byte_range());
2613                let values =
2614                    self.parse_scalar_nodes(sequence.values(), field.span, "entrypoint list items must be scalars");
2615                Some(Entrypoint::List { span, values })
2616            }
2617            _ => {
2618                self.expected(
2619                    EXPECTED_FIELD_FORM,
2620                    field,
2621                    "entrypoint must be null, a scalar, or a sequence",
2622                );
2623                None
2624            }
2625        }
2626    }
2627
2628    fn parse_environment(&mut self, field: &ParsedField) -> Option<Environment> {
2629        match field.value.as_ref() {
2630            Some(YamlNode::Sequence(sequence)) => {
2631                let span = span_from_position(self.source_id, sequence.byte_range());
2632                let entries = self
2633                    .parse_scalar_nodes(sequence.values(), field.span, "environment list items must be scalars")
2634                    .into_iter()
2635                    .map(EnvironmentListEntry::parse)
2636                    .collect();
2637                Some(Environment::List { span, entries })
2638            }
2639            Some(YamlNode::Mapping(mapping)) => {
2640                let span = span_from_position(self.source_id, mapping.byte_range());
2641                let entries = self.parse_environment_map(mapping);
2642                Some(Environment::Map { span, entries })
2643            }
2644            _ => {
2645                self.expected(EXPECTED_FIELD_FORM, field, "environment must be a sequence or mapping");
2646                None
2647            }
2648        }
2649    }
2650
2651    fn parse_environment_map(&mut self, mapping: &Mapping) -> Vec<EnvironmentMapEntry> {
2652        let mut entries = Vec::new();
2653        let mut seen = BTreeMap::new();
2654        for field in self.fields(mapping) {
2655            if self.record_duplicate(&mut seen, &field) {
2656                continue;
2657            }
2658            let value = self.parse_compose_scalar(&field, "environment values must be scalars");
2659            if let Some(value) = value {
2660                entries.push(EnvironmentMapEntry::new(field.name, value, field.span));
2661            }
2662        }
2663        entries
2664    }
2665
2666    fn parse_environment_files(&mut self, field: &ParsedField) -> Vec<EnvironmentFile> {
2667        match field.value.as_ref() {
2668            Some(YamlNode::Scalar(_)) => self
2669                .parse_string(field, "service environment-file path")
2670                .map(EnvironmentFile::Short)
2671                .into_iter()
2672                .collect(),
2673            Some(YamlNode::Sequence(sequence)) => sequence
2674                .values()
2675                .filter_map(|value| match value {
2676                    YamlNode::Scalar(scalar) => {
2677                        let span = span_from_position(self.source_id, scalar.byte_range());
2678                        Some(EnvironmentFile::Short(Located::new(
2679                            scalar_string_from_source(&self.source, &scalar),
2680                            span,
2681                        )))
2682                    }
2683                    YamlNode::Mapping(mapping) => Some(EnvironmentFile::Long(Box::new(
2684                        self.parse_long_environment_file(&mapping),
2685                    ))),
2686                    _ => {
2687                        self.diagnostics.push(
2688                            Diagnostic::new(
2689                                ENVIRONMENT_FILE_EXPECTED_FORM,
2690                                Severity::Error,
2691                                "env_file item must use scalar short syntax or mapping long syntax",
2692                            )
2693                            .with_label(DiagnosticLabel::primary(
2694                                node_span(self.source_id, &value).unwrap_or(field.span),
2695                                "invalid environment-file item",
2696                            )),
2697                        );
2698                        None
2699                    }
2700                })
2701                .collect(),
2702            _ => {
2703                self.expected(
2704                    EXPECTED_FIELD_FORM,
2705                    field,
2706                    "env_file must be a scalar path or a sequence of short/long entries",
2707                );
2708                Vec::new()
2709            }
2710        }
2711    }
2712
2713    fn parse_long_environment_file(&mut self, mapping: &Mapping) -> LongEnvironmentFile {
2714        let span = span_from_position(self.source_id, mapping.byte_range());
2715        let mut environment_file = LongEnvironmentFile::new(span);
2716        let mut seen = BTreeMap::new();
2717        for field in self.fields(mapping) {
2718            let duplicate = self.record_duplicate(&mut seen, &field);
2719            match field.name.value.as_str() {
2720                "path" if !duplicate => self
2721                    .parse_string(&field, "environment-file path")
2722                    .into_iter()
2723                    .for_each(|value| environment_file.set_path(value)),
2724                "required" if !duplicate => self
2725                    .parse_boolean(&field, "environment-file required option")
2726                    .into_iter()
2727                    .for_each(|value| environment_file.set_required(value)),
2728                "format" if !duplicate => {
2729                    if let Some(raw) = self.parse_string(&field, "environment-file format") {
2730                        let format = EnvironmentFileFormat::parse(raw);
2731                        if !format.is_valid() {
2732                            self.diagnostics.push(
2733                                Diagnostic::new(
2734                                    ENVIRONMENT_FILE_INVALID_FORMAT,
2735                                    Severity::Error,
2736                                    "environment-file format must be `raw` or interpolation",
2737                                )
2738                                .with_label(DiagnosticLabel::primary(format.raw().span(), "invalid format")),
2739                            );
2740                        }
2741                        environment_file.set_format(format);
2742                    }
2743                }
2744                name if name.starts_with("x-") => environment_file.push_extension(field.reference()),
2745                _ if duplicate => {}
2746                _ => environment_file.push_unknown(field.reference()),
2747            }
2748        }
2749        if environment_file.path().is_none() {
2750            self.missing(
2751                ENVIRONMENT_FILE_MISSING_PATH,
2752                span,
2753                "long environment-file entry is missing `path`",
2754            );
2755        }
2756        environment_file
2757    }
2758
2759    fn parse_extra_hosts(&mut self, field: &ParsedField) -> Option<ExtraHosts> {
2760        match field.value.as_ref() {
2761            Some(YamlNode::Sequence(sequence)) => {
2762                let span = span_from_position(self.source_id, sequence.byte_range());
2763                let entries = self
2764                    .parse_scalar_nodes(sequence.values(), field.span, "extra_hosts entries must be scalars")
2765                    .into_iter()
2766                    .map(|raw| {
2767                        let entry = ShortExtraHost::parse(raw);
2768                        if !entry.is_complete() {
2769                            self.diagnostics.push(
2770                                Diagnostic::new(
2771                                    EXTRA_HOST_INVALID_ENTRY,
2772                                    Severity::Error,
2773                                    "short extra_hosts entry must contain a hostname and address",
2774                                )
2775                                .with_label(DiagnosticLabel::primary(
2776                                    entry.raw().span(),
2777                                    "missing separator or value",
2778                                )),
2779                            );
2780                        }
2781                        entry
2782                    })
2783                    .collect();
2784                Some(ExtraHosts::Short { span, entries })
2785            }
2786            Some(YamlNode::Mapping(mapping)) => {
2787                let span = span_from_position(self.source_id, mapping.byte_range());
2788                let mut entries = Vec::new();
2789                let mut seen = BTreeMap::new();
2790                for host in self.fields(mapping) {
2791                    if self.record_duplicate(&mut seen, &host) {
2792                        continue;
2793                    }
2794                    if let Some(address) = self.parse_string(&host, "extra host address") {
2795                        let address = Located::new(HostAddress::parse(address.value), address.span);
2796                        entries.push(LongExtraHost::new(host.name, address, host.span));
2797                    }
2798                }
2799                Some(ExtraHosts::Long { span, entries })
2800            }
2801            _ => {
2802                self.expected(EXPECTED_FIELD_FORM, field, "extra_hosts must be a sequence or mapping");
2803                None
2804            }
2805        }
2806    }
2807
2808    fn parse_ulimits(&mut self, field: &ParsedField) -> Option<Ulimits> {
2809        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
2810            self.expected(EXPECTED_MAPPING, field, "ulimits must be a mapping");
2811            return None;
2812        };
2813        let span = span_from_position(self.source_id, mapping.byte_range());
2814        let mut entries = Vec::new();
2815        let mut seen = BTreeMap::new();
2816        for limit in self.fields(mapping) {
2817            if self.record_duplicate(&mut seen, &limit) {
2818                continue;
2819            }
2820            if !valid_ulimit_name(limit.name.value()) {
2821                self.diagnostics.push(
2822                    Diagnostic::new(
2823                        ULIMIT_INVALID_NAME,
2824                        Severity::Error,
2825                        "ulimit names must contain only lowercase ASCII letters",
2826                    )
2827                    .with_label(DiagnosticLabel::primary(limit.name.span, "invalid ulimit name")),
2828                );
2829            }
2830            let value = match limit.value.as_ref() {
2831                Some(YamlNode::Scalar(_)) => self.parse_limit_value(&limit, "ulimit value").map(UlimitValue::Single),
2832                Some(YamlNode::Mapping(range)) => Some(UlimitValue::Range(self.parse_ulimit_range(range))),
2833                _ => {
2834                    self.expected(
2835                        EXPECTED_FIELD_FORM,
2836                        &limit,
2837                        "ulimit must be a scalar or soft/hard mapping",
2838                    );
2839                    None
2840                }
2841            };
2842            if let Some(value) = value {
2843                entries.push(Ulimit::new(limit.name, limit.span, value));
2844            }
2845        }
2846        Some(Ulimits::new(span, entries))
2847    }
2848
2849    fn parse_ulimit_range(&mut self, mapping: &Mapping) -> UlimitRange {
2850        let span = span_from_position(self.source_id, mapping.byte_range());
2851        let mut range = UlimitRange::new(span);
2852        let mut seen = BTreeMap::new();
2853        for field in self.fields(mapping) {
2854            let duplicate = self.record_duplicate(&mut seen, &field);
2855            match field.name.value.as_str() {
2856                "soft" if !duplicate => self
2857                    .parse_limit_value(&field, "ulimit soft value")
2858                    .into_iter()
2859                    .for_each(|value| range.set_soft(value)),
2860                "hard" if !duplicate => self
2861                    .parse_limit_value(&field, "ulimit hard value")
2862                    .into_iter()
2863                    .for_each(|value| range.set_hard(value)),
2864                name if name.starts_with("x-") => range.push_extension(field.reference()),
2865                _ if duplicate => {}
2866                _ => range.push_unknown(field.reference()),
2867            }
2868        }
2869        if range.soft().is_none() {
2870            self.missing(
2871                ULIMIT_MISSING_RANGE_MEMBER,
2872                span,
2873                "ulimit range is missing required `soft`",
2874            );
2875        }
2876        if range.hard().is_none() {
2877            self.missing(
2878                ULIMIT_MISSING_RANGE_MEMBER,
2879                span,
2880                "ulimit range is missing required `hard`",
2881            );
2882        }
2883        range
2884    }
2885
2886    fn parse_limit_value(&mut self, field: &ParsedField, description: &str) -> Option<Located<LimitValue>> {
2887        let value = self.parse_string(field, description)?;
2888        let parsed = LimitValue::parse(value.value);
2889        if !parsed.is_valid() {
2890            self.diagnostics.push(
2891                Diagnostic::new(
2892                    ULIMIT_INVALID_VALUE,
2893                    Severity::Error,
2894                    "ulimit must be -1, a non-negative integer, or an interpolation expression",
2895                )
2896                .with_label(DiagnosticLabel::primary(value.span, "invalid ulimit value")),
2897            );
2898        }
2899        Some(Located::new(parsed, value.span))
2900    }
2901
2902    fn parse_depends_on(&mut self, field: &ParsedField) -> Option<DependsOn> {
2903        match field.value.as_ref() {
2904            Some(YamlNode::Sequence(sequence)) => {
2905                let span = span_from_position(self.source_id, sequence.byte_range());
2906                let services = self.parse_scalar_nodes(
2907                    sequence.values(),
2908                    field.span,
2909                    "dependency service names must be scalars",
2910                );
2911                Some(DependsOn::Short { span, services })
2912            }
2913            Some(YamlNode::Mapping(mapping)) => {
2914                let span = span_from_position(self.source_id, mapping.byte_range());
2915                let mut services = Vec::new();
2916                let mut seen = BTreeMap::new();
2917                for dependency in self.fields(mapping) {
2918                    if self.record_duplicate(&mut seen, &dependency) {
2919                        continue;
2920                    }
2921                    let mut parsed = ServiceDependency::new(dependency.name.clone(), dependency.span);
2922                    if Self::field_is_null(&dependency) {
2923                        services.push(parsed);
2924                        continue;
2925                    }
2926                    let Some(options) = dependency.value.as_ref().and_then(YamlNode::as_mapping) else {
2927                        self.expected(
2928                            EXPECTED_MAPPING,
2929                            &dependency,
2930                            "long dependency options must be a mapping or null",
2931                        );
2932                        continue;
2933                    };
2934                    let mut option_seen = BTreeMap::new();
2935                    for option in self.fields(options) {
2936                        let duplicate = self.record_duplicate(&mut option_seen, &option);
2937                        match option.name.value.as_str() {
2938                            "condition" if !duplicate => {
2939                                if let Some(value) = self.parse_string(&option, "dependency condition") {
2940                                    let condition = DependencyCondition::parse(value.value);
2941                                    if !condition.is_known() {
2942                                        self.diagnostics.push(
2943                                            Diagnostic::new(
2944                                                DEPENDENCY_INVALID_CONDITION,
2945                                                Severity::Error,
2946                                                "dependency condition is not defined by Compose",
2947                                            )
2948                                            .with_label(
2949                                                DiagnosticLabel::primary(value.span, "unknown dependency condition"),
2950                                            ),
2951                                        );
2952                                    }
2953                                    parsed.set_condition(Located::new(condition, value.span));
2954                                }
2955                            }
2956                            "restart" if !duplicate => self
2957                                .parse_boolean(&option, "dependency restart")
2958                                .into_iter()
2959                                .for_each(|value| parsed.set_restart(value)),
2960                            "required" if !duplicate => self
2961                                .parse_boolean(&option, "dependency required")
2962                                .into_iter()
2963                                .for_each(|value| parsed.set_required(value)),
2964                            name if name.starts_with("x-") => parsed.push_extension(option.reference()),
2965                            _ if duplicate => {}
2966                            _ => parsed.push_unknown(option.reference()),
2967                        }
2968                    }
2969                    services.push(parsed);
2970                }
2971                Some(DependsOn::Long { span, services })
2972            }
2973            _ => {
2974                self.expected(EXPECTED_FIELD_FORM, field, "depends_on must be a sequence or mapping");
2975                None
2976            }
2977        }
2978    }
2979
2980    fn parse_healthcheck(&mut self, field: &ParsedField) -> Option<Healthcheck> {
2981        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
2982            self.expected(EXPECTED_MAPPING, field, "healthcheck must be a mapping");
2983            return None;
2984        };
2985        let span = span_from_position(self.source_id, mapping.byte_range());
2986        let mut healthcheck = Healthcheck::new(span);
2987        let mut seen = BTreeMap::new();
2988        for option in self.fields(mapping) {
2989            let duplicate = self.record_duplicate(&mut seen, &option);
2990            match option.name.value.as_str() {
2991                "test" if !duplicate => self
2992                    .parse_healthcheck_test(&option)
2993                    .into_iter()
2994                    .for_each(|value| healthcheck.set_test(value)),
2995                "interval" if !duplicate => self
2996                    .parse_healthcheck_duration(&option, "healthcheck interval")
2997                    .into_iter()
2998                    .for_each(|value| healthcheck.set_interval(value)),
2999                "timeout" if !duplicate => self
3000                    .parse_healthcheck_duration(&option, "healthcheck timeout")
3001                    .into_iter()
3002                    .for_each(|value| healthcheck.set_timeout(value)),
3003                "retries" if !duplicate => self
3004                    .parse_healthcheck_retries(&option)
3005                    .into_iter()
3006                    .for_each(|value| healthcheck.set_retries(value)),
3007                "start_period" if !duplicate => self
3008                    .parse_healthcheck_duration(&option, "healthcheck start period")
3009                    .into_iter()
3010                    .for_each(|value| healthcheck.set_start_period(value)),
3011                "start_interval" if !duplicate => self
3012                    .parse_healthcheck_duration(&option, "healthcheck start interval")
3013                    .into_iter()
3014                    .for_each(|value| healthcheck.set_start_interval(value)),
3015                "disable" if !duplicate => self
3016                    .parse_boolean(&option, "healthcheck disable")
3017                    .into_iter()
3018                    .for_each(|value| healthcheck.set_disable(value)),
3019                name if name.starts_with("x-") => healthcheck.push_extension(option.reference()),
3020                _ if duplicate => {}
3021                _ => healthcheck.push_unknown(option.reference()),
3022            }
3023        }
3024        Some(healthcheck)
3025    }
3026
3027    fn parse_healthcheck_duration(
3028        &mut self,
3029        field: &ParsedField,
3030        description: &str,
3031    ) -> Option<Located<HealthcheckDuration>> {
3032        let value = self.parse_string(field, description)?;
3033        let duration = HealthcheckDuration::parse(value.value);
3034        if !duration.is_valid() {
3035            self.diagnostics.push(
3036                Diagnostic::new(
3037                    HEALTHCHECK_INVALID_DURATION,
3038                    Severity::Error,
3039                    "healthcheck duration must use Compose duration syntax or interpolation",
3040                )
3041                .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck duration")),
3042            );
3043        }
3044        Some(Located::new(duration, value.span))
3045    }
3046
3047    fn parse_healthcheck_retries(&mut self, field: &ParsedField) -> Option<Located<HealthcheckRetries>> {
3048        let value = self.parse_string(field, "healthcheck retries")?;
3049        let retries = HealthcheckRetries::parse(value.value);
3050        if !retries.is_valid() {
3051            self.diagnostics.push(
3052                Diagnostic::new(
3053                    HEALTHCHECK_INVALID_RETRIES,
3054                    Severity::Error,
3055                    "healthcheck retries must be a non-negative integer or interpolation expression",
3056                )
3057                .with_label(DiagnosticLabel::primary(value.span, "invalid healthcheck retry count")),
3058            );
3059        }
3060        Some(Located::new(retries, value.span))
3061    }
3062
3063    fn parse_healthcheck_test(&mut self, field: &ParsedField) -> Option<HealthcheckTest> {
3064        match field.value.as_ref() {
3065            Some(YamlNode::Scalar(_)) => self
3066                .parse_string(field, "healthcheck test")
3067                .map(HealthcheckTest::String),
3068            Some(YamlNode::Sequence(sequence)) => {
3069                let span = span_from_position(self.source_id, sequence.byte_range());
3070                let values =
3071                    self.parse_scalar_nodes(sequence.values(), field.span, "healthcheck test items must be scalars");
3072                let kind = values.first().map(|value| HealthcheckTestKind::parse(value.value()));
3073                if kind.is_none()
3074                    || kind == Some(HealthcheckTestKind::Other)
3075                    || (kind == Some(HealthcheckTestKind::None) && values.len() != 1)
3076                {
3077                    self.diagnostics.push(
3078                        Diagnostic::new(
3079                            HEALTHCHECK_INVALID_TEST,
3080                            Severity::Error,
3081                            "healthcheck list must begin with NONE, CMD, or CMD-SHELL",
3082                        )
3083                        .with_label(DiagnosticLabel::primary(span, "invalid healthcheck command mode")),
3084                    );
3085                }
3086                Some(HealthcheckTest::List { span, kind, values })
3087            }
3088            _ => {
3089                self.expected(
3090                    EXPECTED_FIELD_FORM,
3091                    field,
3092                    "healthcheck test must be a scalar or sequence",
3093                );
3094                None
3095            }
3096        }
3097    }
3098
3099    fn parse_build(&mut self, field: &ParsedField) -> Option<Build> {
3100        match field.value.as_ref() {
3101            Some(YamlNode::Scalar(_)) => self.parse_string(field, "build context").map(Build::Context),
3102            Some(YamlNode::Mapping(mapping)) => {
3103                let span = span_from_position(self.source_id, mapping.byte_range());
3104                let mut definition = BuildDefinition::new(span);
3105                let mut seen = BTreeMap::new();
3106                for option in self.fields(mapping) {
3107                    let duplicate = self.record_duplicate(&mut seen, &option);
3108                    if duplicate {
3109                        continue;
3110                    }
3111                    if let Some(kind) = BuildFieldKind::from_name(option.name.value()) {
3112                        definition.push_field(BuildField::new(kind, option.reference()));
3113                    } else if option.name.value().starts_with("x-") {
3114                        definition.push_extension(option.reference());
3115                    } else {
3116                        definition.push_unknown(option.reference());
3117                    }
3118                }
3119                Some(Build::Definition(definition))
3120            }
3121            _ => {
3122                self.expected(EXPECTED_FIELD_FORM, field, "build must be a scalar context or mapping");
3123                None
3124            }
3125        }
3126    }
3127
3128    fn parse_deploy(&mut self, field: &ParsedField) -> Option<DeployDefinition> {
3129        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3130            self.expected(EXPECTED_MAPPING, field, "deploy must be a mapping");
3131            return None;
3132        };
3133        let span = span_from_position(self.source_id, mapping.byte_range());
3134        let mut definition = DeployDefinition::new(span);
3135        let mut seen = BTreeMap::new();
3136        for option in self.fields(mapping) {
3137            let duplicate = self.record_duplicate(&mut seen, &option);
3138            if duplicate {
3139                continue;
3140            }
3141            if let Some(kind) = DeployFieldKind::from_name(option.name.value()) {
3142                definition.push_field(DeployField::new(kind, option.reference()));
3143            } else if option.name.value().starts_with("x-") {
3144                definition.push_extension(option.reference());
3145            } else {
3146                definition.push_unknown(option.reference());
3147            }
3148        }
3149        Some(definition)
3150    }
3151
3152    fn source_column(&self, offset: usize) -> usize {
3153        let prefix = self.source.get(..offset).unwrap_or_default();
3154        let line_start = prefix.rfind('\n').map_or(0, |index| index + 1);
3155        self.source[line_start..offset].chars().count()
3156    }
3157
3158    fn parse_service_ports(&mut self, field: &ParsedField) -> Vec<Port> {
3159        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3160            self.expected(EXPECTED_SEQUENCE, field, "service ports must be a sequence");
3161            return Vec::new();
3162        };
3163
3164        let mut ports = Vec::new();
3165        for value in sequence.values() {
3166            match value {
3167                YamlNode::Scalar(scalar) => {
3168                    let span = span_from_position(self.source_id, scalar.byte_range());
3169                    ports.push(Port::Short(ShortPort::parse(Located::new(
3170                        scalar_string_from_source(&self.source, &scalar),
3171                        span,
3172                    ))));
3173                }
3174                YamlNode::Mapping(mapping) => {
3175                    ports.push(Port::Long(Box::new(self.parse_long_port(&mapping))));
3176                }
3177                other => self.unsupported_sequence_item(
3178                    PORT_EXPECTED_FORM,
3179                    &other,
3180                    field.span,
3181                    "service port must use scalar short syntax or mapping long syntax",
3182                ),
3183            }
3184        }
3185        ports
3186    }
3187
3188    fn parse_long_port(&mut self, mapping: &Mapping) -> LongPort {
3189        let span = span_from_position(self.source_id, mapping.byte_range());
3190        let mut port = LongPort::new(span);
3191        let mut seen = BTreeMap::new();
3192        for field in self.fields(mapping) {
3193            let duplicate = self.record_duplicate(&mut seen, &field);
3194            match field.name.value.as_str() {
3195                "target" if !duplicate => self
3196                    .parse_string(&field, "port target")
3197                    .into_iter()
3198                    .for_each(|value| port.set_target(value)),
3199                "published" if !duplicate => self
3200                    .parse_string(&field, "published port")
3201                    .into_iter()
3202                    .for_each(|value| port.set_published(value)),
3203                "host_ip" if !duplicate => self
3204                    .parse_string(&field, "port host IP")
3205                    .into_iter()
3206                    .for_each(|value| port.set_host_ip(value)),
3207                "protocol" if !duplicate => self
3208                    .parse_string(&field, "port protocol")
3209                    .into_iter()
3210                    .for_each(|value| port.set_protocol(value)),
3211                "app_protocol" if !duplicate => self
3212                    .parse_string(&field, "port application protocol")
3213                    .into_iter()
3214                    .for_each(|value| port.set_app_protocol(value)),
3215                "mode" if !duplicate => self
3216                    .parse_string(&field, "port mode")
3217                    .into_iter()
3218                    .for_each(|value| port.set_mode(value)),
3219                "name" if !duplicate => self
3220                    .parse_string(&field, "port name")
3221                    .into_iter()
3222                    .for_each(|value| port.set_name(value)),
3223                name if name.starts_with("x-") => port.push_extension(field.reference()),
3224                _ if duplicate => {}
3225                _ => port.push_unknown(field.reference()),
3226            }
3227        }
3228        if port.target().is_none() {
3229            self.missing(PORT_MISSING_TARGET, span, "long port is missing `target`");
3230        }
3231        port
3232    }
3233
3234    fn parse_service_networks(&mut self, field: &ParsedField) -> Option<ServiceNetworks> {
3235        match field.value.as_ref() {
3236            Some(YamlNode::Sequence(sequence)) => {
3237                let span = span_from_position(self.source_id, sequence.byte_range());
3238                let names =
3239                    self.parse_scalar_nodes(sequence.values(), field.span, "service network names must be scalars");
3240                Some(ServiceNetworks::Short { span, names })
3241            }
3242            Some(YamlNode::Mapping(mapping)) => {
3243                let span = span_from_position(self.source_id, mapping.byte_range());
3244                let networks = self.parse_service_network_map(mapping);
3245                Some(ServiceNetworks::Long { span, networks })
3246            }
3247            _ => {
3248                self.expected(
3249                    EXPECTED_FIELD_FORM,
3250                    field,
3251                    "service networks must be a sequence or mapping",
3252                );
3253                None
3254            }
3255        }
3256    }
3257
3258    fn parse_service_network_map(&mut self, mapping: &Mapping) -> Vec<ServiceNetwork> {
3259        let mut networks = Vec::new();
3260        let mut seen = BTreeMap::new();
3261        for field in self.fields(mapping) {
3262            if self.record_duplicate(&mut seen, &field) {
3263                continue;
3264            }
3265            if Self::field_is_null(&field) {
3266                networks.push(ServiceNetwork::new(field.name, field.span));
3267                continue;
3268            }
3269            let Some(options) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3270                self.expected(
3271                    EXPECTED_MAPPING,
3272                    &field,
3273                    "service network options must be a mapping or null",
3274                );
3275                continue;
3276            };
3277            networks.push(self.parse_service_network(&field, options));
3278        }
3279        networks
3280    }
3281
3282    fn parse_service_network(&mut self, field: &ParsedField, mapping: &Mapping) -> ServiceNetwork {
3283        let mut network = ServiceNetwork::new(field.name.clone(), field.span);
3284        let mut seen = BTreeMap::new();
3285        for option in self.fields(mapping) {
3286            let duplicate = self.record_duplicate(&mut seen, &option);
3287            match option.name.value.as_str() {
3288                "aliases" if !duplicate => network.set_aliases(self.parse_string_sequence(&option, "network aliases")),
3289                "interface_name" if !duplicate => self
3290                    .parse_string(&option, "network interface name")
3291                    .into_iter()
3292                    .for_each(|value| network.set_interface_name(value)),
3293                "ipv4_address" if !duplicate => self
3294                    .parse_string(&option, "network IPv4 address")
3295                    .into_iter()
3296                    .for_each(|value| network.set_ipv4_address(value)),
3297                "ipv6_address" if !duplicate => self
3298                    .parse_string(&option, "network IPv6 address")
3299                    .into_iter()
3300                    .for_each(|value| network.set_ipv6_address(value)),
3301                "link_local_ips" if !duplicate => {
3302                    network.set_link_local_ips(self.parse_string_sequence(&option, "link-local IP addresses"));
3303                }
3304                "mac_address" if !duplicate => self
3305                    .parse_string(&option, "network MAC address")
3306                    .into_iter()
3307                    .for_each(|value| network.set_mac_address(value)),
3308                "driver_opts" if !duplicate => {
3309                    network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
3310                }
3311                "gw_priority" if !duplicate => self
3312                    .parse_string(&option, "network gateway priority")
3313                    .into_iter()
3314                    .for_each(|value| network.set_gw_priority(value)),
3315                "priority" if !duplicate => self
3316                    .parse_string(&option, "network priority")
3317                    .into_iter()
3318                    .for_each(|value| network.set_priority(value)),
3319                name if name.starts_with("x-") => network.push_extension(option.reference()),
3320                _ if duplicate => {}
3321                _ => network.push_unknown(option.reference()),
3322            }
3323        }
3324        network
3325    }
3326
3327    fn parse_config_grants(&mut self, field: &ParsedField) -> Vec<ConfigGrant> {
3328        self.parse_grants(field)
3329            .into_iter()
3330            .map(|grant| match grant {
3331                ParsedGrant::Short(value) => ConfigGrant::Short(value),
3332                ParsedGrant::Long(value) => ConfigGrant::Long(value),
3333            })
3334            .collect()
3335    }
3336
3337    fn parse_secret_grants(&mut self, field: &ParsedField) -> Vec<SecretGrant> {
3338        self.parse_grants(field)
3339            .into_iter()
3340            .map(|grant| match grant {
3341                ParsedGrant::Short(value) => SecretGrant::Short(value),
3342                ParsedGrant::Long(value) => SecretGrant::Long(value),
3343            })
3344            .collect()
3345    }
3346
3347    fn parse_grants(&mut self, field: &ParsedField) -> Vec<ParsedGrant> {
3348        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3349            self.expected(EXPECTED_SEQUENCE, field, "service grants must be a sequence");
3350            return Vec::new();
3351        };
3352        let mut grants = Vec::new();
3353        for value in sequence.values() {
3354            match value {
3355                YamlNode::Scalar(scalar) => {
3356                    let span = span_from_position(self.source_id, scalar.byte_range());
3357                    grants.push(ParsedGrant::Short(Located::new(
3358                        scalar_string_from_source(&self.source, &scalar),
3359                        span,
3360                    )));
3361                }
3362                YamlNode::Mapping(mapping) => {
3363                    grants.push(ParsedGrant::Long(Box::new(self.parse_long_grant(&mapping))));
3364                }
3365                other => self.unsupported_sequence_item(
3366                    GRANT_EXPECTED_FORM,
3367                    &other,
3368                    field.span,
3369                    "grant must use scalar short syntax or mapping long syntax",
3370                ),
3371            }
3372        }
3373        grants
3374    }
3375
3376    fn parse_long_grant(&mut self, mapping: &Mapping) -> LongGrant {
3377        let span = span_from_position(self.source_id, mapping.byte_range());
3378        let mut grant = LongGrant::new(span);
3379        let mut seen = BTreeMap::new();
3380        for field in self.fields(mapping) {
3381            let duplicate = self.record_duplicate(&mut seen, &field);
3382            match field.name.value.as_str() {
3383                "source" if !duplicate => self
3384                    .parse_string(&field, "grant source")
3385                    .into_iter()
3386                    .for_each(|value| grant.set_source(value)),
3387                "target" if !duplicate => self
3388                    .parse_string(&field, "grant target")
3389                    .into_iter()
3390                    .for_each(|value| grant.set_target(value)),
3391                "uid" if !duplicate => self
3392                    .parse_string(&field, "grant user ID")
3393                    .into_iter()
3394                    .for_each(|value| grant.set_uid(value)),
3395                "gid" if !duplicate => self
3396                    .parse_string(&field, "grant group ID")
3397                    .into_iter()
3398                    .for_each(|value| grant.set_gid(value)),
3399                "mode" if !duplicate => self
3400                    .parse_string(&field, "grant mode")
3401                    .into_iter()
3402                    .for_each(|value| grant.set_mode(value)),
3403                name if name.starts_with("x-") => grant.push_extension(field.reference()),
3404                _ if duplicate => {}
3405                _ => grant.push_unknown(field.reference()),
3406            }
3407        }
3408        if grant.source().is_none() {
3409            self.missing(GRANT_MISSING_SOURCE, span, "long grant is missing `source`");
3410        }
3411        grant
3412    }
3413
3414    fn parse_service_volumes(&mut self, field: &ParsedField) -> Vec<VolumeMount> {
3415        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3416            self.expected(EXPECTED_SEQUENCE, field, "service volumes must be a sequence");
3417            return Vec::new();
3418        };
3419
3420        sequence
3421            .values()
3422            .filter_map(|value| match value {
3423                YamlNode::Scalar(scalar) => {
3424                    let span = span_from_position(self.source_id, scalar.byte_range());
3425                    let raw = Located::new(scalar_string_from_source(&self.source, &scalar), span);
3426                    Some(VolumeMount::Short(ShortVolumeMount::new(raw)))
3427                }
3428                YamlNode::Mapping(mapping) => Some(VolumeMount::Long(Box::new(self.parse_long_volume(&mapping)))),
3429                other => {
3430                    let span = node_span(self.source_id, &other).unwrap_or(field.span);
3431                    self.diagnostics.push(
3432                        Diagnostic::new(
3433                            VOLUME_EXPECTED_FORM,
3434                            Severity::Error,
3435                            "service volume must use scalar short syntax or mapping long syntax",
3436                        )
3437                        .with_label(DiagnosticLabel::primary(span, "unsupported volume form")),
3438                    );
3439                    None
3440                }
3441            })
3442            .collect()
3443    }
3444
3445    fn parse_long_volume(&mut self, mapping: &Mapping) -> LongVolumeMount {
3446        let span = span_from_position(self.source_id, mapping.byte_range());
3447        let mut mount = LongVolumeMount::new(span);
3448        let mut seen = BTreeMap::new();
3449        for field in self.fields(mapping) {
3450            let duplicate = self.record_duplicate(&mut seen, &field);
3451            match field.name.value.as_str() {
3452                "type" if !duplicate => {
3453                    if let Some(value) = self.parse_string(&field, "volume type") {
3454                        mount.set_mount_type(Located::new(MountType::from_text(value.value), value.span));
3455                    }
3456                }
3457                "source" if !duplicate => {
3458                    if let Some(value) = self.parse_string(&field, "volume source") {
3459                        mount.set_source(value);
3460                    }
3461                }
3462                "target" if !duplicate => {
3463                    if let Some(value) = self.parse_string(&field, "volume target") {
3464                        mount.set_target(value);
3465                    }
3466                }
3467                "read_only" if !duplicate => {
3468                    if let Some(value) = self.parse_boolean(&field, "read_only") {
3469                        mount.set_read_only(value);
3470                    }
3471                }
3472                "bind" if !duplicate => {
3473                    if let Some(value) = self.parse_bind_options(&field) {
3474                        mount.set_bind(value);
3475                    }
3476                }
3477                name if name.starts_with("x-") => mount.push_extension(field.reference()),
3478                _ if duplicate => {}
3479                _ => mount.push_unknown(field.reference()),
3480            }
3481        }
3482
3483        if mount.mount_type().is_none() {
3484            self.missing(VOLUME_MISSING_TYPE, span, "long volume is missing `type`");
3485        }
3486        if mount.target().is_none() {
3487            self.missing(VOLUME_MISSING_TARGET, span, "long volume is missing `target`");
3488        }
3489        mount
3490    }
3491
3492    fn parse_bind_options(&mut self, field: &ParsedField) -> Option<BindOptions> {
3493        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3494            self.expected(EXPECTED_MAPPING, field, "bind options must be a mapping");
3495            return None;
3496        };
3497        let span = span_from_position(self.source_id, mapping.byte_range());
3498        let mut bind = BindOptions::new(span);
3499        let mut seen = BTreeMap::new();
3500        for bind_field in self.fields(mapping) {
3501            let duplicate = self.record_duplicate(&mut seen, &bind_field);
3502            match bind_field.name.value.as_str() {
3503                "propagation" if !duplicate => {
3504                    if let Some(value) = self.parse_string(&bind_field, "bind propagation") {
3505                        bind.set_propagation(value);
3506                    }
3507                }
3508                "create_host_path" if !duplicate => {
3509                    if let Some(value) = self.parse_boolean(&bind_field, "create_host_path") {
3510                        bind.set_create_host_path(value);
3511                    }
3512                }
3513                "selinux" if !duplicate => {
3514                    if let Some(value) = self.parse_string(&bind_field, "SELinux relabel mode") {
3515                        let mode = match value.value.as_str() {
3516                            "z" => Some(SelinuxRelabel::Shared),
3517                            "Z" => Some(SelinuxRelabel::Private),
3518                            _ => None,
3519                        };
3520                        if let Some(mode) = mode {
3521                            bind.set_selinux(Located::new(mode, value.span));
3522                        } else {
3523                            self.diagnostics.push(
3524                                Diagnostic::new(
3525                                    VOLUME_INVALID_SELINUX,
3526                                    Severity::Error,
3527                                    "SELinux relabel mode must be `z` or `Z`",
3528                                )
3529                                .with_label(DiagnosticLabel::primary(value.span, "invalid SELinux mode")),
3530                            );
3531                        }
3532                    }
3533                }
3534                name if name.starts_with("x-") => bind.push_extension(bind_field.reference()),
3535                _ if duplicate => {}
3536                _ => bind.push_unknown(bind_field.reference()),
3537            }
3538        }
3539        Some(bind)
3540    }
3541
3542    fn parse_network_definitions(&mut self, field: &ParsedField) -> Vec<NetworkDefinition> {
3543        let Some(mapping) = self.resource_collection(field, "networks") else {
3544            return Vec::new();
3545        };
3546        let mut definitions = Vec::new();
3547        let mut seen = BTreeMap::new();
3548        for resource in self.fields(&mapping) {
3549            if self.record_duplicate(&mut seen, &resource) {
3550                continue;
3551            }
3552            if Self::field_is_null(&resource) {
3553                definitions.push(NetworkDefinition::new(resource.name, resource.span));
3554                continue;
3555            }
3556            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
3557                self.expected(
3558                    RESOURCE_EXPECTED_FORM,
3559                    &resource,
3560                    "network definition must be a mapping or null",
3561                );
3562                continue;
3563            };
3564            definitions.push(self.parse_network_definition(&resource, definition));
3565        }
3566        definitions
3567    }
3568
3569    fn parse_network_definition(&mut self, field: &ParsedField, mapping: &Mapping) -> NetworkDefinition {
3570        let mut network = NetworkDefinition::new(field.name.clone(), field.span);
3571        let mut seen = BTreeMap::new();
3572        for option in self.fields(mapping) {
3573            let duplicate = self.record_duplicate(&mut seen, &option);
3574            match option.name.value.as_str() {
3575                "driver" if !duplicate => self
3576                    .parse_string(&option, "network driver")
3577                    .into_iter()
3578                    .for_each(|value| network.set_driver(value)),
3579                "driver_opts" if !duplicate => {
3580                    network.set_driver_opts(self.parse_scalar_mapping(&option, "network driver options"));
3581                }
3582                "attachable" if !duplicate => self
3583                    .parse_boolean(&option, "network attachable")
3584                    .into_iter()
3585                    .for_each(|value| network.set_attachable(value)),
3586                "enable_ipv4" if !duplicate => self
3587                    .parse_boolean(&option, "network enable_ipv4")
3588                    .into_iter()
3589                    .for_each(|value| network.set_enable_ipv4(value)),
3590                "enable_ipv6" if !duplicate => self
3591                    .parse_boolean(&option, "network enable_ipv6")
3592                    .into_iter()
3593                    .for_each(|value| network.set_enable_ipv6(value)),
3594                "external" if !duplicate => self
3595                    .parse_boolean(&option, "network external")
3596                    .into_iter()
3597                    .for_each(|value| network.set_external(value)),
3598                "internal" if !duplicate => self
3599                    .parse_boolean(&option, "network internal")
3600                    .into_iter()
3601                    .for_each(|value| network.set_internal(value)),
3602                "ipam" if !duplicate => self
3603                    .parse_ipam(&option)
3604                    .into_iter()
3605                    .for_each(|value| network.set_ipam(value)),
3606                "labels" if !duplicate => self
3607                    .parse_labels(&option)
3608                    .into_iter()
3609                    .for_each(|value| network.set_labels(value)),
3610                "name" if !duplicate => self
3611                    .parse_string(&option, "network custom name")
3612                    .into_iter()
3613                    .for_each(|value| network.set_custom_name(value)),
3614                name if name.starts_with("x-") => network.push_extension(option.reference()),
3615                _ if duplicate => {}
3616                _ => network.push_unknown(option.reference()),
3617            }
3618        }
3619        network
3620    }
3621
3622    fn parse_ipam(&mut self, field: &ParsedField) -> Option<Ipam> {
3623        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3624            self.expected(EXPECTED_MAPPING, field, "network IPAM must be a mapping");
3625            return None;
3626        };
3627        let span = span_from_position(self.source_id, mapping.byte_range());
3628        let mut ipam = Ipam::new(span);
3629        let mut seen = BTreeMap::new();
3630        for option in self.fields(mapping) {
3631            let duplicate = self.record_duplicate(&mut seen, &option);
3632            match option.name.value.as_str() {
3633                "driver" if !duplicate => self
3634                    .parse_string(&option, "IPAM driver")
3635                    .into_iter()
3636                    .for_each(|value| ipam.set_driver(value)),
3637                "config" if !duplicate => ipam.set_config(self.parse_ipam_configs(&option)),
3638                "options" if !duplicate => {
3639                    ipam.set_options(self.parse_scalar_mapping(&option, "IPAM options"));
3640                }
3641                name if name.starts_with("x-") => ipam.push_extension(option.reference()),
3642                _ if duplicate => {}
3643                _ => ipam.push_unknown(option.reference()),
3644            }
3645        }
3646        Some(ipam)
3647    }
3648
3649    fn parse_ipam_configs(&mut self, field: &ParsedField) -> Vec<IpamConfig> {
3650        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3651            self.expected(EXPECTED_SEQUENCE, field, "IPAM config must be a sequence");
3652            return Vec::new();
3653        };
3654        let mut configs = Vec::new();
3655        for value in sequence.values() {
3656            let YamlNode::Mapping(mapping) = value else {
3657                self.unsupported_sequence_item(
3658                    EXPECTED_MAPPING,
3659                    &value,
3660                    field.span,
3661                    "IPAM config entries must be mappings",
3662                );
3663                continue;
3664            };
3665            configs.push(self.parse_ipam_config(&mapping));
3666        }
3667        configs
3668    }
3669
3670    fn parse_ipam_config(&mut self, mapping: &Mapping) -> IpamConfig {
3671        let span = span_from_position(self.source_id, mapping.byte_range());
3672        let mut config = IpamConfig::new(span);
3673        let mut seen = BTreeMap::new();
3674        for field in self.fields(mapping) {
3675            let duplicate = self.record_duplicate(&mut seen, &field);
3676            match field.name.value.as_str() {
3677                "subnet" if !duplicate => self
3678                    .parse_string(&field, "IPAM subnet")
3679                    .into_iter()
3680                    .for_each(|value| config.set_subnet(value)),
3681                "ip_range" if !duplicate => self
3682                    .parse_string(&field, "IPAM allocation range")
3683                    .into_iter()
3684                    .for_each(|value| config.set_ip_range(value)),
3685                "gateway" if !duplicate => self
3686                    .parse_string(&field, "IPAM gateway")
3687                    .into_iter()
3688                    .for_each(|value| config.set_gateway(value)),
3689                "aux_addresses" if !duplicate => {
3690                    config.set_aux_addresses(self.parse_scalar_mapping(&field, "IPAM auxiliary addresses"));
3691                }
3692                name if name.starts_with("x-") => config.push_extension(field.reference()),
3693                _ if duplicate => {}
3694                _ => config.push_unknown(field.reference()),
3695            }
3696        }
3697        config
3698    }
3699
3700    fn parse_volume_definitions(&mut self, field: &ParsedField) -> Vec<VolumeDefinition> {
3701        let Some(mapping) = self.resource_collection(field, "volumes") else {
3702            return Vec::new();
3703        };
3704        let mut definitions = Vec::new();
3705        let mut seen = BTreeMap::new();
3706        for resource in self.fields(&mapping) {
3707            if self.record_duplicate(&mut seen, &resource) {
3708                continue;
3709            }
3710            let mut volume = VolumeDefinition::new(resource.name.clone(), resource.span);
3711            if Self::field_is_null(&resource) {
3712                definitions.push(volume);
3713                continue;
3714            }
3715            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
3716                self.expected(
3717                    RESOURCE_EXPECTED_FORM,
3718                    &resource,
3719                    "volume definition must be a mapping or null",
3720                );
3721                continue;
3722            };
3723            let mut nested_seen = BTreeMap::new();
3724            for option in self.fields(definition) {
3725                let duplicate = self.record_duplicate(&mut nested_seen, &option);
3726                match option.name.value.as_str() {
3727                    "driver" if !duplicate => self
3728                        .parse_string(&option, "volume driver")
3729                        .into_iter()
3730                        .for_each(|value| volume.set_driver(value)),
3731                    "driver_opts" if !duplicate => {
3732                        volume.set_driver_opts(self.parse_scalar_mapping(&option, "volume driver options"));
3733                    }
3734                    "external" if !duplicate => self
3735                        .parse_boolean(&option, "volume external")
3736                        .into_iter()
3737                        .for_each(|value| volume.set_external(value)),
3738                    "labels" if !duplicate => self
3739                        .parse_labels(&option)
3740                        .into_iter()
3741                        .for_each(|value| volume.set_labels(value)),
3742                    "name" if !duplicate => self
3743                        .parse_string(&option, "volume custom name")
3744                        .into_iter()
3745                        .for_each(|value| volume.set_custom_name(value)),
3746                    name if name.starts_with("x-") => volume.push_extension(option.reference()),
3747                    _ if duplicate => {}
3748                    _ => volume.push_unknown(option.reference()),
3749                }
3750            }
3751            definitions.push(volume);
3752        }
3753        definitions
3754    }
3755
3756    fn parse_config_definitions(&mut self, field: &ParsedField) -> Vec<ConfigDefinition> {
3757        let Some(mapping) = self.resource_collection(field, "configs") else {
3758            return Vec::new();
3759        };
3760        let mut definitions = Vec::new();
3761        let mut seen = BTreeMap::new();
3762        for resource in self.fields(&mapping) {
3763            if self.record_duplicate(&mut seen, &resource) {
3764                continue;
3765            }
3766            let mut config = ConfigDefinition::new(resource.name.clone(), resource.span);
3767            if Self::field_is_null(&resource) {
3768                definitions.push(config);
3769                continue;
3770            }
3771            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
3772                self.expected(
3773                    RESOURCE_EXPECTED_FORM,
3774                    &resource,
3775                    "config definition must be a mapping or null",
3776                );
3777                continue;
3778            };
3779            let mut nested_seen = BTreeMap::new();
3780            for option in self.fields(definition) {
3781                let duplicate = self.record_duplicate(&mut nested_seen, &option);
3782                match option.name.value.as_str() {
3783                    "file" if !duplicate => self
3784                        .parse_string(&option, "config file")
3785                        .into_iter()
3786                        .for_each(|value| config.set_file(value)),
3787                    "environment" if !duplicate => self
3788                        .parse_string(&option, "config environment source")
3789                        .into_iter()
3790                        .for_each(|value| config.set_environment(value)),
3791                    "content" if !duplicate => self
3792                        .parse_string(&option, "config content")
3793                        .into_iter()
3794                        .for_each(|value| config.set_content(value)),
3795                    "external" if !duplicate => self
3796                        .parse_boolean(&option, "config external")
3797                        .into_iter()
3798                        .for_each(|value| config.set_external(value)),
3799                    "name" if !duplicate => self
3800                        .parse_string(&option, "config custom name")
3801                        .into_iter()
3802                        .for_each(|value| config.set_custom_name(value)),
3803                    name if name.starts_with("x-") => config.push_extension(option.reference()),
3804                    _ if duplicate => {}
3805                    _ => config.push_unknown(option.reference()),
3806                }
3807            }
3808            definitions.push(config);
3809        }
3810        definitions
3811    }
3812
3813    fn parse_secret_definitions(&mut self, field: &ParsedField) -> Vec<SecretDefinition> {
3814        let Some(mapping) = self.resource_collection(field, "secrets") else {
3815            return Vec::new();
3816        };
3817        let mut definitions = Vec::new();
3818        let mut seen = BTreeMap::new();
3819        for resource in self.fields(&mapping) {
3820            if self.record_duplicate(&mut seen, &resource) {
3821                continue;
3822            }
3823            let mut secret = SecretDefinition::new(resource.name.clone(), resource.span);
3824            if Self::field_is_null(&resource) {
3825                definitions.push(secret);
3826                continue;
3827            }
3828            let Some(definition) = resource.value.as_ref().and_then(YamlNode::as_mapping) else {
3829                self.expected(
3830                    RESOURCE_EXPECTED_FORM,
3831                    &resource,
3832                    "secret definition must be a mapping or null",
3833                );
3834                continue;
3835            };
3836            let mut nested_seen = BTreeMap::new();
3837            for option in self.fields(definition) {
3838                let duplicate = self.record_duplicate(&mut nested_seen, &option);
3839                match option.name.value.as_str() {
3840                    "file" if !duplicate => self
3841                        .parse_string(&option, "secret file")
3842                        .into_iter()
3843                        .for_each(|value| secret.set_file(value)),
3844                    "environment" if !duplicate => self
3845                        .parse_string(&option, "secret environment source")
3846                        .into_iter()
3847                        .for_each(|value| secret.set_environment(value)),
3848                    "external" if !duplicate => self
3849                        .parse_boolean(&option, "secret external")
3850                        .into_iter()
3851                        .for_each(|value| secret.set_external(value)),
3852                    "name" if !duplicate => self
3853                        .parse_string(&option, "secret custom name")
3854                        .into_iter()
3855                        .for_each(|value| secret.set_custom_name(value)),
3856                    name if name.starts_with("x-") => secret.push_extension(option.reference()),
3857                    _ if duplicate => {}
3858                    _ => secret.push_unknown(option.reference()),
3859                }
3860            }
3861            definitions.push(secret);
3862        }
3863        definitions
3864    }
3865
3866    fn resource_collection(&mut self, field: &ParsedField, kind: &str) -> Option<Mapping> {
3867        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3868            self.expected(EXPECTED_MAPPING, field, format!("top-level {kind} must be a mapping"));
3869            return None;
3870        };
3871        Some(mapping.clone())
3872    }
3873
3874    fn parse_string(&mut self, field: &ParsedField, description: &str) -> Option<Located<String>> {
3875        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3876            self.expected(EXPECTED_SCALAR, field, format!("{description} must be a scalar"));
3877            return None;
3878        };
3879        if ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null {
3880            self.expected(
3881                EXPECTED_SCALAR,
3882                field,
3883                format!("{description} must be a non-null scalar"),
3884            );
3885            return None;
3886        }
3887        Some(Located::new(
3888            scalar_string_from_source(&self.source, scalar),
3889            span_from_position(self.source_id, scalar.byte_range()),
3890        ))
3891    }
3892
3893    fn parse_boolean(&mut self, field: &ParsedField, description: &str) -> Option<Located<BooleanValue>> {
3894        let Some(scalar) = field.value.as_ref().and_then(YamlNode::as_scalar) else {
3895            self.expected(EXPECTED_BOOLEAN, field, format!("{description} must be a boolean"));
3896            return None;
3897        };
3898        let span = span_from_position(self.source_id, scalar.byte_range());
3899        let scalar_value = ScalarValue::from_scalar(scalar);
3900        if let Some(value) = scalar_value.to_bool() {
3901            return Some(Located::new(BooleanValue::Literal(value), span));
3902        }
3903        let value = scalar_string_from_source(&self.source, scalar);
3904        if value.contains('$') {
3905            return Some(Located::new(BooleanValue::Expression(value), span));
3906        }
3907        self.diagnostics.push(
3908            Diagnostic::new(
3909                EXPECTED_BOOLEAN,
3910                Severity::Error,
3911                format!("{description} must be a boolean or interpolation expression"),
3912            )
3913            .with_label(DiagnosticLabel::primary(span, "not a boolean expression")),
3914        );
3915        None
3916    }
3917
3918    fn parse_string_sequence(&mut self, field: &ParsedField, description: &str) -> Vec<Located<String>> {
3919        let Some(sequence) = field.value.as_ref().and_then(YamlNode::as_sequence) else {
3920            self.expected(EXPECTED_SEQUENCE, field, format!("{description} must be a sequence"));
3921            return Vec::new();
3922        };
3923        self.parse_scalar_nodes(
3924            sequence.values(),
3925            field.span,
3926            format!("{description} entries must be scalars"),
3927        )
3928    }
3929
3930    fn parse_scalar_nodes(
3931        &mut self,
3932        nodes: impl Iterator<Item = YamlNode>,
3933        fallback_span: SourceSpan,
3934        message: impl Into<String>,
3935    ) -> Vec<Located<String>> {
3936        let message = message.into();
3937        let mut values = Vec::new();
3938        for node in nodes {
3939            let YamlNode::Scalar(scalar) = node else {
3940                self.unsupported_sequence_item(EXPECTED_SCALAR, &node, fallback_span, &message);
3941                continue;
3942            };
3943            let scalar_value = ScalarValue::from_scalar(&scalar);
3944            if scalar_value.scalar_type() == ScalarType::Null {
3945                self.unsupported_sequence_item(EXPECTED_SCALAR, &YamlNode::Scalar(scalar), fallback_span, &message);
3946                continue;
3947            }
3948            let span = span_from_position(self.source_id, scalar.byte_range());
3949            values.push(Located::new(scalar_string_from_source(&self.source, &scalar), span));
3950        }
3951        values
3952    }
3953
3954    fn parse_scalar_mapping(&mut self, field: &ParsedField, description: &str) -> Vec<KeyValueEntry> {
3955        let Some(mapping) = field.value.as_ref().and_then(YamlNode::as_mapping) else {
3956            self.expected(EXPECTED_MAPPING, field, format!("{description} must be a mapping"));
3957            return Vec::new();
3958        };
3959        let mut entries = Vec::new();
3960        let mut seen = BTreeMap::new();
3961        for entry in self.fields(mapping) {
3962            if self.record_duplicate(&mut seen, &entry) {
3963                continue;
3964            }
3965            if let Some(value) = self.parse_compose_scalar(&entry, format!("{description} values must be scalars")) {
3966                entries.push(KeyValueEntry::new(entry.name, value, entry.span));
3967            }
3968        }
3969        entries
3970    }
3971
3972    fn parse_compose_scalar(
3973        &mut self,
3974        field: &ParsedField,
3975        message: impl Into<String>,
3976    ) -> Option<Located<ComposeScalar>> {
3977        let Some(node) = field.value.as_ref() else {
3978            return Some(Located::new(ComposeScalar::Null, field.name.span));
3979        };
3980        let Some(scalar) = node.as_scalar() else {
3981            self.expected(EXPECTED_SCALAR, field, message);
3982            return None;
3983        };
3984        let span = span_from_position(self.source_id, scalar.byte_range());
3985        let value = ScalarValue::from_scalar(scalar);
3986        let typed = match value.scalar_type() {
3987            ScalarType::Null => ComposeScalar::Null,
3988            ScalarType::Boolean => ComposeScalar::Boolean(value.to_bool().unwrap_or(false)),
3989            ScalarType::Integer | ScalarType::Float => {
3990                ComposeScalar::Number(scalar_string_from_source(&self.source, scalar))
3991            }
3992            ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
3993                ComposeScalar::String(scalar_string_from_source(&self.source, scalar))
3994            }
3995        };
3996        Some(Located::new(typed, span))
3997    }
3998
3999    fn parse_labels(&mut self, field: &ParsedField) -> Option<Labels> {
4000        match field.value.as_ref() {
4001            Some(YamlNode::Sequence(sequence)) => {
4002                let span = span_from_position(self.source_id, sequence.byte_range());
4003                let values =
4004                    self.parse_scalar_nodes(sequence.values(), field.span, "label list entries must be scalars");
4005                Some(Labels::List { span, values })
4006            }
4007            Some(YamlNode::Mapping(mapping)) => {
4008                let span = span_from_position(self.source_id, mapping.byte_range());
4009                let entries = self.parse_scalar_mapping(field, "labels");
4010                Some(Labels::Map { span, entries })
4011            }
4012            _ => {
4013                self.expected(EXPECTED_FIELD_FORM, field, "labels must be a sequence or mapping");
4014                None
4015            }
4016        }
4017    }
4018
4019    fn parse_annotations(&mut self, field: &ParsedField) -> Option<Annotations> {
4020        match field.value.as_ref() {
4021            Some(YamlNode::Sequence(sequence)) => Some(self.parse_annotation_list(sequence, field.span)),
4022            Some(YamlNode::Mapping(mapping)) => Some(self.parse_annotation_map(mapping)),
4023            _ => {
4024                self.expected(
4025                    ANNOTATIONS_EXPECTED_FORM,
4026                    field,
4027                    "annotations must be a sequence or mapping",
4028                );
4029                None
4030            }
4031        }
4032    }
4033
4034    fn parse_annotation_list(&mut self, sequence: &yaml_edit::Sequence, fallback: SourceSpan) -> Annotations {
4035        let span = span_from_position(self.source_id, sequence.byte_range());
4036        let mut values = Vec::new();
4037        let mut seen = BTreeSet::new();
4038        for node in sequence.values() {
4039            let YamlNode::Scalar(scalar) = node else {
4040                self.unsupported_sequence_item(
4041                    ANNOTATIONS_EXPECTED_STRING,
4042                    &node,
4043                    fallback,
4044                    "annotation list entries must be string scalars",
4045                );
4046                continue;
4047            };
4048            let item_span = span_from_position(self.source_id, scalar.byte_range());
4049            let scalar_value = ScalarValue::from_scalar(&scalar);
4050            let value = match scalar_value.scalar_type() {
4051                ScalarType::Null => ComposeScalar::Null,
4052                ScalarType::Boolean => ComposeScalar::Boolean(scalar_value.to_bool().unwrap_or(false)),
4053                ScalarType::Integer | ScalarType::Float => {
4054                    ComposeScalar::Number(scalar_string_from_source(&self.source, &scalar))
4055                }
4056                ScalarType::String | ScalarType::Timestamp | ScalarType::Regex => {
4057                    ComposeScalar::String(scalar_string_from_source(&self.source, &scalar))
4058                }
4059            };
4060            self.validate_annotation_list_scalar(&value, item_span, &mut seen);
4061            values.push(Located::new(value, item_span));
4062        }
4063        Annotations::new(span, AnnotationsForm::List(values))
4064    }
4065
4066    fn validate_annotation_list_scalar(
4067        &mut self,
4068        value: &ComposeScalar,
4069        span: SourceSpan,
4070        seen: &mut BTreeSet<String>,
4071    ) {
4072        let ComposeScalar::String(raw) = value else {
4073            self.diagnostics.push(annotation_diagnostic(
4074                ANNOTATIONS_EXPECTED_STRING,
4075                Severity::Error,
4076                span,
4077                "annotation list entries must be string scalars",
4078                "non-string annotation item retained",
4079            ));
4080            return;
4081        };
4082        let name = raw.split_once('=').map_or(raw.as_str(), |(name, _)| name);
4083        if name.is_empty() {
4084            self.diagnostics.push(annotation_diagnostic(
4085                ANNOTATIONS_EMPTY_NAME,
4086                Severity::Error,
4087                span,
4088                "service annotation name must not be empty",
4089                "empty annotation name",
4090            ));
4091        } else if !seen.insert(name.to_owned()) {
4092            self.diagnostics.push(annotation_diagnostic(
4093                ANNOTATIONS_DUPLICATE_NAME,
4094                Severity::Error,
4095                span,
4096                "service annotation names must be unique",
4097                "duplicate annotation name",
4098            ));
4099        }
4100        if !raw.contains('=') {
4101            self.diagnostics.push(annotation_diagnostic(
4102                ANNOTATIONS_KEY_ONLY,
4103                Severity::Warning,
4104                span,
4105                "key-only service annotation has no explicit value",
4106                "ambiguous key-only annotation",
4107            ));
4108        }
4109    }
4110
4111    fn parse_annotation_map(&mut self, mapping: &Mapping) -> Annotations {
4112        let span = span_from_position(self.source_id, mapping.byte_range());
4113        let mut entries = Vec::new();
4114        let mut seen = BTreeMap::new();
4115        for entry in self.fields(mapping) {
4116            let _duplicate = self.record_duplicate(&mut seen, &entry);
4117            if entry.name.value.is_empty() {
4118                self.diagnostics.push(annotation_diagnostic(
4119                    ANNOTATIONS_EMPTY_NAME,
4120                    Severity::Error,
4121                    entry.name.span,
4122                    "service annotation name must not be empty",
4123                    "empty annotation name",
4124                ));
4125            }
4126            if let Some(value) = self.parse_compose_scalar(
4127                &entry,
4128                "annotation mapping values must be scalar strings, numbers, booleans, or null",
4129            ) {
4130                entries.push(KeyValueEntry::new(entry.name, value, entry.span));
4131            }
4132        }
4133        Annotations::new(span, AnnotationsForm::Map(entries))
4134    }
4135
4136    fn field_is_null(field: &ParsedField) -> bool {
4137        field.value.as_ref().is_none_or(|node| {
4138            node.as_scalar()
4139                .is_some_and(|scalar| ScalarValue::from_scalar(scalar).scalar_type() == ScalarType::Null)
4140        })
4141    }
4142
4143    fn unsupported_sequence_item(
4144        &mut self,
4145        code: DiagnosticCode,
4146        node: &YamlNode,
4147        fallback_span: SourceSpan,
4148        message: impl Into<String>,
4149    ) {
4150        let span = node_span(self.source_id, node).unwrap_or(fallback_span);
4151        self.diagnostics.push(
4152            Diagnostic::new(code, Severity::Error, message)
4153                .with_label(DiagnosticLabel::primary(span, "unsupported value form")),
4154        );
4155    }
4156
4157    fn fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
4158        let fields = self.raw_fields(mapping);
4159        let mut fields = self.flatten_empty_value_continuations(fields);
4160        for field in &mut fields {
4161            field.value = field.value.take().map(|value| self.resolve_alias(value));
4162        }
4163        fields
4164    }
4165
4166    fn raw_fields(&mut self, mapping: &Mapping) -> Vec<ParsedField> {
4167        mapping
4168            .entries()
4169            .filter_map(|entry| {
4170                let key = entry.key_node()?;
4171                let Some(scalar) = key.as_scalar() else {
4172                    let span = node_span(self.source_id, &key)
4173                        .unwrap_or_else(|| span_from_position(self.source_id, mapping.byte_range()));
4174                    self.diagnostics.push(
4175                        Diagnostic::new(EXPECTED_SCALAR, Severity::Error, "Compose mapping keys must be scalars")
4176                            .with_label(DiagnosticLabel::primary(span, "non-scalar key")),
4177                    );
4178                    return None;
4179                };
4180                let name_span = span_from_position(self.source_id, scalar.byte_range());
4181                let authored_value = entry.value_node();
4182                let value_span = authored_value
4183                    .as_ref()
4184                    .and_then(|value| node_span(self.source_id, value));
4185                let value = authored_value.map(unwrap_processing_tag);
4186                let span = value_span.map_or(name_span, |value_span| union(name_span, value_span));
4187                Some(ParsedField {
4188                    name: Located::new(scalar_string_from_source(&self.source, scalar), name_span),
4189                    value,
4190                    value_span,
4191                    span,
4192                })
4193            })
4194            .collect()
4195    }
4196
4197    fn resolve_alias(&self, node: YamlNode) -> YamlNode {
4198        let mut node = node;
4199        let mut visited = BTreeSet::new();
4200        for _ in 0..64 {
4201            let YamlNode::Alias(alias) = &node else {
4202                return node;
4203            };
4204            if !visited.insert(alias.name()) {
4205                return node;
4206            }
4207            let Some(target) = self.anchors.resolve(&alias.name()).and_then(|target| {
4208                YamlNode::from_syntax(target.clone()).or_else(|| target.children().find_map(YamlNode::from_syntax))
4209            }) else {
4210                return node;
4211            };
4212            node = target;
4213        }
4214        node
4215    }
4216
4217    fn flatten_empty_value_continuations(&mut self, fields: Vec<ParsedField>) -> Vec<ParsedField> {
4218        let Some(target_column) = fields.first().map(|field| self.source_column(field.name.span.start())) else {
4219            return fields;
4220        };
4221        self.recover_fields(fields, target_column)
4222    }
4223
4224    fn recover_fields(&mut self, fields: Vec<ParsedField>, target_column: usize) -> Vec<ParsedField> {
4225        let mut flattened = Vec::new();
4226        for mut field in fields {
4227            let field_column = self.source_column(field.name.span.start());
4228            let nested_mapping = field.value.as_ref().and_then(YamlNode::as_mapping).cloned();
4229            let continuation = nested_mapping.as_ref().is_some_and(|mapping| {
4230                !self.is_flow_mapping(mapping)
4231                    && mapping
4232                        .entries()
4233                        .find_map(|entry| {
4234                            let key = entry.key_node()?;
4235                            let scalar = key.as_scalar()?;
4236                            Some(scalar.byte_range().start as usize)
4237                        })
4238                        .is_some_and(|key_start| self.source_column(key_start) <= field_column)
4239            });
4240
4241            if continuation {
4242                field.value = None;
4243                field.value_span = None;
4244                field.span = field.name.span;
4245            }
4246            if field_column == target_column {
4247                flattened.push(field);
4248            }
4249            if let Some(mapping) = nested_mapping.filter(|mapping| !self.is_flow_mapping(mapping)) {
4250                let nested = self.raw_fields(&mapping);
4251                flattened.extend(self.recover_fields(nested, target_column));
4252            }
4253        }
4254        flattened
4255    }
4256
4257    fn is_flow_mapping(&self, mapping: &Mapping) -> bool {
4258        let position = mapping.byte_range();
4259        self.source
4260            .get(position.start as usize..position.end as usize)
4261            .is_some_and(|text| text.trim_start().starts_with('{'))
4262    }
4263
4264    fn record_duplicate(&mut self, seen: &mut BTreeMap<String, SourceSpan>, field: &ParsedField) -> bool {
4265        if let Some(first) = seen.get(field.name.value()) {
4266            self.diagnostics.push(
4267                Diagnostic::new(
4268                    DUPLICATE_FIELD,
4269                    Severity::Error,
4270                    "Compose mapping fields must be unique",
4271                )
4272                .with_label(DiagnosticLabel::primary(field.name.span, "duplicate field"))
4273                .with_label(DiagnosticLabel::secondary(*first, "first field")),
4274            );
4275            true
4276        } else {
4277            seen.insert(field.name.value.clone(), field.name.span);
4278            false
4279        }
4280    }
4281
4282    fn expected(&mut self, code: DiagnosticCode, field: &ParsedField, message: impl Into<String>) {
4283        self.diagnostics.push(
4284            Diagnostic::new(code, Severity::Error, message)
4285                .with_label(DiagnosticLabel::primary(field.span, "unexpected value form")),
4286        );
4287    }
4288
4289    fn missing(&mut self, code: DiagnosticCode, span: SourceSpan, message: &'static str) {
4290        self.diagnostics.push(
4291            Diagnostic::new(code, Severity::Error, message)
4292                .with_label(DiagnosticLabel::primary(span, "incomplete long syntax")),
4293        );
4294    }
4295}
4296
4297fn unwrap_processing_tag(node: YamlNode) -> YamlNode {
4298    let YamlNode::TaggedNode(tagged) = &node else {
4299        return node;
4300    };
4301    if !matches!(tagged.tag().as_deref(), Some("!reset" | "!override")) {
4302        return node;
4303    }
4304    tagged
4305        .as_node()
4306        .and_then(|syntax| syntax.children().find_map(YamlNode::from_syntax))
4307        .unwrap_or(node)
4308}
4309
4310#[derive(Debug, Clone)]
4311enum ParsedGrant {
4312    Short(Located<String>),
4313    Long(Box<LongGrant>),
4314}
4315
4316#[derive(Debug, Clone)]
4317struct ParsedField {
4318    name: Located<String>,
4319    value: Option<YamlNode>,
4320    value_span: Option<SourceSpan>,
4321    span: SourceSpan,
4322}
4323
4324impl ParsedField {
4325    fn reference(&self) -> FieldReference {
4326        FieldReference {
4327            name: self.name.clone(),
4328            span: self.span,
4329            value_span: self.value_span,
4330        }
4331    }
4332}
4333
4334fn node_span(source_id: SourceId, node: &YamlNode) -> Option<SourceSpan> {
4335    let position = match node {
4336        YamlNode::Scalar(value) => value.byte_range(),
4337        YamlNode::Mapping(value) => value.byte_range(),
4338        YamlNode::Sequence(value) => value.byte_range(),
4339        YamlNode::Alias(_) | YamlNode::TaggedNode(_) => {
4340            let range = node.as_node()?.text_range();
4341            return Some(SourceSpan::from_valid_offsets(
4342                source_id,
4343                u32::from(range.start()) as usize,
4344                u32::from(range.end()) as usize,
4345            ));
4346        }
4347    };
4348    Some(span_from_position(source_id, position))
4349}
4350
4351fn span_from_position(source_id: SourceId, position: yaml_edit::TextPosition) -> SourceSpan {
4352    SourceSpan::from_valid_offsets(source_id, position.start as usize, position.end as usize)
4353}
4354
4355fn union(left: SourceSpan, right: SourceSpan) -> SourceSpan {
4356    SourceSpan::from_valid_offsets(
4357        left.source_id(),
4358        left.start().min(right.start()),
4359        left.end().max(right.end()),
4360    )
4361}