noyalib/cst/emit.rs
1// SPDX-License-Identifier: MIT OR Apache-2.0
2// Copyright (c) 2026 Noyalib. All rights reserved.
3
4//! Auto-formatting for values spliced by the CST insertion mutators.
5//!
6//! The fragment-taking mutators — [`Document::set`],
7//! [`Document::insert_entry`], [`Document::push_back`],
8//! [`Document::insert_after`] — splice their `&str` argument
9//! **verbatim**. They synthesise indentation, but not quoting: a
10//! fragment holding `a: b`, a leading `-`, a `#`, or a line break is
11//! pasted as YAML *syntax*, not as data. The re-parse guard behind
12//! those mutators rejects a splice that produces **invalid** YAML; it
13//! cannot reject one that produces **valid but different** YAML, so
14//! `push_back("items", "- x")` silently grows a nested sequence and
15//! `insert_entry(m, "k", "a: b")` silently grows a nested mapping.
16//!
17//! This module closes that hole. [`Emit`] turns a typed value into the
18//! YAML spelling that re-parses to exactly that value at a given site,
19//! and pairs it with [`Emit::expected_value`] — the typed value the
20//! spliced fragment must load back as. The insertion mutators that
21//! take an `impl Emit` use the second as an oracle for the first: after
22//! the splice the document must re-parse *and* its typed view must
23//! equal the pre-edit value with exactly that one insertion applied,
24//! or the edit is rolled back.
25//!
26//! # Style matching
27//!
28//! Unlike [`Document::set_value`], which matches the scalar style of
29//! the leaf it replaces, an *insertion* has no existing leaf to match.
30//! [`EmitCtx`] therefore carries the document's own conventions —
31//! detected indent unit, dominant quote style, dominant collection
32//! style — so a new entry looks like the file it lands in. Quoting is
33//! forced regardless of those conventions whenever the plain spelling
34//! would re-parse to something other than the intended value.
35//!
36//! # Examples
37//!
38//! ```
39//! use noyalib::cst::parse_document;
40//!
41//! let mut doc = parse_document("items:\n - one\n").unwrap();
42//! // The verbatim path would splice a nested sequence here.
43//! doc.push_back_value("items", "- two").unwrap();
44//! assert_eq!(doc.to_string(), "items:\n - one\n - \"- two\"\n");
45//! ```
46//!
47//! [`Document::set`]: crate::cst::Document::set
48//! [`Document::set_value`]: crate::cst::Document::set_value
49//! [`Document::insert_entry`]: crate::cst::Document::insert_entry
50//! [`Document::push_back`]: crate::cst::Document::push_back
51//! [`Document::insert_after`]: crate::cst::Document::insert_after
52
53use crate::cst::document::{
54 can_use_block_literal, format_block_literal, format_double_quoted, format_number,
55 format_single_quoted, is_plain_safe,
56};
57use crate::error::{Error, Result};
58use crate::prelude::*;
59use crate::value::Value;
60use crate::{FlowStyle, ScalarStyle};
61
62/// The site an emitted fragment will be spliced into.
63///
64/// Built by the insertion mutators from the document's own detected
65/// conventions ([`Document::indent_unit`],
66/// [`Document::dominant_quote_style`],
67/// [`Document::dominant_flow_style`]) plus the column the new entry
68/// starts at, so an [`Emit`] implementation can match the file it is
69/// landing in rather than a serializer default.
70///
71/// [`Document::indent_unit`]: crate::cst::Document::indent_unit
72/// [`Document::dominant_quote_style`]: crate::cst::Document::dominant_quote_style
73/// [`Document::dominant_flow_style`]: crate::cst::Document::dominant_flow_style
74///
75/// # Examples
76///
77/// ```
78/// use noyalib::cst::{Emit, EmitCtx};
79/// use noyalib::{FlowStyle, ScalarStyle};
80///
81/// let ctx = EmitCtx::new(ScalarStyle::Plain, FlowStyle::Block, 2, 0);
82/// // Plain is safe here, and the site prefers plain.
83/// assert_eq!("noyalib".emit(&ctx).unwrap(), "noyalib");
84/// // Plain would re-parse as a boolean — quoting is forced.
85/// assert_eq!("true".emit(&ctx).unwrap(), "\"true\"");
86/// ```
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct EmitCtx {
89 quote: ScalarStyle,
90 flow: FlowStyle,
91 indent_unit: usize,
92 column: usize,
93}
94
95impl EmitCtx {
96 /// Build a context from a site's conventions.
97 ///
98 /// `column` is the zero-based column the emitted entry starts at —
99 /// the mapping key's column, or the `-` indicator's column for a
100 /// sequence item.
101 ///
102 /// It is **advisory**: an emitted fragment indents its own
103 /// continuation lines *relative to its first line*, and the mutator
104 /// shifts the whole fragment to the site's content column
105 /// afterwards. `column` is there for implementations that need to
106 /// know where they land anyway — line-width budgets, for
107 /// instance — not as the indent to emit at.
108 #[must_use]
109 pub fn new(quote: ScalarStyle, flow: FlowStyle, indent_unit: usize, column: usize) -> Self {
110 Self {
111 quote,
112 flow,
113 indent_unit,
114 column,
115 }
116 }
117
118 /// The dominant scalar quote style at this site.
119 #[must_use]
120 pub fn quote_style(&self) -> ScalarStyle {
121 self.quote
122 }
123
124 /// The dominant collection style at this site.
125 #[must_use]
126 pub fn flow_style(&self) -> FlowStyle {
127 self.flow
128 }
129
130 /// The document's detected indent step, in spaces.
131 #[must_use]
132 pub fn indent_unit(&self) -> usize {
133 self.indent_unit
134 }
135
136 /// The zero-based column the emitted entry starts at.
137 #[must_use]
138 pub fn column(&self) -> usize {
139 self.column
140 }
141}
142
143/// A value that can be spliced into a document by the auto-formatting
144/// mutators.
145///
146/// Implementors provide two halves of one contract:
147///
148/// - [`emit`](Self::emit) — the YAML spelling to splice, quoted and
149/// escaped so that it re-parses to this value at `ctx`'s site.
150/// - [`expected_value`](Self::expected_value) — the typed value that
151/// spelling must load back as.
152///
153/// The mutators check the second against the document after splicing
154/// the first, so an implementation whose two halves disagree causes a
155/// **refusal with the document left unchanged**, never a silent
156/// corruption.
157///
158/// Implementations ship for `str`, `String`, `bool`, the integer and
159/// float primitives, [`Value`], and any reference to one of those. A
160/// `Serialize` type is emitted by converting it first:
161/// `doc.push_back_value(path, &noyalib::to_value(&my_struct)?)`.
162///
163/// # Examples
164///
165/// ```
166/// use noyalib::cst::{Emit, EmitCtx};
167/// use noyalib::{FlowStyle, ScalarStyle, Value};
168///
169/// let ctx = EmitCtx::new(ScalarStyle::Plain, FlowStyle::Block, 2, 0);
170/// assert_eq!(42_i64.emit(&ctx).unwrap(), "42");
171/// assert_eq!(42_i64.expected_value().unwrap(), Value::from(42_i64));
172/// ```
173pub trait Emit {
174 /// The YAML spelling of this value at `ctx`'s site.
175 ///
176 /// # Errors
177 ///
178 /// Returns `Error::Parse` when the value has no representation the
179 /// emitter can produce for that site (for example a tagged scalar,
180 /// whose tag the scalar emitter would drop), and propagates
181 /// serializer errors for collections.
182 fn emit(&self, ctx: &EmitCtx) -> Result<String>;
183
184 /// The typed value [`emit`](Self::emit)'s output must re-parse to.
185 ///
186 /// # Errors
187 ///
188 /// Returns `Error::Parse` for the same cases as
189 /// [`emit`](Self::emit) — a value that cannot be emitted has no
190 /// meaningful oracle.
191 fn expected_value(&self) -> Result<Value>;
192}
193
194impl Emit for str {
195 fn emit(&self, ctx: &EmitCtx) -> Result<String> {
196 Ok(emit_string(self, ctx))
197 }
198
199 fn expected_value(&self) -> Result<Value> {
200 Ok(Value::String(self.to_owned()))
201 }
202}
203
204impl Emit for String {
205 fn emit(&self, ctx: &EmitCtx) -> Result<String> {
206 Ok(emit_string(self, ctx))
207 }
208
209 fn expected_value(&self) -> Result<Value> {
210 Ok(Value::String(self.clone()))
211 }
212}
213
214impl Emit for bool {
215 fn emit(&self, _ctx: &EmitCtx) -> Result<String> {
216 Ok(if *self {
217 "true".to_owned()
218 } else {
219 "false".to_owned()
220 })
221 }
222
223 fn expected_value(&self) -> Result<Value> {
224 Ok(Value::Bool(*self))
225 }
226}
227
228/// Emit the integer / float primitives through `Value`'s own
229/// conversions, so the spelling and the oracle come from one place.
230macro_rules! impl_emit_via_value {
231 ($($t:ty),* $(,)?) => {
232 $(
233 impl Emit for $t {
234 fn emit(&self, ctx: &EmitCtx) -> Result<String> {
235 Value::from(*self).emit(ctx)
236 }
237
238 fn expected_value(&self) -> Result<Value> {
239 Ok(Value::from(*self))
240 }
241 }
242 )*
243 };
244}
245
246impl_emit_via_value!(i8, i16, i32, i64, isize, u8, u16, u32, u64, usize, f32, f64);
247
248impl Emit for Value {
249 fn emit(&self, ctx: &EmitCtx) -> Result<String> {
250 match self {
251 Self::Null => Ok("null".to_owned()),
252 Self::Bool(b) => b.emit(ctx),
253 Self::Number(n) => Ok(emit_number(n)),
254 Self::String(s) => Ok(emit_string(s, ctx)),
255 Self::Sequence(_) | Self::Mapping(_) => emit_collection(self, ctx),
256 Self::Tagged(_) => Err(Error::Parse(
257 "emit: tagged values are not auto-formatted yet — the scalar emitter would \
258 drop the tag; splice the `!tag value` spelling with `set` / `insert_entry` \
259 instead"
260 .into(),
261 )),
262 }
263 }
264
265 fn expected_value(&self) -> Result<Value> {
266 if matches!(self, Self::Tagged(_)) {
267 return Err(Error::Parse(
268 "emit: tagged values are not auto-formatted yet — the scalar emitter would \
269 drop the tag; splice the `!tag value` spelling with `set` / `insert_entry` \
270 instead"
271 .into(),
272 ));
273 }
274 Ok(self.clone())
275 }
276}
277
278impl<T: Emit + ?Sized> Emit for &T {
279 fn emit(&self, ctx: &EmitCtx) -> Result<String> {
280 (**self).emit(ctx)
281 }
282
283 fn expected_value(&self) -> Result<Value> {
284 (**self).expected_value()
285 }
286}
287
288/// YAML spelling for a numeric scalar.
289///
290/// Floats must not use `Display` (what [`format_number`] reaches for):
291/// it renders `1.0_f64` as `"1"`, which re-parses as an **integer** and
292/// fails the insertion oracle, and the special floats need `.inf` /
293/// `-.inf` / `.nan`. Routing the number through the serializer reuses
294/// its tested, canonical formatting (including the `fast-float` path),
295/// so `1.0`, `-0.0`, `±inf`, and `NaN` each re-parse back to the same
296/// `Number::Float`. Integers are unaffected. The fallback to
297/// [`format_number`] is unreachable for a bare numeric scalar (the
298/// serializer cannot error on one) but keeps the function total.
299fn emit_number(n: &crate::value::Number) -> String {
300 // A plain `match` (not `.unwrap_or_else`) so the defensive fallback
301 // is an inline branch, not a nested closure the coverage tools would
302 // count as its own perpetually-uncovered function. The serializer
303 // cannot fail on a bare numeric scalar, so `Err` is never taken.
304 match crate::to_string_value_with_config(&Value::Number(*n), &crate::SerializerConfig::new()) {
305 Ok(s) => s.trim_end_matches('\n').to_owned(),
306 Err(_) => format_number(n),
307 }
308}
309
310/// YAML spelling for a **string** at an insertion site.
311///
312/// Multi-line strings prefer a literal block scalar, matching
313/// `set_value`'s behaviour at block sites. Otherwise the site's
314/// dominant quote style is honoured — except that a string whose plain
315/// spelling would re-parse to something else (`true`, `8080`, `- x`,
316/// `a: b`, a `#` comment lead-in) is always quoted, whatever the file's
317/// convention.
318fn emit_string(s: &str, ctx: &EmitCtx) -> String {
319 if s.contains('\n') && can_use_block_literal(s) {
320 // Relative indentation: the body sits two columns in from the
321 // fragment's own first line, and the mutator shifts the whole
322 // block to the site's content column (see [`EmitCtx::column`]).
323 return format_block_literal(s, 0);
324 }
325 quote_for_site(s, ctx.quote)
326}
327
328/// Pick a one-line YAML spelling for `s` under a site's dominant
329/// scalar style.
330///
331/// The dominant style is a *preference*, not a licence: it decides
332/// between the spellings that faithfully represent `s`, and the plain
333/// spelling is only among them when [`is_plain_safe`] says so. Single
334/// quotes have no escapes, so they cannot carry control characters,
335/// and a line break inside them folds — a string holding either is
336/// double-quoted whatever the file prefers.
337fn quote_for_site(s: &str, style: ScalarStyle) -> String {
338 let single_representable = !s.bytes().any(|b| b < 0x20 || b == 0x7F);
339 match style {
340 ScalarStyle::SingleQuoted if single_representable => format_single_quoted(s),
341 ScalarStyle::DoubleQuoted | ScalarStyle::SingleQuoted => format_double_quoted(s),
342 _ if is_plain_safe(s) => s.to_owned(),
343 _ => format_double_quoted(s),
344 }
345}
346
347/// YAML spelling for a sequence / mapping at an insertion site.
348///
349/// Delegates to the serializer with the file's detected indent unit and
350/// collection style, then strips the one line break that terminates the
351/// emission's last line — the splice templates supply their own. Only
352/// that one: a keep-chomped block scalar (`|+`) ends in the empty lines
353/// that *are* its value, and stripping every trailing break lost them,
354/// so the spliced entry failed the integrity check (#386). The result
355/// may be multi-line; callers re-indent continuation lines to the
356/// site's column before splicing.
357///
358/// Nested scalars take the serializer's conservative `Auto` quoting,
359/// which always round-trips (the oracle would reject anything that did
360/// not) — so a plain-styled file may receive `cpu: "100m"` where it
361/// writes `cpu: 100m` elsewhere. Matching the file's dominant scalar
362/// style for *nested* scalars would need `SerializerConfig::scalar_style`
363/// to be honoured by the serializer, which it is not yet.
364fn emit_collection(value: &Value, ctx: &EmitCtx) -> Result<String> {
365 let cfg = crate::SerializerConfig::new()
366 .indent(ctx.indent_unit)
367 .flow_style(ctx.flow);
368 let emitted = crate::to_string_value_with_config(value, &cfg)?;
369 Ok(emitted.strip_suffix('\n').unwrap_or(&emitted).to_owned())
370}
371
372/// YAML spelling for a **mapping key** being inserted.
373///
374/// Keys are quoted only when they must be. The site's dominant style
375/// is a statement about *values* — a file that single-quotes its
376/// strings is not asking for `'name':` on every key — so it chooses
377/// only which quote to reach for once `key` has forced the issue by
378/// not being plain-safe. Unlike a value, a key has no block-scalar
379/// fallback; a key that cannot be spelled on one line is refused by
380/// the caller.
381pub(super) fn emit_key(key: &str, ctx: &EmitCtx) -> String {
382 if is_plain_safe(key) {
383 return key.to_owned();
384 }
385 let single_representable = !key.bytes().any(|b| b < 0x20 || b == 0x7F);
386 if ctx.quote == ScalarStyle::SingleQuoted && single_representable {
387 format_single_quoted(key)
388 } else {
389 format_double_quoted(key)
390 }
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 fn plain_ctx() -> EmitCtx {
398 EmitCtx::new(ScalarStyle::Plain, FlowStyle::Block, 2, 0)
399 }
400
401 #[test]
402 fn plain_safe_string_stays_plain() {
403 assert_eq!(emit_string("noyalib", &plain_ctx()), "noyalib");
404 }
405
406 #[test]
407 fn type_changing_spellings_are_quoted() {
408 let ctx = plain_ctx();
409 for s in ["true", "null", "8080", "- x", "a: b", "#lead", "~"] {
410 let out = emit_string(s, &ctx);
411 assert!(
412 out.starts_with('"'),
413 "{s:?} must be quoted, emitted {out:?}"
414 );
415 }
416 }
417
418 #[test]
419 fn dominant_single_quote_is_honoured() {
420 let ctx = EmitCtx::new(ScalarStyle::SingleQuoted, FlowStyle::Block, 2, 0);
421 assert_eq!(emit_string("plain", &ctx), "'plain'");
422 }
423
424 #[test]
425 fn control_characters_defeat_single_quoting() {
426 let ctx = EmitCtx::new(ScalarStyle::SingleQuoted, FlowStyle::Block, 2, 0);
427 assert_eq!(emit_string("a\tb", &ctx), "\"a\\tb\"");
428 }
429
430 #[test]
431 fn multiline_string_becomes_a_block_literal() {
432 let out = emit_string("one\ntwo\n", &plain_ctx());
433 assert!(
434 out.starts_with("|\n"),
435 "expected a block literal, got {out:?}"
436 );
437 }
438
439 #[test]
440 fn key_spelling_matches_value_rules() {
441 let ctx = plain_ctx();
442 assert_eq!(emit_key("name", &ctx), "name");
443 assert_eq!(emit_key("a: b", &ctx), "\"a: b\"");
444 assert_eq!(emit_key("true", &ctx), "\"true\"");
445 }
446
447 #[test]
448 fn emit_and_oracle_agree_for_primitives() {
449 let ctx = plain_ctx();
450 assert_eq!(true.emit(&ctx).unwrap(), "true");
451 assert_eq!(true.expected_value().unwrap(), Value::Bool(true));
452 assert_eq!(7_i64.emit(&ctx).unwrap(), "7");
453 assert_eq!(7_i64.expected_value().unwrap(), Value::from(7_i64));
454 }
455
456 #[test]
457 fn tagged_values_are_refused_on_both_halves() {
458 let ctx = plain_ctx();
459 let tagged = crate::from_str::<Value>("!custom 1").unwrap();
460 assert!(tagged.emit(&ctx).is_err());
461 assert!(tagged.expected_value().is_err());
462 }
463}