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