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;
30
31pub const UNRENDERABLE_ALIAS: DiagnosticCode = DiagnosticCode::new("compose.render.unresolved-alias");
33
34pub const UNRENDERABLE_TAG: DiagnosticCode = DiagnosticCode::new("compose.render.invalid-tag");
36
37#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39pub struct IndentWidth(u8);
40
41impl IndentWidth {
42 pub const MIN: u8 = 1;
44
45 pub const CANONICAL: Self = Self(2);
47
48 #[must_use]
50 pub const fn new(spaces: u8) -> Option<Self> {
51 if spaces >= Self::MIN { Some(Self(spaces)) } else { None }
52 }
53
54 #[must_use]
56 pub const fn spaces(self) -> u8 {
57 self.0
58 }
59}
60
61#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
63pub enum LineEnding {
64 #[default]
66 Lf,
67 CrLf,
69}
70
71#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
76pub struct CanonicalFormatting {
77 indent_width: IndentWidth,
78 line_ending: LineEnding,
79 document_marker: bool,
80 final_newline: bool,
81}
82
83impl CanonicalFormatting {
84 #[must_use]
86 pub const fn indent_width(self) -> IndentWidth {
87 self.indent_width
88 }
89
90 #[must_use]
92 pub const fn line_ending(self) -> LineEnding {
93 self.line_ending
94 }
95
96 #[must_use]
98 pub const fn document_marker(self) -> bool {
99 self.document_marker
100 }
101
102 #[must_use]
104 pub const fn final_newline(self) -> bool {
105 self.final_newline
106 }
107
108 #[must_use]
110 pub const fn with_indent_width(mut self, indent_width: IndentWidth) -> Self {
111 self.indent_width = indent_width;
112 self
113 }
114
115 #[must_use]
117 pub const fn with_line_ending(mut self, line_ending: LineEnding) -> Self {
118 self.line_ending = line_ending;
119 self
120 }
121
122 #[must_use]
124 pub const fn with_document_marker(mut self, document_marker: bool) -> Self {
125 self.document_marker = document_marker;
126 self
127 }
128
129 #[must_use]
131 pub const fn with_final_newline(mut self, final_newline: bool) -> Self {
132 self.final_newline = final_newline;
133 self
134 }
135}
136
137impl Default for CanonicalFormatting {
138 fn default() -> Self {
139 Self {
140 indent_width: IndentWidth::CANONICAL,
141 line_ending: LineEnding::Lf,
142 document_marker: false,
143 final_newline: true,
144 }
145 }
146}
147
148#[derive(Clone, PartialEq, Eq)]
154pub struct CanonicalRender {
155 output: String,
156 diagnostics: Vec<Diagnostic>,
157 sensitive: bool,
158}
159
160impl CanonicalRender {
161 #[must_use]
163 pub fn output(&self) -> &str {
164 &self.output
165 }
166
167 #[must_use]
169 pub fn into_output(self) -> String {
170 self.output
171 }
172
173 #[must_use]
175 pub fn diagnostics(&self) -> &[Diagnostic] {
176 &self.diagnostics
177 }
178
179 #[must_use]
181 pub fn is_valid(&self) -> bool {
182 self.diagnostics
183 .iter()
184 .all(|diagnostic| diagnostic.severity() != Severity::Error)
185 }
186
187 #[must_use]
189 pub const fn is_sensitive(&self) -> bool {
190 self.sensitive
191 }
192}
193
194impl fmt::Debug for CanonicalRender {
195 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
196 formatter
197 .debug_struct("CanonicalRender")
198 .field(
199 "output",
200 &if self.sensitive {
201 "<redacted>"
202 } else {
203 self.output.as_str()
204 },
205 )
206 .field("diagnostics", &self.diagnostics)
207 .field("sensitive", &self.sensitive)
208 .finish()
209 }
210}
211
212#[must_use]
219pub fn render_canonical(project: &MergedProject, selection: Option<&ProfileSelection>) -> CanonicalRender {
220 render_canonical_with_formatting(project, selection, &CanonicalFormatting::default())
221}
222
223#[must_use]
228pub fn render_canonical_with_formatting(
229 project: &MergedProject,
230 selection: Option<&ProfileSelection>,
231 formatting: &CanonicalFormatting,
232) -> CanonicalRender {
233 let mut diagnostics = Vec::new();
234 if !selection_matches(project, selection, &mut diagnostics) {
235 return CanonicalRender {
236 output: String::new(),
237 diagnostics,
238 sensitive: false,
239 };
240 }
241
242 let mut renderer = Renderer {
243 output: String::new(),
244 diagnostics,
245 sensitive: false,
246 formatting: *formatting,
247 };
248 renderer.write_project(project, selection);
249 renderer.finish_formatting();
250 CanonicalRender {
251 output: renderer.output,
252 diagnostics: renderer.diagnostics,
253 sensitive: renderer.sensitive,
254 }
255}
256
257struct Renderer {
258 output: String,
259 diagnostics: Vec<Diagnostic>,
260 sensitive: bool,
261 formatting: CanonicalFormatting,
262}
263
264impl Renderer {
265 fn write_project(&mut self, project: &MergedProject, selection: Option<&ProfileSelection>) {
266 if self.formatting.document_marker {
267 self.output.push_str("---\n");
268 }
269 let Some(entries) = project.root().as_mapping() else {
270 self.write_inline(project.root());
271 self.output.push('\n');
272 return;
273 };
274 if entries.is_empty() {
275 self.output.push_str("{}\n");
276 return;
277 }
278 for entry in entries {
279 if entry.key() == "services" && selection.is_some() {
280 self.write_selected_services(entry, selection);
281 } else {
282 self.write_entry(entry, 0);
283 }
284 }
285 }
286
287 fn write_selected_services(&mut self, entry: &MergedEntry, selection: Option<&ProfileSelection>) {
288 let Some(services) = entry.value().as_mapping() else {
289 self.write_entry(entry, 0);
290 return;
291 };
292 self.write_indent(0);
293 write_quoted(&mut self.output, entry.key());
294 self.output.push(':');
295 let active: Vec<_> = services
296 .iter()
297 .filter(|service| selection.is_none_or(|selection| selection.is_active(service.key())))
298 .collect();
299 if active.is_empty() {
300 self.output.push_str(" {}\n");
301 return;
302 }
303 self.output.push('\n');
304 for service in active {
305 self.write_entry(service, self.indent_width());
306 }
307 }
308
309 fn write_entry(&mut self, entry: &MergedEntry, indent: usize) {
310 self.write_indent(indent);
311 write_quoted(&mut self.output, entry.key());
312 self.output.push(':');
313 self.write_after_indicator(entry.value(), indent + self.indent_width());
314 }
315
316 fn write_sequence_item(&mut self, value: &MergedValue, indent: usize) {
317 self.write_indent(indent);
318 self.output.push('-');
319 self.write_after_indicator(value, indent + self.indent_width());
320 }
321
322 fn write_after_indicator(&mut self, value: &MergedValue, nested_indent: usize) {
323 self.sensitive |= value.is_sensitive();
324 let core = self.write_tag_prefixes(value);
325 if non_empty_collection(core) {
326 self.output.push('\n');
327 self.write_block(core, nested_indent);
328 } else {
329 self.output.push(' ');
330 self.write_inline(core);
331 self.output.push('\n');
332 }
333 }
334
335 fn write_tag_prefixes<'a>(&mut self, mut value: &'a MergedValue) -> &'a MergedValue {
336 while let MergedValueKind::Tagged { tag, value: inner } = value.kind() {
337 if valid_tag(tag) {
338 self.output.push(' ');
339 self.output.push_str(tag);
340 } else {
341 self.diagnostics.push(
342 Diagnostic::new(
343 UNRENDERABLE_TAG,
344 Severity::Error,
345 "retained YAML tag cannot be emitted canonically",
346 )
347 .with_label(DiagnosticLabel::primary(
348 effective_span(value),
349 "invalid canonical tag token",
350 )),
351 );
352 }
353 value = inner;
354 }
355 value
356 }
357
358 fn write_block(&mut self, value: &MergedValue, indent: usize) {
359 match value.kind() {
360 MergedValueKind::Mapping(entries) => {
361 for entry in entries {
362 self.write_entry(entry, indent);
363 }
364 }
365 MergedValueKind::Sequence(values) => {
366 for value in values {
367 self.write_sequence_item(value, indent);
368 }
369 }
370 MergedValueKind::Tagged { .. } => {
371 self.write_indent(indent);
372 self.write_after_indicator(value, indent + self.indent_width());
373 }
374 MergedValueKind::Null(_) | MergedValueKind::Scalar(_) | MergedValueKind::Alias(_) => {
375 self.write_indent(indent);
376 self.write_inline(value);
377 self.output.push('\n');
378 }
379 }
380 }
381
382 fn write_inline(&mut self, value: &MergedValue) {
383 match value.kind() {
384 MergedValueKind::Null(_) => self.output.push_str("null"),
385 MergedValueKind::Scalar(scalar) => self.write_scalar(scalar),
386 MergedValueKind::Mapping(entries) if entries.is_empty() => self.output.push_str("{}"),
387 MergedValueKind::Sequence(values) if values.is_empty() => self.output.push_str("[]"),
388 MergedValueKind::Alias(_) => {
389 self.diagnostics.push(
390 Diagnostic::new(
391 UNRENDERABLE_ALIAS,
392 Severity::Error,
393 "unresolved YAML alias cannot be emitted in a standalone canonical document",
394 )
395 .with_label(DiagnosticLabel::primary(
396 effective_span(value),
397 "alias has no resolved canonical value",
398 )),
399 );
400 self.output.push_str("null");
401 }
402 MergedValueKind::Tagged { .. } => {
403 let core = self.write_tag_prefixes(value);
404 self.output.push(' ');
405 self.write_inline(core);
406 }
407 MergedValueKind::Mapping(_) | MergedValueKind::Sequence(_) => {}
408 }
409 }
410
411 fn write_scalar(&mut self, scalar: &MergedScalar) {
412 self.sensitive |= scalar.is_sensitive();
413 match scalar.kind() {
414 MergedScalarKind::Boolean if scalar.value().eq_ignore_ascii_case("true") => {
415 self.output.push_str("true");
416 }
417 MergedScalarKind::Boolean if scalar.value().eq_ignore_ascii_case("false") => {
418 self.output.push_str("false");
419 }
420 MergedScalarKind::String | MergedScalarKind::Boolean => {
421 write_quoted(&mut self.output, scalar.value());
422 }
423 MergedScalarKind::Number => self.output.push_str(scalar.value()),
424 }
425 }
426
427 fn write_indent(&mut self, indent: usize) {
428 self.output.extend(std::iter::repeat_n(' ', indent));
429 }
430
431 fn indent_width(&self) -> usize {
432 usize::from(self.formatting.indent_width.spaces())
433 }
434
435 fn finish_formatting(&mut self) {
436 if !self.formatting.final_newline && self.output.ends_with('\n') {
437 let _ = self.output.pop();
438 }
439 if self.formatting.line_ending == LineEnding::CrLf {
440 self.output = self.output.replace('\n', "\r\n");
441 }
442 }
443}
444
445fn non_empty_collection(value: &MergedValue) -> bool {
446 match value.kind() {
447 MergedValueKind::Mapping(entries) => !entries.is_empty(),
448 MergedValueKind::Sequence(values) => !values.is_empty(),
449 _ => false,
450 }
451}
452
453fn valid_tag(tag: &str) -> bool {
454 if let Some(verbatim) = tag.strip_prefix("!<").and_then(|tag| tag.strip_suffix('>')) {
455 return !verbatim.is_empty()
456 && verbatim
457 .bytes()
458 .all(|byte| byte.is_ascii_graphic() && !matches!(byte, b'<' | b'>'));
459 }
460 tag.starts_with('!')
461 && tag.len() > 1
462 && tag
463 .bytes()
464 .skip(1)
465 .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'!' | b'_' | b'-' | b'.' | b':' | b'/'))
466}
467
468fn write_quoted(output: &mut String, value: &str) {
469 output.push('"');
470 for character in value.chars() {
471 match character {
472 '"' => output.push_str("\\\""),
473 '\\' => output.push_str("\\\\"),
474 '\u{08}' => output.push_str("\\b"),
475 '\t' => output.push_str("\\t"),
476 '\n' => output.push_str("\\n"),
477 '\u{0c}' => output.push_str("\\f"),
478 '\r' => output.push_str("\\r"),
479 character
480 if character.is_control() || matches!(character, '\u{85}' | '\u{2028}' | '\u{2029}' | '\u{feff}') =>
481 {
482 push_unicode_escape(output, character);
483 }
484 character => output.push(character),
485 }
486 }
487 output.push('"');
488}
489
490fn push_unicode_escape(output: &mut String, character: char) {
491 const HEX: &[u8; 16] = b"0123456789ABCDEF";
492 let value = character as u32;
493 if value <= 0xffff {
494 output.push_str("\\u");
495 for shift in [12, 8, 4, 0] {
496 output.push(HEX[((value >> shift) & 0xf) as usize] as char);
497 }
498 } else {
499 output.push_str("\\U");
500 for shift in [28, 24, 20, 16, 12, 8, 4, 0] {
501 output.push(HEX[((value >> shift) & 0xf) as usize] as char);
502 }
503 }
504}