Skip to main content

compose_lens/render/
preserved.rs

1//! Atomic preservation-oriented edits over exact YAML value-scalar spans.
2
3use super::write_quoted;
4use crate::diagnostic::{Diagnostic, DiagnosticCode, DiagnosticLabel, Severity};
5use crate::source::SourceSpan;
6use crate::syntax::SyntaxDocument;
7use std::fmt;
8use yaml_edit::{ScalarType, ScalarValue, YamlFile};
9
10/// An edit targets a source document other than the supplied syntax document.
11pub const EDIT_SOURCE_MISMATCH: DiagnosticCode = DiagnosticCode::new("compose.edit.source-mismatch");
12
13/// An edit target is not exactly one YAML value scalar.
14pub const EDIT_TARGET_NOT_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.edit.target-not-scalar");
15
16/// Two requested edits modify overlapping source ranges.
17pub const EDIT_OVERLAP: DiagnosticCode = DiagnosticCode::new("compose.edit.overlap");
18
19/// The authored scalar style cannot yet be replaced without risking surrounding syntax.
20pub const EDIT_UNSUPPORTED_SCALAR_STYLE: DiagnosticCode = DiagnosticCode::new("compose.edit.unsupported-scalar-style");
21
22/// A requested numeric spelling is not one complete YAML number scalar.
23pub const EDIT_INVALID_NUMBER: DiagnosticCode = DiagnosticCode::new("compose.edit.invalid-number");
24
25/// A typed scalar value to place at an existing YAML value-scalar span.
26#[derive(Clone, PartialEq, Eq)]
27pub struct ReplacementScalar {
28    kind: ReplacementKind,
29    sensitive: bool,
30}
31
32#[derive(Clone, PartialEq, Eq)]
33enum ReplacementKind {
34    String(String),
35    Boolean(bool),
36    Number(String),
37    Null,
38}
39
40impl ReplacementScalar {
41    /// Creates a non-sensitive YAML string replacement.
42    #[must_use]
43    pub fn string(value: impl Into<String>) -> Self {
44        Self {
45            kind: ReplacementKind::String(value.into()),
46            sensitive: false,
47        }
48    }
49
50    /// Creates a sensitive YAML string replacement.
51    ///
52    /// The value remains available only through the explicit edit operation. `Debug` output for
53    /// the replacement, containing edit, and successful result is redacted.
54    #[must_use]
55    pub fn sensitive_string(value: impl Into<String>) -> Self {
56        Self {
57            kind: ReplacementKind::String(value.into()),
58            sensitive: true,
59        }
60    }
61
62    /// Creates a YAML boolean replacement.
63    #[must_use]
64    pub const fn boolean(value: bool) -> Self {
65        Self {
66            kind: ReplacementKind::Boolean(value),
67            sensitive: false,
68        }
69    }
70
71    /// Creates a YAML number replacement whose spelling is validated when the edit is applied.
72    #[must_use]
73    pub fn number(value: impl Into<String>) -> Self {
74        Self {
75            kind: ReplacementKind::Number(value.into()),
76            sensitive: false,
77        }
78    }
79
80    /// Creates an explicit YAML null replacement.
81    #[must_use]
82    pub const fn null() -> Self {
83        Self {
84            kind: ReplacementKind::Null,
85            sensitive: false,
86        }
87    }
88
89    /// Reports whether the replacement contains sensitive caller-supplied content.
90    #[must_use]
91    pub const fn is_sensitive(&self) -> bool {
92        self.sensitive
93    }
94}
95
96impl fmt::Debug for ReplacementScalar {
97    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
98        let mut debug = formatter.debug_struct("ReplacementScalar");
99        match &self.kind {
100            ReplacementKind::String(value) => {
101                debug.field("kind", &"String");
102                debug.field("value", &if self.sensitive { "<redacted>" } else { value });
103            }
104            ReplacementKind::Boolean(value) => {
105                debug.field("kind", &"Boolean");
106                debug.field("value", value);
107            }
108            ReplacementKind::Number(value) => {
109                debug.field("kind", &"Number");
110                debug.field("value", value);
111            }
112            ReplacementKind::Null => {
113                debug.field("kind", &"Null");
114            }
115        }
116        debug.field("sensitive", &self.sensitive).finish()
117    }
118}
119
120/// One exact value-scalar replacement in a syntax document.
121#[derive(Clone, PartialEq, Eq)]
122pub struct ScalarEdit {
123    span: SourceSpan,
124    replacement: ReplacementScalar,
125}
126
127impl ScalarEdit {
128    /// Creates an edit for one exact source span.
129    #[must_use]
130    pub const fn new(span: SourceSpan, replacement: ReplacementScalar) -> Self {
131        Self { span, replacement }
132    }
133
134    /// Returns the exact scalar span to replace.
135    #[must_use]
136    pub const fn span(&self) -> SourceSpan {
137        self.span
138    }
139
140    /// Returns the typed replacement value.
141    #[must_use]
142    pub const fn replacement(&self) -> &ReplacementScalar {
143        &self.replacement
144    }
145}
146
147impl fmt::Debug for ScalarEdit {
148    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
149        formatter
150            .debug_struct("ScalarEdit")
151            .field("span", &self.span)
152            .field("replacement", &self.replacement)
153            .finish()
154    }
155}
156
157/// The result of applying an atomic batch of preservation-oriented scalar edits.
158///
159/// Any error rejects the complete batch and returns the original source bytes. Successful output
160/// can contain sensitive replacement values and is therefore exposed through explicit accessors.
161#[derive(Clone, PartialEq, Eq)]
162pub struct PreservationEditResult {
163    output: String,
164    diagnostics: Vec<Diagnostic>,
165    sensitive: bool,
166}
167
168impl PreservationEditResult {
169    /// Returns the edited text, or the unchanged original text when the batch was rejected.
170    #[must_use]
171    pub fn output(&self) -> &str {
172        &self.output
173    }
174
175    /// Consumes the result and returns the edited or unchanged original text.
176    #[must_use]
177    pub fn into_output(self) -> String {
178        self.output
179    }
180
181    /// Returns edit diagnostics in deterministic validation order.
182    #[must_use]
183    pub fn diagnostics(&self) -> &[Diagnostic] {
184        &self.diagnostics
185    }
186
187    /// Reports whether the complete edit batch was applied.
188    #[must_use]
189    pub fn is_valid(&self) -> bool {
190        self.diagnostics
191            .iter()
192            .all(|diagnostic| diagnostic.severity() != Severity::Error)
193    }
194
195    /// Reports whether successfully edited output contains a sensitive replacement.
196    #[must_use]
197    pub const fn is_sensitive(&self) -> bool {
198        self.sensitive
199    }
200}
201
202impl fmt::Debug for PreservationEditResult {
203    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
204        formatter
205            .debug_struct("PreservationEditResult")
206            .field(
207                "output",
208                &if self.sensitive {
209                    "<redacted>"
210                } else {
211                    self.output.as_str()
212                },
213            )
214            .field("diagnostics", &self.diagnostics)
215            .field("sensitive", &self.sensitive)
216            .finish()
217    }
218}
219
220/// Applies exact value-scalar replacements while retaining every unrelated source byte.
221///
222/// The operation is pure, performs no I/O or implicit processing, and is atomic: any invalid
223/// target, replacement, or overlap rejects the complete batch. Mapping keys, collections, aliases,
224/// empty values, and block or multiline scalars are not scalar-edit targets in this initial API.
225#[must_use]
226pub fn apply_preservation_edits(document: &SyntaxDocument, edits: &[ScalarEdit]) -> PreservationEditResult {
227    let editable = document.editable_value_scalars();
228    let mut diagnostics = Vec::new();
229    let mut planned = Vec::new();
230
231    for edit in edits {
232        if edit.span.source_id() != document.source_id() {
233            diagnostics.push(error(
234                EDIT_SOURCE_MISMATCH,
235                edit.span,
236                "edit target belongs to another source document",
237                "source does not match the edited document",
238            ));
239            continue;
240        }
241        let Some(target) = editable.iter().find(|target| target.span == edit.span) else {
242            diagnostics.push(error(
243                EDIT_TARGET_NOT_SCALAR,
244                edit.span,
245                "edit target is not exactly one YAML value scalar",
246                "expected an exact value-scalar span",
247            ));
248            continue;
249        };
250        if unsupported_style(&target.raw) {
251            diagnostics.push(error(
252                EDIT_UNSUPPORTED_SCALAR_STYLE,
253                edit.span,
254                "authored scalar style is not supported for preservation editing",
255                "block and multiline scalars require a dedicated edit operation",
256            ));
257            continue;
258        }
259        let Some(replacement) = render_replacement(&edit.replacement, &target.raw) else {
260            diagnostics.push(error(
261                EDIT_INVALID_NUMBER,
262                edit.span,
263                "numeric replacement is not one complete YAML number scalar",
264                "invalid numeric replacement",
265            ));
266            continue;
267        };
268        planned.push(PlannedEdit {
269            span: edit.span,
270            replacement,
271            sensitive: edit.replacement.sensitive,
272        });
273    }
274
275    planned.sort_by_key(|edit| (edit.span.start(), edit.span.end()));
276    for pair in planned.windows(2) {
277        if pair[1].span.start() < pair[0].span.end() || pair[1].span == pair[0].span {
278            diagnostics.push(
279                Diagnostic::new(
280                    EDIT_OVERLAP,
281                    Severity::Error,
282                    "preservation edits must target disjoint source ranges",
283                )
284                .with_label(DiagnosticLabel::primary(pair[1].span, "overlapping edit"))
285                .with_label(DiagnosticLabel::secondary(pair[0].span, "earlier edit")),
286            );
287        }
288    }
289
290    if diagnostics
291        .iter()
292        .any(|diagnostic| diagnostic.severity() == Severity::Error)
293    {
294        return PreservationEditResult {
295            output: document.source_text().to_owned(),
296            diagnostics,
297            sensitive: false,
298        };
299    }
300
301    let sensitive = planned.iter().any(|edit| edit.sensitive);
302    let mut output = document.source_text().to_owned();
303    for edit in planned.iter().rev() {
304        output.replace_range(edit.span.range(), &edit.replacement);
305    }
306    PreservationEditResult {
307        output,
308        diagnostics,
309        sensitive,
310    }
311}
312
313#[derive(Debug)]
314struct PlannedEdit {
315    span: SourceSpan,
316    replacement: String,
317    sensitive: bool,
318}
319
320fn error(code: DiagnosticCode, span: SourceSpan, message: &'static str, label: &'static str) -> Diagnostic {
321    Diagnostic::new(code, Severity::Error, message).with_label(DiagnosticLabel::primary(span, label))
322}
323
324fn unsupported_style(raw: &str) -> bool {
325    raw.starts_with('|') || raw.starts_with('>') || raw.contains(['\n', '\r'])
326}
327
328fn render_replacement(replacement: &ReplacementScalar, authored: &str) -> Option<String> {
329    match &replacement.kind {
330        ReplacementKind::String(value) => Some(render_string(value, authored)),
331        ReplacementKind::Boolean(value) => Some(value.to_string()),
332        ReplacementKind::Number(value) if scalar_has_type(value, &[ScalarType::Integer, ScalarType::Float]) => {
333            Some(value.clone())
334        }
335        ReplacementKind::Number(_) => None,
336        ReplacementKind::Null => Some("null".to_owned()),
337    }
338}
339
340fn render_string(value: &str, authored: &str) -> String {
341    if authored.starts_with('"') {
342        return double_quoted(value);
343    }
344    if authored.starts_with('\'') && single_quoted_safe(value) {
345        return format!("'{}'", value.replace('\'', "''"));
346    }
347    if plain_string_safe(value) {
348        return value.to_owned();
349    }
350    double_quoted(value)
351}
352
353fn single_quoted_safe(value: &str) -> bool {
354    !value
355        .chars()
356        .any(|character| character.is_control() || matches!(character, '\u{85}' | '\u{2028}' | '\u{2029}' | '\u{feff}'))
357}
358
359fn plain_string_safe(value: &str) -> bool {
360    !value.is_empty() && !value.contains(['\n', '\r']) && scalar_has_type(value, &[ScalarType::String])
361}
362
363fn scalar_has_type(value: &str, expected: &[ScalarType]) -> bool {
364    let parse = YamlFile::parse(value);
365    if !parse.ok() {
366        return false;
367    }
368    let file = parse.tree();
369    let Some(document) = file.document() else {
370        return false;
371    };
372    let Some(scalar) = document.as_scalar() else {
373        return false;
374    };
375    let position = scalar.byte_range();
376    position.start == 0
377        && position.end as usize == value.len()
378        && expected.contains(&ScalarValue::from_scalar(&scalar).scalar_type())
379}
380
381fn double_quoted(value: &str) -> String {
382    let mut output = String::new();
383    write_quoted(&mut output, value);
384    output
385}