Skip to main content

compose_lens/render/
mod.rs

1//! Deterministic rendering and preservation-oriented editing of ComposeLens-owned documents.
2
3mod 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
16/// An unresolved alias cannot be represented in a standalone canonical document.
17pub const UNRENDERABLE_ALIAS: DiagnosticCode = DiagnosticCode::new("compose.render.unresolved-alias");
18
19/// A retained YAML tag is not safe to emit as a canonical tag token.
20pub const UNRENDERABLE_TAG: DiagnosticCode = DiagnosticCode::new("compose.render.invalid-tag");
21
22/// A valid number of spaces used for one canonical YAML indentation level.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
24pub struct IndentWidth(u8);
25
26impl IndentWidth {
27    /// The smallest supported indentation width.
28    pub const MIN: u8 = 1;
29
30    /// The fixed width used by canonical-v1 output.
31    pub const CANONICAL: Self = Self(2);
32
33    /// Creates a validated indentation width.
34    #[must_use]
35    pub const fn new(spaces: u8) -> Option<Self> {
36        if spaces >= Self::MIN { Some(Self(spaces)) } else { None }
37    }
38
39    /// Returns the number of spaces in one indentation level.
40    #[must_use]
41    pub const fn spaces(self) -> u8 {
42        self.0
43    }
44}
45
46/// A presentation-only line-ending choice for rendered YAML.
47#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)]
48pub enum LineEnding {
49    /// A single line-feed byte.
50    #[default]
51    Lf,
52    /// A carriage return followed by a line feed.
53    CrLf,
54}
55
56/// Presentation-only options for deterministic merged-project rendering.
57///
58/// These options cannot interpolate, merge, select profiles, apply defaults, reorder mappings,
59/// or change retained Compose short/long forms. [`Self::default`] is the fixed canonical-v1 format.
60#[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    /// Returns the number of spaces used for each nested YAML level.
70    #[must_use]
71    pub const fn indent_width(self) -> IndentWidth {
72        self.indent_width
73    }
74
75    /// Returns the selected line-ending convention.
76    #[must_use]
77    pub const fn line_ending(self) -> LineEnding {
78        self.line_ending
79    }
80
81    /// Reports whether output starts with a YAML document marker.
82    #[must_use]
83    pub const fn document_marker(self) -> bool {
84        self.document_marker
85    }
86
87    /// Reports whether non-empty output ends with the selected line ending.
88    #[must_use]
89    pub const fn final_newline(self) -> bool {
90        self.final_newline
91    }
92
93    /// Returns options with a different validated indentation width.
94    #[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    /// Returns options with a different line-ending convention.
101    #[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    /// Returns options with YAML document-marker emission enabled or disabled.
108    #[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    /// Returns options with a final line ending enabled or disabled.
115    #[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/// The result of deterministic canonical rendering.
134///
135/// The output can contain interpolated secrets and is therefore available only through explicit
136/// accessors. Its `Debug` representation redacts the complete output when any rendered value is
137/// sensitive.
138#[derive(Clone, PartialEq, Eq)]
139pub struct CanonicalRender {
140    output: String,
141    diagnostics: Vec<Diagnostic>,
142    sensitive: bool,
143}
144
145impl CanonicalRender {
146    /// Returns the rendered UTF-8 YAML document.
147    #[must_use]
148    pub fn output(&self) -> &str {
149        &self.output
150    }
151
152    /// Consumes the result and returns the rendered document.
153    #[must_use]
154    pub fn into_output(self) -> String {
155        self.output
156    }
157
158    /// Returns canonical-rendering diagnostics.
159    #[must_use]
160    pub fn diagnostics(&self) -> &[Diagnostic] {
161        &self.diagnostics
162    }
163
164    /// Reports whether the renderer emitted no error diagnostics.
165    #[must_use]
166    pub fn is_valid(&self) -> bool {
167        self.diagnostics
168            .iter()
169            .all(|diagnostic| diagnostic.severity() != Severity::Error)
170    }
171
172    /// Reports whether any rendered value contains sensitive interpolation output.
173    #[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/// Renders a merged project as deterministic `ComposeLens` canonical YAML.
198///
199/// The renderer preserves merged mapping/sequence order and retained short/long forms. It does not
200/// interpolate, resolve paths, apply defaults, normalize syntax variants, or invoke a Compose
201/// implementation. When a matching selection is supplied, profile-inactive services are omitted
202/// while top-level resources remain available.
203#[must_use]
204pub fn render_canonical(project: &MergedProject, selection: Option<&ProfileSelection>) -> CanonicalRender {
205    render_canonical_with_formatting(project, selection, &CanonicalFormatting::default())
206}
207
208/// Renders a merged project with explicit presentation-only formatting choices.
209///
210/// Semantic processing remains identical to [`render_canonical`]. The default formatting value
211/// produces byte-identical canonical-v1 output.
212#[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}