1mod generated;
4mod preserved;
5
6pub use generated::{
7 ComposeDocumentBuilder, GeneratedAnnotation, GeneratedCommand, GeneratedComposeDocument,
8 GeneratedConfigFileDefinition, GeneratedCpuRtRuntime, GeneratedDevice, GeneratedDns, GeneratedDnsSearch,
9 GeneratedEntrypoint, GeneratedEnvironment, GeneratedEnvironmentFile, GeneratedEnvironmentFileFormat,
10 GeneratedExtraHost, GeneratedHostname, GeneratedLabel, GeneratedLogging, GeneratedLoggingOption,
11 GeneratedLoggingOptionValue, GeneratedLongDevice, GeneratedMemLimit, GeneratedMount, GeneratedNetworkAttachment,
12 GeneratedNetworkDefinition, GeneratedNetworkDriverOption, GeneratedNetworkDriverOptionValue, GeneratedPidsLimit,
13 GeneratedPort, GeneratedProtocol, GeneratedPullPolicy, GeneratedResource, GeneratedRestartPolicy,
14 GeneratedSecretFileDefinition, GeneratedSelinux, GeneratedService, GeneratedServiceRuntimeField, GeneratedShmSize,
15 GeneratedString, GeneratedSysctl, GeneratedSysctls, GeneratedTmpfs, GeneratedUlimit, GeneratedUlimitValue,
16 GeneratedUlimits, GeneratedVolumeDefinition, GeneratedVolumeDriverOption, GeneratedVolumeDriverOptionValue,
17 GenerationError,
18};
19
20pub use preserved::{
21 EDIT_INVALID_NUMBER, EDIT_OVERLAP, EDIT_SOURCE_MISMATCH, EDIT_TARGET_NOT_SCALAR, EDIT_UNSUPPORTED_SCALAR_STYLE,
22 PreservationEditResult, ReplacementScalar, ScalarEdit, apply_preservation_edits,
23};
24
25use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
26use crate::merge::{MergedEntry, MergedProject, MergedScalar, MergedScalarKind, MergedValue, MergedValueKind};
27use crate::profiles::ProfileSelection;
28use crate::resolution::{effective_span, selection_matches};
29use std::fmt;
30use yaml_edit::{ScalarStyle, ScalarType, ScalarValue, YamlFile};
31
32pub const UNRENDERABLE_ALIAS: DiagnosticCode = DiagnosticCode::new("compose.render.unresolved-alias");
34
35pub const UNRENDERABLE_TAG: DiagnosticCode = DiagnosticCode::new("compose.render.invalid-tag");
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
40pub struct IndentWidth(u8);
41
42impl IndentWidth {
43 pub const MIN: u8 = 1;
45
46 pub const CANONICAL: Self = Self(2);
48
49 #[must_use]
51 pub const fn new(spaces: u8) -> Option<Self> {
52 if spaces >= Self::MIN { Some(Self(spaces)) } else { None }
53 }
54
55 #[must_use]
57 pub const fn spaces(self) -> u8 {
58 self.0
59 }
60}
61
62#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
64pub enum LineEnding {
65 #[default]
67 Lf,
68 CrLf,
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
77pub struct CanonicalFormatting {
78 indent_width: IndentWidth,
79 line_ending: LineEnding,
80 document_marker: bool,
81 final_newline: bool,
82}
83
84impl CanonicalFormatting {
85 #[must_use]
87 pub const fn indent_width(self) -> IndentWidth {
88 self.indent_width
89 }
90
91 #[must_use]
93 pub const fn line_ending(self) -> LineEnding {
94 self.line_ending
95 }
96
97 #[must_use]
99 pub const fn document_marker(self) -> bool {
100 self.document_marker
101 }
102
103 #[must_use]
105 pub const fn final_newline(self) -> bool {
106 self.final_newline
107 }
108
109 #[must_use]
111 pub const fn with_indent_width(mut self, indent_width: IndentWidth) -> Self {
112 self.indent_width = indent_width;
113 self
114 }
115
116 #[must_use]
118 pub const fn with_line_ending(mut self, line_ending: LineEnding) -> Self {
119 self.line_ending = line_ending;
120 self
121 }
122
123 #[must_use]
125 pub const fn with_document_marker(mut self, document_marker: bool) -> Self {
126 self.document_marker = document_marker;
127 self
128 }
129
130 #[must_use]
132 pub const fn with_final_newline(mut self, final_newline: bool) -> Self {
133 self.final_newline = final_newline;
134 self
135 }
136}
137
138impl Default for CanonicalFormatting {
139 fn default() -> Self {
140 Self {
141 indent_width: IndentWidth::CANONICAL,
142 line_ending: LineEnding::Lf,
143 document_marker: true,
144 final_newline: true,
145 }
146 }
147}
148
149#[derive(Clone, PartialEq, Eq)]
155pub struct CanonicalRender {
156 output: String,
157 diagnostics: Vec<Diagnostic>,
158 sensitive: bool,
159}
160
161impl CanonicalRender {
162 #[must_use]
164 pub fn output(&self) -> &str {
165 &self.output
166 }
167
168 #[must_use]
170 pub fn into_output(self) -> String {
171 self.output
172 }
173
174 #[must_use]
176 pub fn diagnostics(&self) -> &[Diagnostic] {
177 &self.diagnostics
178 }
179
180 #[must_use]
182 pub fn is_valid(&self) -> bool {
183 self.diagnostics
184 .iter()
185 .all(|diagnostic| diagnostic.severity() != Severity::Error)
186 }
187
188 #[must_use]
190 pub const fn is_sensitive(&self) -> bool {
191 self.sensitive
192 }
193}
194
195impl fmt::Debug for CanonicalRender {
196 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
197 formatter
198 .debug_struct("CanonicalRender")
199 .field(
200 "output",
201 &if self.sensitive {
202 "<redacted>"
203 } else {
204 self.output.as_str()
205 },
206 )
207 .field("diagnostics", &self.diagnostics)
208 .field("sensitive", &self.sensitive)
209 .finish()
210 }
211}
212
213#[must_use]
220pub fn render_canonical(project: &MergedProject, selection: Option<&ProfileSelection>) -> CanonicalRender {
221 render_canonical_with_formatting(project, selection, &CanonicalFormatting::default())
222}
223
224#[must_use]
229pub fn render_canonical_with_formatting(
230 project: &MergedProject,
231 selection: Option<&ProfileSelection>,
232 formatting: &CanonicalFormatting,
233) -> CanonicalRender {
234 let mut diagnostics = Vec::new();
235 if !selection_matches(project, selection, &mut diagnostics) {
236 return CanonicalRender {
237 output: String::new(),
238 diagnostics,
239 sensitive: false,
240 };
241 }
242
243 let mut renderer = Renderer {
244 output: String::new(),
245 diagnostics,
246 sensitive: false,
247 formatting: *formatting,
248 };
249 renderer.write_project(project, selection);
250 renderer.finish_formatting();
251 CanonicalRender {
252 output: renderer.output,
253 diagnostics: renderer.diagnostics,
254 sensitive: renderer.sensitive,
255 }
256}
257
258struct Renderer {
259 output: String,
260 diagnostics: Vec<Diagnostic>,
261 sensitive: bool,
262 formatting: CanonicalFormatting,
263}
264
265impl Renderer {
266 fn write_project(&mut self, project: &MergedProject, selection: Option<&ProfileSelection>) {
267 if self.formatting.document_marker {
268 self.output.push_str("---\n");
269 }
270 let Some(entries) = project.root().as_mapping() else {
271 self.write_inline(project.root());
272 self.output.push('\n');
273 return;
274 };
275 if entries.is_empty() {
276 self.output.push_str("{}\n");
277 return;
278 }
279 for entry in entries {
280 if entry.key() == "services" && selection.is_some() {
281 self.write_selected_services(entry, selection);
282 } else {
283 self.write_entry(entry, 0);
284 }
285 }
286 }
287
288 fn write_selected_services(&mut self, entry: &MergedEntry, selection: Option<&ProfileSelection>) {
289 let Some(services) = entry.value().as_mapping() else {
290 self.write_entry(entry, 0);
291 return;
292 };
293 self.write_indent(0);
294 write_quoted(&mut self.output, entry.key());
295 self.output.push(':');
296 let active: Vec<_> = services
297 .iter()
298 .filter(|service| selection.is_none_or(|selection| selection.is_active(service.key())))
299 .collect();
300 if active.is_empty() {
301 self.output.push_str(" {}\n");
302 return;
303 }
304 self.output.push('\n');
305 for service in active {
306 self.write_entry(service, self.indent_width());
307 }
308 }
309
310 fn write_entry(&mut self, entry: &MergedEntry, indent: usize) {
311 self.write_indent(indent);
312 write_quoted(&mut self.output, entry.key());
313 self.output.push(':');
314 self.write_after_indicator(entry.value(), indent + self.indent_width());
315 }
316
317 fn write_sequence_item(&mut self, value: &MergedValue, indent: usize) {
318 self.write_indent(indent);
319 self.output.push('-');
320 self.sensitive |= value.is_sensitive();
321 let core = self.write_tag_prefixes(value);
322 if let MergedValueKind::Mapping(entries) = core.kind() {
323 if let Some((first, remaining)) = entries.split_first() {
324 self.output.push(' ');
325 write_quoted(&mut self.output, first.key());
326 self.output.push(':');
327 self.write_after_indicator(first.value(), indent + (self.indent_width() * 2));
328 for entry in remaining {
329 self.write_entry(entry, indent + self.indent_width());
330 }
331 return;
332 }
333 }
334 self.write_after_indicator(value, indent + self.indent_width());
335 }
336
337 fn write_after_indicator(&mut self, value: &MergedValue, nested_indent: usize) {
338 self.sensitive |= value.is_sensitive();
339 let core = self.write_tag_prefixes(value);
340 if non_empty_collection(core) {
341 self.output.push('\n');
342 self.write_block(core, nested_indent);
343 } else {
344 self.output.push(' ');
345 self.write_inline(core);
346 self.output.push('\n');
347 }
348 }
349
350 fn write_tag_prefixes<'a>(&mut self, mut value: &'a MergedValue) -> &'a MergedValue {
351 while let MergedValueKind::Tagged { tag, value: inner } = value.kind() {
352 if valid_tag(tag) {
353 self.output.push(' ');
354 self.output.push_str(tag);
355 } else {
356 self.diagnostics.push(
357 Diagnostic::new(
358 UNRENDERABLE_TAG,
359 Severity::Error,
360 "retained YAML tag cannot be emitted canonically",
361 )
362 .with_label(DiagnosticLabel::primary(
363 effective_span(value),
364 "invalid canonical tag token",
365 )),
366 );
367 }
368 value = inner;
369 }
370 value
371 }
372
373 fn write_block(&mut self, value: &MergedValue, indent: usize) {
374 match value.kind() {
375 MergedValueKind::Mapping(entries) => {
376 for entry in entries {
377 self.write_entry(entry, indent);
378 }
379 }
380 MergedValueKind::Sequence(values) => {
381 for value in values {
382 self.write_sequence_item(value, indent);
383 }
384 }
385 MergedValueKind::Tagged { .. } => {
386 self.write_indent(indent);
387 self.write_after_indicator(value, indent + self.indent_width());
388 }
389 MergedValueKind::Null(_) | MergedValueKind::Scalar(_) | MergedValueKind::Alias(_) => {
390 self.write_indent(indent);
391 self.write_inline(value);
392 self.output.push('\n');
393 }
394 }
395 }
396
397 fn write_inline(&mut self, value: &MergedValue) {
398 match value.kind() {
399 MergedValueKind::Null(_) => self.output.push_str("null"),
400 MergedValueKind::Scalar(scalar) => self.write_scalar(scalar),
401 MergedValueKind::Mapping(entries) if entries.is_empty() => self.output.push_str("{}"),
402 MergedValueKind::Sequence(values) if values.is_empty() => self.output.push_str("[]"),
403 MergedValueKind::Alias(_) => {
404 self.diagnostics.push(
405 Diagnostic::new(
406 UNRENDERABLE_ALIAS,
407 Severity::Error,
408 "unresolved YAML alias cannot be emitted in a standalone canonical document",
409 )
410 .with_label(DiagnosticLabel::primary(
411 effective_span(value),
412 "alias has no resolved canonical value",
413 )),
414 );
415 self.output.push_str("null");
416 }
417 MergedValueKind::Tagged { .. } => {
418 let core = self.write_tag_prefixes(value);
419 self.output.push(' ');
420 self.write_inline(core);
421 }
422 MergedValueKind::Mapping(_) | MergedValueKind::Sequence(_) => {}
423 }
424 }
425
426 fn write_scalar(&mut self, scalar: &MergedScalar) {
427 self.sensitive |= scalar.is_sensitive();
428 match scalar.kind() {
429 MergedScalarKind::Boolean if scalar.value().eq_ignore_ascii_case("true") => {
430 self.output.push_str("true");
431 }
432 MergedScalarKind::Boolean if scalar.value().eq_ignore_ascii_case("false") => {
433 self.output.push_str("false");
434 }
435 MergedScalarKind::String | MergedScalarKind::Boolean => {
436 write_quoted(&mut self.output, scalar.value());
437 }
438 MergedScalarKind::Number => self.output.push_str(scalar.value()),
439 }
440 }
441
442 fn write_indent(&mut self, indent: usize) {
443 self.output.extend(std::iter::repeat_n(' ', indent));
444 }
445
446 fn indent_width(&self) -> usize {
447 usize::from(self.formatting.indent_width.spaces())
448 }
449
450 fn finish_formatting(&mut self) {
451 if !self.formatting.final_newline && self.output.ends_with('\n') {
452 let _ = self.output.pop();
453 }
454 if self.formatting.line_ending == LineEnding::CrLf {
455 self.output = self.output.replace('\n', "\r\n");
456 }
457 }
458}
459
460fn non_empty_collection(value: &MergedValue) -> bool {
461 match value.kind() {
462 MergedValueKind::Mapping(entries) => !entries.is_empty(),
463 MergedValueKind::Sequence(values) => !values.is_empty(),
464 _ => false,
465 }
466}
467
468fn valid_tag(tag: &str) -> bool {
469 if let Some(verbatim) = tag.strip_prefix("!<").and_then(|tag| tag.strip_suffix('>')) {
470 return !verbatim.is_empty()
471 && verbatim
472 .bytes()
473 .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'<' | b'>'));
474 }
475 tag.starts_with('!')
476 && tag.len() > 1
477 && tag
478 .bytes()
479 .skip(1)
480 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'!' | b'_' | b'-' | b'.' | b':' | b'/'))
481}
482
483fn write_quoted(output: &mut String, value: &str) {
489 if plain_string_safe(value) {
490 output.push_str(value);
491 return;
492 }
493
494 write_double_quoted(output, value);
495}
496
497fn plain_string_safe(value: &str) -> bool {
498 if value.is_empty() || value.trim() != value || value.contains(['\n', '\r']) || yaml_1_1_ambiguous_string(value) {
499 return false;
500 }
501
502 let parse = YamlFile::parse(value);
503 if !parse.ok() {
504 return false;
505 }
506 let file = parse.tree();
507 let Some(document) = file.document() else {
508 return false;
509 };
510 let Some(scalar) = document.as_scalar() else {
511 return false;
512 };
513 let range = scalar.byte_range();
514 range.start == 0
515 && range.end as usize == value.len()
516 && ScalarValue::from_scalar(&scalar).style() == ScalarStyle::Plain
517 && ScalarValue::from_scalar(&scalar).scalar_type() == ScalarType::String
518}
519
520fn yaml_1_1_ambiguous_string(value: &str) -> bool {
521 matches!(
522 value,
523 "y" | "Y"
524 | "yes"
525 | "Yes"
526 | "YES"
527 | "n"
528 | "N"
529 | "no"
530 | "No"
531 | "NO"
532 | "true"
533 | "True"
534 | "TRUE"
535 | "false"
536 | "False"
537 | "FALSE"
538 | "on"
539 | "On"
540 | "ON"
541 | "off"
542 | "Off"
543 | "OFF"
544 | "null"
545 | "Null"
546 | "NULL"
547 | "~"
548 ) || yaml_1_1_sexagesimal(value)
549 || yaml_1_1_special_float(value)
550 || yaml_1_1_timestamp(value)
551}
552
553fn yaml_1_1_sexagesimal(value: &str) -> bool {
554 let mut components = value.split(':');
555 let Some(first) = components.next() else {
556 return false;
557 };
558 if components.clone().next().is_none() || first.is_empty() || !first.bytes().all(|byte| byte.is_ascii_digit()) {
559 return false;
560 }
561 components.all(|component| !component.is_empty() && component.bytes().all(|byte| byte.is_ascii_digit()))
562}
563
564fn yaml_1_1_special_float(value: &str) -> bool {
565 matches!(value.to_ascii_lowercase().as_str(), ".inf" | "+.inf" | "-.inf" | ".nan")
566}
567
568fn yaml_1_1_timestamp(value: &str) -> bool {
569 let date_end = value.find(['T', 't', ' ']).unwrap_or(value.len());
570 let date = &value[..date_end];
571 let mut components = date.split('-');
572 let (Some(year), Some(month), Some(day), None) = (
573 components.next(),
574 components.next(),
575 components.next(),
576 components.next(),
577 ) else {
578 return false;
579 };
580
581 year.len() == 4
582 && (1..=2).contains(&month.len())
583 && (1..=2).contains(&day.len())
584 && year.bytes().all(|byte| byte.is_ascii_digit())
585 && month.bytes().all(|byte| byte.is_ascii_digit())
586 && day.bytes().all(|byte| byte.is_ascii_digit())
587}
588
589fn write_double_quoted(output: &mut String, value: &str) {
590 output.push('"');
591 for character in value.chars() {
592 match character {
593 '"' => output.push_str("\\\""),
594 '\\' => output.push_str("\\\\"),
595 '\u{08}' => output.push_str("\\b"),
596 '\t' => output.push_str("\\t"),
597 '\n' => output.push_str("\\n"),
598 '\u{0c}' => output.push_str("\\f"),
599 '\r' => output.push_str("\\r"),
600 character
601 if character.is_control() || matches!(character, '\u{85}' | '\u{2028}' | '\u{2029}' | '\u{feff}') =>
602 {
603 push_unicode_escape(output, character);
604 }
605 character => output.push(character),
606 }
607 }
608 output.push('"');
609}
610
611fn push_unicode_escape(output: &mut String, character: char) {
612 const HEX: &[u8; 16] = b"0123456789ABCDEF";
613 let value = character as u32;
614 if value <= 0xffff {
615 output.push_str("\\u");
616 for shift in [12, 8, 4, 0] {
617 output.push(HEX[((value >> shift) & 0xf) as usize] as char);
618 }
619 } else {
620 output.push_str("\\U");
621 for shift in [28, 24, 20, 16, 12, 8, 4, 0] {
622 output.push(HEX[((value >> shift) & 0xf) as usize] as char);
623 }
624 }
625}