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