Skip to main content

guppy_summaries/
toml_compat.rs

1// Copyright (c) The cargo-guppy Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4//! A serializer that reproduces the output of `toml` 0.5's `Serializer::pretty`
5//! with `pretty_array(false)`.
6//!
7//! This is an internal module that is public only because it is shared by
8//! several guppy-related projects.
9//!
10//! The TOML output is part of the stable on-disk format for guppy-related
11//! projects. If you're not bound by backwards compatibility concerns, you
12//! probably want to use `toml` 1's serializer -- its output format is better.
13
14// Specific details that are preserved here (from toml 0.5.11's src/ser.rs):
15//
16// * toml 0.5's preserve_order feature (tables are emitted in iteration order)
17// * literal single-quoted strings
18// * headers for empty tables
19// * blank line placement around `[[...]]` entries
20//
21// The structure deliberately mirrors the original state machine in toml 0.5 so
22// that the two can be compared side by side.
23
24use std::{cell::Cell, fmt::Write};
25use toml::{Table, Value, ser::Error};
26
27/// Writes `table` to `out` in the toml 0.5 pretty format.
28///
29/// Returns an error if a non-table value follows a table or array of tables
30/// within the same parent. This matches toml 0.5's `ValueAfterTable` error.
31pub fn write_table(table: &Table, out: &mut String) -> Result<(), Error> {
32    emit_table(out, table, &State::End)
33}
34
35/// Reorders values within `value` to conform to what toml 0.5 does.
36///
37/// This mirrors toml 0.5.11's `value.rs:412-439` (`impl Serialize for
38/// Value::Table`), and reorders items as:
39///
40/// * plain values
41/// * then arrays of tables
42/// * then tables, recursively
43///
44/// toml 0.5 applies this reordering to `Value` trees only, not to structs or a
45/// directly serialized `Table` field, so callers are expected to only apply it
46/// in those cases.
47pub fn reorder_value(value: &Value) -> Value {
48    match value {
49        Value::String(_)
50        | Value::Integer(_)
51        | Value::Float(_)
52        | Value::Boolean(_)
53        | Value::Datetime(_) => value.clone(),
54        Value::Array(array) => Value::Array(array.iter().map(reorder_value).collect()),
55        Value::Table(table) => {
56            let mut reordered = Table::new();
57            for (key, value) in table {
58                if !value.is_table() && !is_array_of_tables(value) {
59                    reordered.insert(key.clone(), reorder_value(value));
60                }
61            }
62            for (key, value) in table {
63                if is_array_of_tables(value) {
64                    reordered.insert(key.clone(), reorder_value(value));
65                }
66            }
67            for (key, value) in table {
68                if value.is_table() {
69                    reordered.insert(key.clone(), reorder_value(value));
70                }
71            }
72            Value::Table(reordered)
73        }
74    }
75}
76
77// toml 0.5 treats an array as an array of tables if any element is a table.
78fn is_array_of_tables(value: &Value) -> bool {
79    match value {
80        Value::Array(array) => array.iter().any(Value::is_table),
81        Value::String(_)
82        | Value::Integer(_)
83        | Value::Float(_)
84        | Value::Boolean(_)
85        | Value::Datetime(_)
86        | Value::Table(_) => false,
87    }
88}
89
90#[derive(Clone, Copy, Debug, Eq, PartialEq)]
91enum ArrayState {
92    Started,
93    StartedAsATable,
94}
95
96#[derive(Clone, Copy, Debug)]
97enum State<'a> {
98    End,
99    Table {
100        key: &'a str,
101        parent: &'a State<'a>,
102        first: &'a Cell<bool>,
103        table_emitted: &'a Cell<bool>,
104    },
105    Array {
106        parent: &'a State<'a>,
107        first: &'a Cell<bool>,
108        type_: &'a Cell<Option<ArrayState>>,
109    },
110}
111
112fn value_after_table() -> Error {
113    serde::ser::Error::custom("values must be emitted before tables")
114}
115
116fn emit_value(out: &mut String, value: &Value, state: &State<'_>) -> Result<(), Error> {
117    match value {
118        Value::String(s) => {
119            emit_key(out, state, ArrayState::Started)?;
120            emit_str(out, s, false);
121            newline_if_table(out, state);
122            Ok(())
123        }
124        Value::Integer(i) => display(out, i, state),
125        Value::Float(f) => {
126            emit_key(out, state, ArrayState::Started)?;
127            emit_float(out, *f);
128            newline_if_table(out, state);
129            Ok(())
130        }
131        Value::Boolean(b) => display(out, b, state),
132        // toml_datetime 1.x's Display matches toml 0.5 almost all the time,
133        // with two exceptions:
134        //
135        // 1. An explicit zero fractional second is kept (`07:32:00.0`, where
136        //    0.5 dropped it).
137        // 2. Negative offsets under an hour keep their sign (`-00:30`, which
138        //    0.5 mis-wrote as `+00:30`).
139        //
140        // We accept these divergences here in the interest of not
141        // overengineering a solution, especially since this metadata is
142        // unlikely to have toml datetime values in the first place.
143        Value::Datetime(dt) => display(out, dt, state),
144        Value::Array(array) => emit_array(out, array, state),
145        Value::Table(table) => emit_table(out, table, state),
146    }
147}
148
149fn display(
150    out: &mut String,
151    value: impl std::fmt::Display,
152    state: &State<'_>,
153) -> Result<(), Error> {
154    emit_key(out, state, ArrayState::Started)?;
155    write!(out, "{value}").expect("writing to a String cannot fail");
156    newline_if_table(out, state);
157    Ok(())
158}
159
160fn emit_float(out: &mut String, v: f64) {
161    match (v.is_sign_negative(), v.is_nan(), v == 0.0) {
162        (true, true, _) => out.push_str("-nan"),
163        (false, true, _) => out.push_str("nan"),
164        (true, false, true) => out.push_str("-0.0"),
165        (false, false, true) => out.push_str("0.0"),
166        (_, false, false) => {
167            write!(out, "{v}").expect("writing to a String cannot fail");
168            if v % 1.0 == 0.0 {
169                out.push_str(".0");
170            }
171        }
172    }
173}
174
175fn newline_if_table(out: &mut String, state: &State<'_>) {
176    match state {
177        State::Table { .. } => out.push('\n'),
178        State::End | State::Array { .. } => {}
179    }
180}
181
182fn emit_array(out: &mut String, array: &[Value], state: &State<'_>) -> Result<(), Error> {
183    array_type(state, ArrayState::Started);
184    let first = Cell::new(true);
185    let type_ = Cell::new(None);
186    for element in array {
187        emit_value(
188            out,
189            element,
190            &State::Array {
191                parent: state,
192                first: &first,
193                type_: &type_,
194            },
195        )?;
196        first.set(false);
197    }
198
199    match type_.get() {
200        Some(ArrayState::StartedAsATable) => return Ok(()),
201        Some(ArrayState::Started) => out.push(']'),
202        None => {
203            emit_key(out, state, ArrayState::Started)?;
204            out.push_str("[]");
205        }
206    }
207    newline_if_table(out, state);
208    Ok(())
209}
210
211fn emit_table(out: &mut String, table: &Table, state: &State<'_>) -> Result<(), Error> {
212    array_type(state, ArrayState::StartedAsATable);
213    let first = Cell::new(true);
214    let table_emitted = Cell::new(false);
215    for (key, value) in table {
216        emit_value(
217            out,
218            value,
219            &State::Table {
220                key,
221                parent: state,
222                first: &first,
223                table_emitted: &table_emitted,
224            },
225        )?;
226        first.set(false);
227    }
228
229    if first.get() {
230        emit_table_header(out, state);
231    }
232    Ok(())
233}
234
235fn emit_key(out: &mut String, state: &State<'_>, type_: ArrayState) -> Result<(), Error> {
236    array_type(state, type_);
237    emit_key_inner(out, state)
238}
239
240fn emit_key_inner(out: &mut String, state: &State<'_>) -> Result<(), Error> {
241    match *state {
242        State::End => Ok(()),
243        State::Array {
244            parent,
245            first,
246            type_,
247        } => {
248            assert!(
249                type_.get().is_some(),
250                "array_type is always called before emit_key_inner"
251            );
252            if first.get() {
253                emit_key_inner(out, parent)?;
254            }
255            if first.get() {
256                out.push('[');
257            } else {
258                out.push_str(", ");
259            }
260            Ok(())
261        }
262        State::Table {
263            key,
264            parent,
265            first,
266            table_emitted,
267        } => {
268            if table_emitted.get() {
269                return Err(value_after_table());
270            }
271            if first.get() {
272                emit_table_header(out, parent);
273                first.set(false);
274            }
275            escape_key(out, key);
276            out.push_str(" = ");
277            Ok(())
278        }
279    }
280}
281
282fn array_type(state: &State<'_>, type_: ArrayState) {
283    if let State::Array { type_: prev, .. } = state
284        && prev.get().is_none()
285    {
286        prev.set(Some(type_));
287    }
288}
289
290fn escape_key(out: &mut String, key: &str) {
291    let bare = !key.is_empty()
292        && key
293            .chars()
294            .all(|c| matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '-' | '_'));
295    if bare {
296        out.push_str(key);
297    } else {
298        emit_str(out, key, true);
299    }
300}
301
302#[derive(Clone, Copy, Debug, Eq, PartialEq)]
303enum StrType {
304    NewlineTriple,
305    OnelineTriple,
306    OnelineSingle,
307}
308
309enum StrRepr {
310    // A literal string using single quotes.
311    Literal(String, StrType),
312    // A basic string using double quotes and escapes.
313    Std(StrType),
314}
315
316fn str_repr(value: &str) -> StrRepr {
317    let mut out = String::with_capacity(value.len() * 2);
318    let mut ty = StrType::OnelineSingle;
319    let mut max_found_singles = 0;
320    let mut found_singles = 0;
321    let mut can_be_pretty = true;
322
323    for ch in value.chars() {
324        if can_be_pretty {
325            if ch == '\'' {
326                found_singles += 1;
327                if found_singles >= 3 {
328                    can_be_pretty = false;
329                }
330            } else {
331                if found_singles > max_found_singles {
332                    max_found_singles = found_singles;
333                }
334                found_singles = 0
335            }
336            match ch {
337                '\t' => {}
338                '\n' => ty = StrType::NewlineTriple,
339                c if c <= '\u{1f}' || c == '\u{7f}' => can_be_pretty = false,
340                _ => {}
341            }
342            out.push(ch);
343        } else if ch == '\n' {
344            ty = StrType::NewlineTriple;
345        }
346    }
347    if can_be_pretty && found_singles > 0 && value.ends_with('\'') {
348        can_be_pretty = false;
349    }
350    if !can_be_pretty {
351        debug_assert!(ty != StrType::OnelineTriple);
352        return StrRepr::Std(ty);
353    }
354    if found_singles > max_found_singles {
355        max_found_singles = found_singles;
356    }
357    debug_assert!(max_found_singles < 3);
358    if ty == StrType::OnelineSingle && max_found_singles >= 1 {
359        ty = StrType::OnelineTriple;
360    }
361    StrRepr::Literal(out, ty)
362}
363
364fn emit_str(out: &mut String, value: &str, is_key: bool) {
365    let repr = if is_key {
366        StrRepr::Std(StrType::OnelineSingle)
367    } else {
368        str_repr(value)
369    };
370    match repr {
371        StrRepr::Literal(literal, ty) => {
372            match ty {
373                StrType::NewlineTriple => out.push_str("'''\n"),
374                StrType::OnelineTriple => out.push_str("'''"),
375                StrType::OnelineSingle => out.push('\''),
376            }
377            out.push_str(&literal);
378            match ty {
379                StrType::OnelineSingle => out.push('\''),
380                StrType::NewlineTriple | StrType::OnelineTriple => out.push_str("'''"),
381            }
382        }
383        StrRepr::Std(ty) => {
384            match ty {
385                StrType::NewlineTriple => out.push_str("\"\"\"\n"),
386                StrType::OnelineSingle | StrType::OnelineTriple => out.push('"'),
387            }
388            for ch in value.chars() {
389                match ch {
390                    '\u{8}' => out.push_str("\\b"),
391                    '\u{9}' => out.push_str("\\t"),
392                    '\u{a}' => match ty {
393                        StrType::NewlineTriple => out.push('\n'),
394                        StrType::OnelineSingle => out.push_str("\\n"),
395                        StrType::OnelineTriple => {
396                            unreachable!("newlines always produce NewlineTriple")
397                        }
398                    },
399                    '\u{c}' => out.push_str("\\f"),
400                    '\u{d}' => out.push_str("\\r"),
401                    '\u{22}' => out.push_str("\\\""),
402                    '\u{5c}' => out.push_str("\\\\"),
403                    c if c <= '\u{1f}' || c == '\u{7f}' => {
404                        write!(out, "\\u{:04X}", ch as u32)
405                            .expect("writing to a String cannot fail");
406                    }
407                    ch => out.push(ch),
408                }
409            }
410            match ty {
411                StrType::NewlineTriple => out.push_str("\"\"\""),
412                StrType::OnelineSingle | StrType::OnelineTriple => out.push('"'),
413            }
414        }
415    }
416}
417
418fn emit_table_header(out: &mut String, state: &State<'_>) {
419    let array_of_tables = match state {
420        State::End => return,
421        State::Array { .. } => true,
422        State::Table { .. } => false,
423    };
424
425    // `[a.b]` headers can omit their `[a]` ancestor, but `[[a]]` ancestors
426    // cannot be omitted, so emit those first.
427    let mut p = state;
428    if let State::Array { first, parent, .. } = *state
429        && first.get()
430    {
431        p = parent;
432    }
433    while let State::Table { first, parent, .. } = *p {
434        p = parent;
435        if !first.get() {
436            break;
437        }
438        if let State::Array {
439            parent: State::Table { .. },
440            ..
441        } = *parent
442        {
443            emit_table_header(out, parent);
444            break;
445        }
446    }
447
448    match *state {
449        State::Table { first, .. } => {
450            if !first.get() {
451                out.push('\n');
452            }
453        }
454        State::Array { parent, first, .. } => {
455            if !first.get() {
456                out.push('\n');
457            } else if let State::Table { first, .. } = *parent
458                && !first.get()
459            {
460                out.push('\n');
461            }
462        }
463        State::End => {}
464    }
465    out.push('[');
466    if array_of_tables {
467        out.push('[');
468    }
469    _ = emit_key_part(out, state);
470    if array_of_tables {
471        out.push(']');
472    }
473    out.push_str("]\n");
474}
475
476// Returns true if nothing was written, so that callers know whether to insert a
477// `.` separator.
478#[must_use]
479fn emit_key_part(out: &mut String, state: &State<'_>) -> bool {
480    match *state {
481        State::Array { parent, .. } => emit_key_part(out, parent),
482        State::End => true,
483        State::Table {
484            key,
485            parent,
486            table_emitted,
487            ..
488        } => {
489            table_emitted.set(true);
490            let first = emit_key_part(out, parent);
491            if !first {
492                out.push('.');
493            }
494            escape_key(out, key);
495            false
496        }
497    }
498}
499
500#[cfg(test)]
501mod tests {
502    use super::*;
503
504    fn write(input: &str) -> Result<String, Error> {
505        let table: Table = toml::from_str(input).expect("valid TOML input");
506        let mut out = String::new();
507        write_table(&table, &mut out)?;
508        Ok(out)
509    }
510
511    // Expected outputs in this module were captured from toml 0.5.11's
512    // `Serializer::pretty` with `pretty_array(false)`.
513    #[test]
514    fn summary_shape() {
515        let input = r#"
516hakari-package = "workspace-hack"
517resolver = "2"
518output-single-feature = true
519platforms = ["x86_64-unknown-linux-gnu", "aarch64-apple-darwin"]
520empty = []
521[traversal-excludes]
522workspace-members = ["a"]
523[[traversal-excludes.ids]]
524name = "cargo-compare"
525version = "0.1.0"
526workspace-path = "internal-tools/cargo-compare"
527[[traversal-excludes.ids]]
528name = "serde"
529version = "1.0.0"
530crates-io = true
531[[traversal-excludes.third-party]]
532name = "quote"
533version = "1"
534crates-io = true
535[final-excludes]
536[registries.alt]
537index = "https://example.com/alt"
538[registries."my.registry"]
539index = "https://example.com/index"
540"#;
541        let expected = "\
542hakari-package = 'workspace-hack'
543resolver = '2'
544output-single-feature = true
545platforms = ['x86_64-unknown-linux-gnu', 'aarch64-apple-darwin']
546empty = []
547
548[traversal-excludes]
549workspace-members = ['a']
550
551[[traversal-excludes.ids]]
552name = 'cargo-compare'
553version = '0.1.0'
554workspace-path = 'internal-tools/cargo-compare'
555
556[[traversal-excludes.ids]]
557name = 'serde'
558version = '1.0.0'
559crates-io = true
560
561[[traversal-excludes.third-party]]
562name = 'quote'
563version = '1'
564crates-io = true
565
566[final-excludes]
567[registries.alt]
568index = 'https://example.com/alt'
569
570[registries.\"my.registry\"]
571index = 'https://example.com/index'
572";
573        assert_eq!(write(input).unwrap(), expected);
574    }
575
576    #[test]
577    fn strings() {
578        let input = r#"
579plain = "abc"
580apostrophe = "it's"
581ends-with-apostrophe = "ends'"
582triple = "'''"
583double-quotes = "say \"hi\""
584escapes = "tab\there\u0001\u007f"
585multiline = "a\nb"
586multiline-apostrophe = "a'\nb"
587multiline-control = "a\r\nb"
588backslash = "C:\\dir"
589"#;
590        let expected = "\
591plain = 'abc'
592apostrophe = '''it's'''
593ends-with-apostrophe = \"ends'\"
594triple = \"'''\"
595double-quotes = 'say \"hi\"'
596escapes = \"tab\\there\\u0001\\u007F\"
597multiline = '''
598a
599b'''
600multiline-apostrophe = '''
601a'
602b'''
603multiline-control = \"\"\"
604a\\r
605b\"\"\"
606backslash = 'C:\\dir'
607";
608        assert_eq!(write(input).unwrap(), expected);
609    }
610
611    #[test]
612    fn scalars_and_nesting() {
613        let input = r#"
614int = -42
615float = 1.0
616float2 = 2.5
617yes = false
618nested = [[1, 2], []]
619[a.b.c]
620x = 1
621[[aot]]
622[[aot]]
623y = 2
624[aot.sub]
625z = 3
626"#;
627        let expected = "\
628int = -42
629float = 1.0
630float2 = 2.5
631yes = false
632nested = [[1, 2], []]
633[a.b.c]
634x = 1
635
636[[aot]]
637
638[[aot]]
639y = 2
640
641[aot.sub]
642z = 3
643";
644        assert_eq!(write(input).unwrap(), expected);
645    }
646
647    #[test]
648    fn reorder_value_matches_toml_05() {
649        let input = r#"
650plain = [1, 2]
651v = true
652[t]
653x = 1
654[[aot]]
655[[aot]]
656sub = { inner = { y = 1 }, z = 2 }
657w = 3
658"#;
659        let table: Table = toml::from_str(input).expect("valid TOML input");
660        let reordered = match reorder_value(&Value::Table(table)) {
661            Value::Table(table) => table,
662            other => panic!("reordered a table into {other:?}"),
663        };
664        let keys: Vec<_> = reordered.keys().map(String::as_str).collect();
665        assert_eq!(keys, ["plain", "v", "aot", "t"]);
666
667        let second = reordered["aot"][1]
668            .as_table()
669            .expect("array of tables element is a table");
670        let keys: Vec<_> = second.keys().map(String::as_str).collect();
671        assert_eq!(keys, ["w", "sub"], "array elements are reordered");
672        let sub = second["sub"].as_table().expect("sub is a table");
673        let keys: Vec<_> = sub.keys().map(String::as_str).collect();
674        assert_eq!(keys, ["z", "inner"], "nested tables are reordered");
675
676        let mut out = String::new();
677        write_table(&reordered, &mut out).expect("reordered table serializes");
678        assert_eq!(
679            out,
680            "\
681plain = [1, 2]
682v = true
683
684[[aot]]
685
686[[aot]]
687w = 3
688
689[aot.sub]
690z = 2
691
692[aot.sub.inner]
693y = 1
694
695[t]
696x = 1
697"
698        );
699    }
700
701    #[test]
702    fn value_after_table_is_an_error() {
703        let mut table = Table::new();
704        table.insert("t".to_owned(), Value::Table(Table::new()));
705        table.insert("v".to_owned(), Value::Integer(1));
706        let mut out = String::new();
707        let err = write_table(&table, &mut out).unwrap_err();
708        assert!(
709            err.to_string()
710                .contains("values must be emitted before tables"),
711            "unexpected error: {err}"
712        );
713    }
714}