Skip to main content

fig/
lib.rs

1// Lets the `derive`-generated code refer to this crate as `fig::…` even from
2// within the crate's own tests and examples.
3extern crate self as fig;
4
5mod diagnostics;
6mod editor;
7mod embed;
8mod error;
9mod value;
10
11// The raw FFI layer moved to the `fig-sys` crate. Alias it as `ffi` so every
12// `ffi::…` / `crate::ffi::…` reference in this crate keeps resolving unchanged,
13// and so `fig-sys`'s build script (via its `links = "fig"`) links `libfig.a`.
14pub(crate) use fig_sys as ffi;
15
16#[cfg(feature = "derive")]
17mod convert;
18#[cfg(feature = "serde")]
19mod de;
20#[cfg(feature = "serde")]
21mod ser;
22
23use std::os::raw::c_int;
24use std::ptr::NonNull;
25
26pub use diagnostics::{Warning, WarningCause, WarningCode};
27pub use editor::{Editor, Segment};
28pub use embed::{Embed, EmbedType, Extracted, Region, Span, detect, split};
29pub use error::{Error, ParseError};
30pub use value::{ExtKind, Value};
31
32#[cfg(feature = "derive")]
33pub use convert::{FromValue, ToValue};
34// Shared helpers the derive macros call instead of inlining a lookup, convert,
35// and error per field.
36#[cfg(feature = "derive")]
37#[doc(hidden)]
38pub use convert::{field, field_or_default, map_get};
39// The derive macros share the trait names (trait vs. macro namespace), mirroring
40// `serde::Serialize`. Glob users get both with one import.
41#[cfg(feature = "derive")]
42pub use fig_macros::{FromValue, ToValue};
43
44#[cfg(feature = "serde")]
45pub use de::{from_slice, from_str};
46#[cfg(feature = "serde")]
47pub use ser::{to_string, to_value};
48
49use ffi::{FIG_NODE_NONE, FigNodeId, FigNodeKind};
50
51/// A config format. Every variant parses, edits, and serializes.
52///
53/// Every variant is always present in the enum, but each format is gated by a
54/// crate feature of the same name (`json`, `yaml`, `toml`, `zon`, `fig`).
55/// `json`, `yaml`, `toml`, and `fig` are on by default; `zon` is opt-in.
56/// Disabling a feature compiles that format out of the bundled native library,
57/// so selecting it then fails with [`Error::UnsupportedFormat`] at runtime.
58/// `Json`/`Jsonc`/`Json5` share one core behind the `json` feature. (The core
59/// also has a reader-only XML format, but it has no writable `Format` variant
60/// and so is not exposed here.)
61///
62/// `#[non_exhaustive]`: the core gains formats over time, so a `match` needs a
63/// `_` arm. Constructing a variant is unaffected.
64#[derive(Clone, Copy, Debug, Eq, PartialEq)]
65#[non_exhaustive]
66pub enum Format {
67    Json,
68    Jsonc,
69    Json5,
70    Yaml,
71    Toml,
72    Zon,
73    /// The native `fig` authoring dialect (see `src/languages/fig/DESIGN.md`
74    /// in the core repo) — a memorable, typeable surface over the same AST.
75    Fig,
76}
77
78impl From<Format> for ffi::FigFormat {
79    fn from(format: Format) -> Self {
80        match format {
81            Format::Json => ffi::FigFormat::Json,
82            Format::Jsonc => ffi::FigFormat::Jsonc,
83            Format::Json5 => ffi::FigFormat::Json5,
84            Format::Yaml => ffi::FigFormat::Yaml,
85            Format::Toml => ffi::FigFormat::Toml,
86            Format::Zon => ffi::FigFormat::Zon,
87            Format::Fig => ffi::FigFormat::Fig,
88        }
89    }
90}
91
92/// Controls how [`Value::serialize_with`] renders output. The [`Default`] is
93/// fig's historical style (pretty-printed, two-space indent), so
94/// [`Value::serialize`] is exactly `serialize_with(format, SerializeOptions::default())`.
95///
96/// `pretty` is honored by [`Format::Json`] (multi-line vs. minified),
97/// [`Format::Zon`] (`zig fmt` multi-line vs. inline `.{ a, b }`), and
98/// [`Format::Toml`] (gates array wrapping); `indent` by [`Format::Json`] and
99/// [`Format::Toml`]'s wrapped arrays; `width` by the inline-vs-expanded (flow vs.
100/// block) layout of [`Format::Toml`], [`Format::Yaml`], and [`Format::Fig`] — with
101/// the caveats on [`width`](SerializeOptions::width) itself.
102#[derive(Clone, Copy, Debug, Eq, PartialEq)]
103#[non_exhaustive]
104pub struct SerializeOptions {
105    /// `true`: multi-line, indented output. `false`: compact single-line output
106    /// with no insignificant whitespace. For TOML, `false` keeps every array on
107    /// one line; `true` lets a wide array wrap (see `width`).
108    pub pretty: bool,
109    /// Spaces per indentation level when `pretty` is set (JSON, and TOML's wrapped
110    /// arrays).
111    pub indent: u8,
112    /// Drop comments carried on the value instead of emitting them. Default
113    /// `false` (preserve them where the target format allows).
114    pub strip_comments: bool,
115    /// [`Document::serialize`] only: preserve values the target format cannot
116    /// represent natively (a null in TOML, a TOML datetime in JSON, …) through a
117    /// `$fig` envelope, and decode any such envelope found in the source. Default
118    /// `false` (lossy — an unrepresentable value is an [`Error::UnsupportedFormat`]).
119    /// Ignored by [`Value::serialize_with`] (a built value has no source envelopes).
120    pub lossless: bool,
121    /// The column budget for the inline-vs-expanded layout of [`Format::Toml`],
122    /// [`Format::Yaml`], and [`Format::Fig`]. A mapping/array that renders within
123    /// `width` columns stays inline (flow: `k = { … }` / `[a, b]`); a wider one
124    /// expands to a `[section]`, a wrapped array, or block form. Default `80`.
125    /// Ignored by the other formats.
126    ///
127    /// Two limits to know before reaching for this as a "render block" lever:
128    ///
129    /// * **`0` means "unset", not "never inline".** It resolves to the default
130    ///   `80` at the C ABI, where zero-initialized option structs are ordinary.
131    ///   Pass `1` to force block layout.
132    /// * **For YAML the budget applies to nested containers only.** A *root*
133    ///   map or sequence always renders block, whatever the width — so
134    ///   `Value::Map([("k", "v")]).serialize_with(Yaml, …)` is `"k: v\n"` at
135    ///   every setting, and a width change first becomes visible one level in
136    ///   (`a: { k: v }` vs `a:\n  k: v`).
137    pub width: u16,
138}
139
140impl Default for SerializeOptions {
141    fn default() -> Self {
142        Self { pretty: true, indent: 2, strip_comments: false, lossless: false, width: 80 }
143    }
144}
145
146/// `SerializeOptions` is `#[non_exhaustive]`, so it is built from one of the
147/// three constructors below and then narrowed with the chainable setters —
148/// `SerializeOptions::default().width(1).strip_comments()` — rather than by
149/// struct literal. Every field has a setter here; a future field can be added
150/// without a breaking release.
151impl SerializeOptions {
152    /// Compact single-line output with no insignificant whitespace.
153    pub fn compact() -> Self {
154        Self { pretty: false, ..Self::default() }
155    }
156
157    /// Pretty-printed output with the given number of spaces per indent level.
158    pub fn pretty(indent: u8) -> Self {
159        Self { pretty: true, indent, ..Self::default() }
160    }
161
162    /// This style with the given spaces-per-indent-level (see `indent`). The
163    /// chainable twin of the [`pretty`](Self::pretty) constructor, for setting
164    /// the indent on options you already have.
165    pub fn indent(self, indent: u8) -> Self {
166        Self { indent, ..self }
167    }
168
169    /// This style with `lossless` enabled (see the field). Builder-style so
170    /// `SerializeOptions::default().lossless()` reads naturally.
171    pub fn lossless(self) -> Self {
172        Self { lossless: true, ..self }
173    }
174
175    /// This style with comments stripped (see `strip_comments`).
176    pub fn strip_comments(self) -> Self {
177        Self { strip_comments: true, ..self }
178    }
179
180    /// This style with the given inline-vs-expanded column budget for
181    /// TOML/YAML/fig (see [`width`](Self::width) for its two limits — `0` is
182    /// "unset", and YAML's root is always block). Builder-style, e.g.
183    /// `SerializeOptions::default().width(120)`, or `.width(1)` to force block.
184    pub fn width(self, width: u16) -> Self {
185        Self { width, ..self }
186    }
187}
188
189impl From<SerializeOptions> for ffi::FigSerializeOptions {
190    fn from(o: SerializeOptions) -> Self {
191        ffi::FigSerializeOptions {
192            size: std::mem::size_of::<ffi::FigSerializeOptions>() as u32,
193            pretty: u8::from(o.pretty),
194            indent: o.indent,
195            strip_comments: u8::from(o.strip_comments),
196            lossless: u8::from(o.lossless),
197            width: o.width,
198            // Not a public style option — the editors' splice path sets this
199            // directly (see `value_text`).
200            flow: 0,
201        }
202    }
203}
204
205/// The linked fig library's version, from [`version`].
206#[derive(Clone, Copy, Debug, Eq, PartialEq)]
207#[non_exhaustive]
208pub struct Version {
209    pub major: u8,
210    pub minor: u8,
211    pub patch: u8,
212}
213
214/// The version of the linked fig core, decoded from the packed
215/// `(major << 16) | (minor << 8) | patch` that `fig_version` returns.
216pub fn version() -> Version {
217    let packed = unsafe { ffi::fig_version() };
218    Version {
219        major: (packed >> 16) as u8,
220        minor: (packed >> 8) as u8,
221        patch: packed as u8,
222    }
223}
224
225/// The linked fig core's version as a `"major.minor.patch"` string.
226pub fn version_string() -> &'static str {
227    // Safety: `fig_version_string` returns a static NUL-terminated ASCII string
228    // owned by the library, valid for the whole program.
229    let ptr = unsafe { ffi::fig_version_string() };
230    unsafe { std::ffi::CStr::from_ptr(ptr) }
231        .to_str()
232        .unwrap_or("")
233}
234
235/// What this build of the fig core can do with a format. Reflects both inherent
236/// support (every format the `Format` enum exposes parses, edits, and
237/// serializes; the core's reader-only XML is not surfaced here) and build-time
238/// gating (a format compiled out reports all-false).
239#[derive(Clone, Copy, Debug, Eq, PartialEq)]
240#[non_exhaustive]
241pub struct Capabilities {
242    /// [`Document::parse`] accepts this format.
243    pub read: bool,
244    /// The editor/embed APIs accept this format.
245    pub edit: bool,
246    /// The serializers can write this format.
247    pub serialize: bool,
248}
249
250/// Query what this build can do with `format` (read/edit/serialize). Lets a host
251/// pick a working format up front instead of probing via `UnsupportedFormat`.
252pub fn capabilities(format: Format) -> Capabilities {
253    let ffi_format: ffi::FigFormat = format.into();
254    let bits = unsafe { ffi::fig_format_capabilities(ffi_format as c_int) };
255    Capabilities {
256        read: bits & (1 << 0) != 0,
257        edit: bits & (1 << 1) != 0,
258        serialize: bits & (1 << 2) != 0,
259    }
260}
261
262#[derive(Debug)]
263pub struct Document {
264    raw: NonNull<ffi::FigDocument>,
265}
266
267impl Document {
268    pub fn parse(input: &[u8], format: Format) -> Result<Self, Error> {
269        let mut raw = std::ptr::null_mut();
270        let ffi_format: ffi::FigFormat = format.into();
271
272        // `fig_parse_ex` fills `err` on failure; on a parse error we project its
273        // message/location into `Error::Parse`. Other statuses fold through
274        // `from_status` as usual (the struct is meaningful only on failure).
275        let mut err = ffi::FigError::new();
276        let status = unsafe {
277            ffi::fig_parse_ex(input.as_ptr(), input.len(), ffi_format as i32, &mut raw, &mut err)
278        };
279        if status != ffi::FigStatus(ffi::FigStatus::OK) {
280            if status == ffi::FigStatus(ffi::FigStatus::PARSE_ERROR) {
281                return Err(Error::Parse(crate::error::ParseError::from_ffi(&err)));
282            }
283            Error::from_status(status)?;
284        }
285
286        let raw = NonNull::new(raw).ok_or(Error::Internal)?;
287        Ok(Self { raw })
288    }
289}
290
291/// Read-path traversal over the parsed node graph: the public `to_value`, plus
292/// the lower-level accessors the serde deserializer walks.
293impl Document {
294    /// Read the whole document into an owned [`Value`] tree — the non-serde
295    /// structural read, the mirror of [`Value::serialize`]. An empty document is
296    /// [`Value::Null`].
297    pub fn to_value(&self) -> Result<Value, Error> {
298        match self.root() {
299            None => Ok(Value::Null),
300            Some(id) => self.node_to_value(id),
301        }
302    }
303
304    fn node_to_value(&self, id: FigNodeId) -> Result<Value, Error> {
305        // A format-specific scalar masquerades as a string/int at the `kind`
306        // ABI; recover it faithfully here (the serde path keeps the string/int).
307        if let Some((kind, text)) = self.extended(id) {
308            return Ok(Value::Extended { kind, text });
309        }
310        let kind = self.kind(id);
311        match kind {
312            FigNodeKind::Null => Ok(Value::Null),
313            FigNodeKind::Bool => Ok(Value::Bool(self.get_bool(id).ok_or(Error::Internal)?)),
314            FigNodeKind::Int | FigNodeKind::Float => {
315                let raw = self.number_raw(id).ok_or(Error::Internal)??;
316                value::number_from_raw(raw, kind == FigNodeKind::Float)
317            }
318            FigNodeKind::String => Ok(Value::Str(
319                self.get_str(id).ok_or(Error::Internal)??.to_owned(),
320            )),
321            FigNodeKind::Sequence => {
322                let mut items = Vec::with_capacity(self.child_count(id));
323                let mut next = self.first_child(id);
324                while let Some(child) = next {
325                    items.push(self.node_to_value(child)?);
326                    next = self.next_sibling(child);
327                }
328                Ok(Value::Seq(items))
329            }
330            FigNodeKind::Mapping => {
331                let mut entries = Vec::with_capacity(self.child_count(id));
332                let mut next = self.first_child(id);
333                while let Some(kv) = next {
334                    let key = self.kv_key(kv).ok_or(Error::Internal)?;
335                    let value = match self.kv_value(kv) {
336                        Some(vid) => self.node_to_value(vid)?,
337                        None => Value::Null,
338                    };
339                    entries.push((self.node_to_value(key)?, value));
340                    next = self.next_sibling(kv);
341                }
342                Ok(Value::Map(entries))
343            }
344            // A bare keyvalue, an invalid id, or an unresolved alias can't stand
345            // alone as a value.
346            FigNodeKind::Keyvalue | FigNodeKind::Invalid | FigNodeKind::Alias => {
347                Err(Error::Internal)
348            }
349        }
350    }
351
352    fn ptr(&self) -> *const ffi::FigDocument {
353        self.raw.as_ptr()
354    }
355
356    /// The root node, or `None` for an empty document.
357    pub(crate) fn root(&self) -> Option<FigNodeId> {
358        normalize(unsafe { ffi::fig_document_root(self.ptr()) })
359    }
360
361    pub(crate) fn kind(&self, node: FigNodeId) -> FigNodeKind {
362        // `from_c` is the sole gate that turns the raw ABI int into the enum,
363        // mapping any unknown/future kind to `Invalid` instead of risking UB.
364        FigNodeKind::from_c(unsafe { ffi::fig_node_kind(self.ptr(), node) })
365    }
366
367    pub(crate) fn first_child(&self, node: FigNodeId) -> Option<FigNodeId> {
368        normalize(unsafe { ffi::fig_node_first_child(self.ptr(), node) })
369    }
370
371    pub(crate) fn next_sibling(&self, node: FigNodeId) -> Option<FigNodeId> {
372        normalize(unsafe { ffi::fig_node_next_sibling(self.ptr(), node) })
373    }
374
375    pub(crate) fn child_count(&self, node: FigNodeId) -> usize {
376        unsafe { ffi::fig_node_child_count(self.ptr(), node) }
377    }
378
379    pub(crate) fn kv_key(&self, node: FigNodeId) -> Option<FigNodeId> {
380        normalize(unsafe { ffi::fig_keyvalue_key(self.ptr(), node) })
381    }
382
383    pub(crate) fn kv_value(&self, node: FigNodeId) -> Option<FigNodeId> {
384        normalize(unsafe { ffi::fig_keyvalue_value(self.ptr(), node) })
385    }
386
387    pub(crate) fn get_bool(&self, node: FigNodeId) -> Option<bool> {
388        let mut out = false;
389        unsafe { ffi::fig_node_bool(self.ptr(), node, &mut out) }.then_some(out)
390    }
391
392    /// The raw source text of a numeric scalar. Borrows document memory.
393    pub(crate) fn number_raw(&self, node: FigNodeId) -> Option<Result<&str, Error>> {
394        self.scalar_bytes(node, ffi::fig_node_number)
395            .map(|bytes| std::str::from_utf8(bytes).map_err(|_| Error::Utf8))
396    }
397
398    /// The bytes of a string scalar, as UTF-8. Borrows document memory.
399    pub(crate) fn get_str(&self, node: FigNodeId) -> Option<Result<&str, Error>> {
400        self.scalar_bytes(node, ffi::fig_node_string)
401            .map(|bytes| std::str::from_utf8(bytes).map_err(|_| Error::Utf8))
402    }
403
404    /// If `node` is a format-specific extended scalar (TOML datetime, ZON
405    /// enum/char literal), recover its [`ExtKind`] and text; `None` otherwise.
406    /// Used only by the structural [`Document::to_value`] read — the serde path
407    /// reads these as plain strings/ints.
408    pub(crate) fn extended(&self, node: FigNodeId) -> Option<(ExtKind, String)> {
409        let mut kind: c_int = 0;
410        let mut ptr: *const u8 = std::ptr::null();
411        let mut len: usize = 0;
412        let ok = unsafe { ffi::fig_node_extended(self.ptr(), node, &mut kind, &mut ptr, &mut len) };
413        if !ok {
414            return None;
415        }
416        let ext = ExtKind::from_c(kind)?;
417        let text = if len == 0 {
418            String::new()
419        } else {
420            // Safety: on success the ABI guarantees `ptr` points to `len` bytes
421            // owned by the document, valid until our `Drop`.
422            let bytes = unsafe { std::slice::from_raw_parts(ptr, len) };
423            std::str::from_utf8(bytes).ok()?.to_owned()
424        };
425        Some((ext, text))
426    }
427
428    /// Shared helper for the byte-returning scalar accessors. The returned
429    /// slice borrows memory owned by the document (valid until drop), which we
430    /// tie to `&self`.
431    fn scalar_bytes(
432        &self,
433        node: FigNodeId,
434        accessor: unsafe extern "C" fn(
435            *const ffi::FigDocument,
436            FigNodeId,
437            *mut *const u8,
438            *mut usize,
439        ) -> bool,
440    ) -> Option<&[u8]> {
441        let mut ptr: *const u8 = std::ptr::null();
442        let mut len: usize = 0;
443        let ok = unsafe { accessor(self.ptr(), node, &mut ptr, &mut len) };
444        if !ok {
445            return None;
446        }
447        if len == 0 {
448            return Some(&[]);
449        }
450        // Safety: on success the ABI guarantees `ptr` points to `len` bytes
451        // owned by the document, valid until `fig_document_destroy` (i.e. our
452        // `Drop`). The returned borrow is bounded by `&self`.
453        Some(unsafe { std::slice::from_raw_parts(ptr, len) })
454    }
455}
456
457/// Cross-format conversion + serialization diagnostics over the parsed document.
458impl Document {
459    /// Render the whole document to `format` with the default output style — the
460    /// cross-format conversion primitive (e.g. parse YAML, emit JSON). Unlike
461    /// `to_value().serialize()`, this preserves comments carried on the source
462    /// where the target allows, and collapses YAML's reference layer when leaving
463    /// YAML. A value the target cannot represent is an [`Error::UnsupportedFormat`]
464    /// unless `lossless` is set (see [`Document::serialize_with`]).
465    pub fn serialize(&self, format: Format) -> Result<String, Error> {
466        self.serialize_with(format, SerializeOptions::default())
467    }
468
469    /// As [`Document::serialize`], with `options` controlling output style,
470    /// comment stripping, and lossless `$fig`-envelope round-tripping.
471    pub fn serialize_with(&self, format: Format, options: SerializeOptions) -> Result<String, Error> {
472        let ffi_format: ffi::FigFormat = format.into();
473        let ffi_options: ffi::FigSerializeOptions = options.into();
474        let mut ptr_out: *const u8 = std::ptr::null();
475        let mut len: usize = 0;
476        Error::from_status(unsafe {
477            ffi::fig_document_serialize(
478                self.raw.as_ptr(),
479                ffi_format as c_int,
480                &ffi_options,
481                &mut ptr_out,
482                &mut len,
483            )
484        })?;
485        // Safety: on success the ABI guarantees `len` bytes at `ptr_out`, owned by
486        // the handle and valid until the next serialize/destroy. Copy out now.
487        let bytes = if len == 0 {
488            &[][..]
489        } else {
490            unsafe { std::slice::from_raw_parts(ptr_out, len) }
491        };
492        Ok(std::str::from_utf8(bytes).map_err(|_| Error::Utf8)?.to_owned())
493    }
494
495    /// Report what serializing the whole document to `format` would silently lose
496    /// (comments dropped/degraded, values dropped/degraded), using the same
497    /// pipeline [`Document::serialize_with`] prints from. Returns one [`Warning`]
498    /// per lossy event (empty if nothing is lost).
499    pub fn diagnose(&self, format: Format, options: SerializeOptions) -> Result<Vec<Warning>, Error> {
500        let ffi_format: ffi::FigFormat = format.into();
501        let ffi_options: ffi::FigSerializeOptions = options.into();
502        let mut count: usize = 0;
503        Error::from_status(unsafe {
504            ffi::fig_document_diagnose(self.raw.as_ptr(), ffi_format as c_int, &ffi_options, &mut count)
505        })?;
506        let mut out = Vec::with_capacity(count);
507        for i in 0..count {
508            let mut w = ffi::FigWarning::new();
509            Error::from_status(unsafe { ffi::fig_document_warning(self.raw.as_ptr(), i, &mut w) })?;
510            // Safety: on `OK`, `w` is filled with a warning whose path/note point
511            // into the handle's arena, valid until the next diagnose/destroy.
512            out.push(unsafe { Warning::from_ffi(&w) });
513        }
514        Ok(out)
515    }
516}
517
518impl Drop for Document {
519    fn drop(&mut self) {
520        unsafe {
521            ffi::fig_document_destroy(self.raw.as_ptr());
522        }
523    }
524}
525
526fn normalize(id: FigNodeId) -> Option<FigNodeId> {
527    if id == FIG_NODE_NONE { None } else { Some(id) }
528}
529
530#[cfg(test)]
531mod tests {
532    use super::{Document, Embed, EmbedType, Error, Format, Segment};
533
534    #[test]
535    fn parses_json_document() {
536        let doc = Document::parse(br#"{"name":"fig","ok":true}"#, Format::Json);
537        assert!(doc.is_ok());
538    }
539
540    #[test]
541    fn parse_error_is_reported() {
542        let err = Document::parse(br#"{"name":"fig""#, Format::Json).unwrap_err();
543        let Error::Parse(detail) = &err else {
544            panic!("expected Error::Parse, got {err:?}");
545        };
546        // The core surfaces a non-empty message (its error name). Offsets are not
547        // yet plumbed, so the location fields are None for now.
548        assert!(!detail.message.is_empty());
549        assert_eq!(detail.byte_offset, None);
550    }
551
552    #[test]
553    fn version_and_capabilities() {
554        use super::{capabilities, version, version_string, Format};
555        let v = version();
556        // The packed version round-trips through the string form.
557        assert_eq!(version_string(), format!("{}.{}.{}", v.major, v.minor, v.patch));
558        // JSON is always fully supported in any build.
559        let json = capabilities(Format::Json);
560        assert!(json.read && json.edit && json.serialize);
561    }
562
563    #[test]
564    fn document_serialize_converts_cross_format() {
565        // YAML in, JSON out — the conversion primitive, not a value rebuild.
566        let doc = Document::parse(b"name: fig\nnums:\n- 1\n- 2\n", Format::Yaml).unwrap();
567        assert_eq!(
568            doc.serialize(Format::Json).unwrap(),
569            "{\n  \"name\": \"fig\",\n  \"nums\": [\n    1,\n    2\n  ]\n}\n",
570        );
571    }
572
573    #[test]
574    #[cfg(feature = "toml")]
575    fn document_diagnose_reports_dropped_null() {
576        use super::{SerializeOptions, WarningCause, WarningCode};
577        let doc = Document::parse(b"a: null\nb: 1\n", Format::Yaml).unwrap();
578        // A null can't survive in TOML → one value-dropped warning at path "a".
579        let warns = doc.diagnose(Format::Toml, SerializeOptions::default()).unwrap();
580        assert_eq!(warns.len(), 1);
581        assert_eq!(warns[0].code, WarningCode::ValueDropped);
582        assert_eq!(warns[0].cause, WarningCause::FormatLimitation);
583        assert_eq!(warns[0].path, "a");
584        // Lossless preserves the null → nothing lost.
585        let none = doc
586            .diagnose(Format::Toml, SerializeOptions::default().lossless())
587            .unwrap();
588        assert!(none.is_empty());
589    }
590
591    #[test]
592    fn parse_error_message_is_surfaced_in_display() {
593        let err = Document::parse(br#"{"name":"fig""#, Format::Json).unwrap_err();
594        // Display includes the core's message after the generic prefix.
595        assert!(err.to_string().starts_with("failed to parse input: "));
596    }
597
598    #[test]
599    fn editor_comment_ops_add_set_and_delete() {
600        use super::{Editor, Segment};
601        let mut ed = Editor::open(b"a: 1\nb: 2\n", Format::Yaml).unwrap();
602        ed.add_leading_comment(&[Segment::Key("b")], "why").unwrap();
603        ed.set_trailing_comment(&[Segment::Key("b")], "two").unwrap();
604        assert_eq!(ed.source().unwrap(), "a: 1\n# why\nb: 2 # two\n");
605        ed.delete_trailing_comment(&[Segment::Key("b")]).unwrap();
606        ed.delete_leading_comments(&[Segment::Key("b")]).unwrap();
607        assert_eq!(ed.source().unwrap(), "a: 1\nb: 2\n");
608    }
609
610    #[test]
611    fn editor_comments_unsupported_in_strict_json() {
612        use super::{Editor, Error, Segment};
613        let mut ed = Editor::open(br#"{"a":1}"#, Format::Json).unwrap();
614        assert!(matches!(
615            ed.add_leading_comment(&[Segment::Key("a")], "x"),
616            Err(Error::UnsupportedFormat)
617        ));
618    }
619
620    #[test]
621    fn frontmatter_reorder_keys_preserves_comments_and_body() {
622        let md = "---\ntitle: Hi\n# a comment\ntags:\n- x\nauthor: me\n---\n# Body\n";
623        let mut fm = Embed::open(md.as_bytes(), EmbedType::FrontmatterYaml).unwrap();
624        // String keys (the diaryx call site passes `Vec<String>`).
625        let order = vec![String::from("author"), String::from("title")];
626        fm.reorder_keys(&[], &order).unwrap();
627        assert_eq!(
628            fm.render().unwrap(),
629            "---\nauthor: me\ntitle: Hi\n# a comment\ntags:\n- x\n---\n# Body\n",
630        );
631    }
632
633    #[test]
634    fn frontmatter_move_key_preserves_comments_and_body() {
635        let md = "---\na: 1\n# note for c\nc: 3\nb: 2\n---\nbody\n";
636        let mut fm = Embed::open(md.as_bytes(), EmbedType::FrontmatterYaml).unwrap();
637        fm.move_key(&[Segment::Key("c")], &[Segment::Key("a")])
638            .unwrap();
639        assert_eq!(
640            fm.render().unwrap(),
641            "---\n# note for c\nc: 3\na: 1\nb: 2\n---\nbody\n",
642        );
643    }
644
645    #[test]
646    fn frontmatter_reorder_items_in_block_sequence() {
647        let md = "---\ntags:\n- x\n- y\n- z\n---\nbody\n";
648        let mut fm = Embed::open(md.as_bytes(), EmbedType::FrontmatterYaml).unwrap();
649        fm.reorder_items(&[Segment::Key("tags")], &[2, 0]).unwrap();
650        assert_eq!(
651            fm.render().unwrap(),
652            "---\ntags:\n- z\n- x\n- y\n---\nbody\n",
653        );
654    }
655
656    #[test]
657    fn frontmatter_move_item_in_flow_sequence_keeps_separators() {
658        let md = "---\ntags: [x, y, z]\n---\nbody\n";
659        let mut fm = Embed::open(md.as_bytes(), EmbedType::FrontmatterYaml).unwrap();
660        fm.move_item(&[Segment::Key("tags")], 2, 0).unwrap();
661        assert_eq!(fm.render().unwrap(), "---\ntags: [z, x, y]\n---\nbody\n");
662    }
663
664    #[test]
665    fn html_code_edit_is_span_aware_over_the_entity_codec() {
666        // An entity-encoded <code> block with MIXED encodings (numeric &#60; vs
667        // named &lt;). Editing `expr` must canonically re-encode only that value
668        // and leave `note`'s original &lt; byte-for-byte.
669        let html =
670            "<pre><code class=\"language-figl\">\nexpr = \"a &#60; b\"\nnote = \"x &lt; y\"\n</code></pre>\n";
671        let mut ec = Embed::open(html.as_bytes(), EmbedType::HtmlCodeFig).unwrap();
672        ec.replace_value(&[Segment::Key("expr")], "p > q").unwrap();
673        // The edited value canonically encodes `>`→`&gt;`; the untouched `note`
674        // keeps its ORIGINAL `&lt;` byte-for-byte (not normalized).
675        assert_eq!(
676            ec.render().unwrap(),
677            "<pre><code class=\"language-figl\">\nexpr = p &gt; q\nnote = \"x &lt; y\"\n</code></pre>\n",
678        );
679    }
680}