1use 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
10pub const EDIT_SOURCE_MISMATCH: DiagnosticCode = DiagnosticCode::new("compose.edit.source-mismatch");
12
13pub const EDIT_TARGET_NOT_SCALAR: DiagnosticCode = DiagnosticCode::new("compose.edit.target-not-scalar");
15
16pub const EDIT_OVERLAP: DiagnosticCode = DiagnosticCode::new("compose.edit.overlap");
18
19pub const EDIT_UNSUPPORTED_SCALAR_STYLE: DiagnosticCode = DiagnosticCode::new("compose.edit.unsupported-scalar-style");
21
22pub const EDIT_INVALID_NUMBER: DiagnosticCode = DiagnosticCode::new("compose.edit.invalid-number");
24
25#[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 #[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 #[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 #[must_use]
64 pub const fn boolean(value: bool) -> Self {
65 Self {
66 kind: ReplacementKind::Boolean(value),
67 sensitive: false,
68 }
69 }
70
71 #[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 #[must_use]
82 pub const fn null() -> Self {
83 Self {
84 kind: ReplacementKind::Null,
85 sensitive: false,
86 }
87 }
88
89 #[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#[derive(Clone, PartialEq, Eq)]
122pub struct ScalarEdit {
123 span: SourceSpan,
124 replacement: ReplacementScalar,
125}
126
127impl ScalarEdit {
128 #[must_use]
130 pub const fn new(span: SourceSpan, replacement: ReplacementScalar) -> Self {
131 Self { span, replacement }
132 }
133
134 #[must_use]
136 pub const fn span(&self) -> SourceSpan {
137 self.span
138 }
139
140 #[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#[derive(Clone, PartialEq, Eq)]
162pub struct PreservationEditResult {
163 output: String,
164 diagnostics: Vec<Diagnostic>,
165 sensitive: bool,
166}
167
168impl PreservationEditResult {
169 #[must_use]
171 pub fn output(&self) -> &str {
172 &self.output
173 }
174
175 #[must_use]
177 pub fn into_output(self) -> String {
178 self.output
179 }
180
181 #[must_use]
183 pub fn diagnostics(&self) -> &[Diagnostic] {
184 &self.diagnostics
185 }
186
187 #[must_use]
189 pub fn is_valid(&self) -> bool {
190 self.diagnostics
191 .iter()
192 .all(|diagnostic| diagnostic.severity() != Severity::Error)
193 }
194
195 #[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#[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}