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