Skip to main content

lino_objects_codec/
readable.rs

1//! Readable, indented Links Notation representation.
2//!
3//! This module implements the default output of [`crate::encode`]: a plain-text,
4//! indented projection where keys and values are written as they are, so the file
5//! can be read, grepped and reviewed without decoding anything.
6//!
7//! # Shape
8//!
9//! One construct — `( )` — is used for both objects and arrays, at every level
10//! including the root. What distinguishes them is the content of the lines:
11//! `key value` pairs make an object, bare values make an array.
12//!
13//! ```text
14//! (
15//!   type "RouterState"
16//!   server (
17//!     host "127.0.0.1"
18//!     port 18878
19//!   )
20//!   models (
21//!     "claude-haiku"
22//!     "claude-opus"
23//!   )
24//! )
25//! ```
26//!
27//! # Value mapping
28//!
29//! | `LinoValue`                | Readable form                                  |
30//! |----------------------------|------------------------------------------------|
31//! | `Object`                   | `( )` with one `key value` pair per line        |
32//! | `Array`                    | `( )` with one value per line                   |
33//! | `String`                   | quoted, never encoded                           |
34//! | `Int` / `Float` / `Bool` / `Null` | bare, so the type survives the round trip |
35//!
36//! Empty containers keep their type: an empty array is `()` on one line, while an
37//! empty object is written as `(` and `)` on two lines.
38//!
39//! Text is written as text. A string keeps every character a reader would grep
40//! for, including newlines and tabs, and is quoted with a run of delimiters —
41//! `"""say "hi""""` — when it holds the delimiter itself. Only the characters a
42//! form cannot carry are escaped, and only they: the value is then written as
43//! `(escaped "…")`, where `%XX` stands for one escaped byte. The indented form
44//! escapes the carriage return, which CRLF normalisation would otherwise rewrite,
45//! and the other control characters; the single-line form escapes the newline as
46//! well, because there a record ends at the end of the line. Nothing else is
47//! encoded: base64 lives in [`crate::encode_compact`], which a caller asks for by
48//! name.
49//!
50//! # Single-line form
51//!
52//! [`encode_line`] writes the same document on one line, so one record is one
53//! line and an append-only log stays greppable, tailable and countable by
54//! `wc -l`. Rows can no longer be told apart by line breaks there, so an object
55//! names itself with the `o` link id the notation already has, and its pairs are
56//! written as their own links:
57//!
58//! ```text
59//! (o: (type "RouterState") (server (o: (host "127.0.0.1") (port 18878))) (models ("claude-haiku" "claude-opus")))
60//! ```
61//!
62//! | Value            | Single-line form              |
63//! |------------------|-------------------------------|
64//! | `Object`         | `(o: (key value) …)`          |
65//! | empty `Object`   | `(o:)`                        |
66//! | `Array`          | `(value …)`                   |
67//! | empty `Array`    | `()`                          |
68//! | scalars          | exactly as in the indented form |
69//!
70//! The marker is what answers the ambiguity a flat layout otherwise has: without
71//! it `((key value))` reads both as the one-pair object and as the array holding
72//! the two-element array, and an empty key makes it worse. With it, a bare `( )`
73//! is always an array and a marked one is always an object, so every value —
74//! empty key included — survives the round trip. Consequently a *hand-written*
75//! one-line link such as `(a 1)` is the two-element array, not the one-pair
76//! object: on one line, objects say so.
77
78use crate::debug::trace;
79use crate::{CodecError, LinoValue};
80use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
81
82/// Default indentation used by [`encode`].
83pub const DEFAULT_INDENT: &str = "  ";
84
85/// Marker of a base64 payload. Written by [`crate::encode_compact`] and by
86/// versions up to 0.6.0 of the readable form, which is why it is still read.
87pub const BASE64_MARKER: &str = "base64";
88
89/// Marker of a string whose unwritable characters are percent-escaped.
90///
91/// It reads as `(escaped "line one%0Aline two")`. Only those characters change;
92/// the rest of the text is written as it is, so the value stays readable and
93/// greppable.
94pub const ESCAPED_MARKER: &str = "escaped";
95
96/// Link id naming an object in the single-line form, written as `(o: …)`.
97pub const OBJECT_MARKER: &str = "o";
98
99/// Encode a value into the readable, indented Links Notation form.
100pub fn encode(value: &LinoValue, indent: &str) -> String {
101    let mut out = String::new();
102    write_value(value, indent, 0, &mut out);
103    out
104}
105
106/// Encode a value into the readable, single-line Links Notation form.
107///
108/// The result never contains a newline, so one value is one line of an
109/// append-only log. See the module documentation for the shape.
110pub fn encode_line(value: &LinoValue) -> String {
111    let mut out = String::new();
112    write_line_value(value, &mut out);
113    out
114}
115
116/// Decode the readable, single-line Links Notation form back into a value.
117///
118/// This is the exact inverse of [`encode_line`]. Input spanning more than one
119/// line is rejected: a line-based reader hands over one record at a time, and
120/// silently accepting several would merge two records into one value.
121pub fn decode_line(text: &str) -> Result<LinoValue, CodecError> {
122    let line = text.trim_matches(|c: char| c == '\n' || c == '\r');
123    if line.contains('\n') || line.contains('\r') {
124        return Err(CodecError::ParseError(
125            "a single-line document cannot contain a line break".to_string(),
126        ));
127    }
128    decode(line)
129}
130
131/// Decode the readable, indented Links Notation form back into a value.
132pub fn decode(text: &str) -> Result<LinoValue, CodecError> {
133    let tokens = tokenize(text)?;
134    trace("readable.decode", || format!("{} tokens", tokens.len()));
135    let mut cursor = Cursor { tokens, pos: 0 };
136    let rows = cursor.parse_rows(true)?;
137
138    if cursor.pos < cursor.tokens.len() {
139        return Err(CodecError::ParseError(
140            "unexpected ')' in readable notation".to_string(),
141        ));
142    }
143
144    // A document holding a single value (for example `42`) is that value.
145    if rows.len() == 1 && rows[0].len() == 1 {
146        return node_to_value(&rows[0][0]);
147    }
148
149    rows_to_value(&rows, true, false)
150}
151
152/// Quote and escape a value for one physical line. Decode with [`decode_line`].
153pub fn format_value_single_line(value: &str) -> String {
154    format_string(value, Form::Line)
155}
156
157/// Quote a value retaining literal newlines and tabs. Characters that text-file
158/// normalization could change are escaped; decode with [`decode`].
159pub fn format_value_verbatim(value: &str) -> String {
160    format_string(value, Form::Indented)
161}
162
163// === Encoding ===
164
165/// The line structure of the text being written, which is what decides whether a
166/// newline can be written literally: in the indented form a value ends at its
167/// closing quote, so it may span lines; in the single-line form a record ends at
168/// the end of the line, so a newline inside a value would end the record.
169#[derive(Debug, Clone, Copy, PartialEq, Eq)]
170enum Form {
171    Indented,
172    Line,
173}
174
175fn write_value(value: &LinoValue, indent: &str, level: usize, out: &mut String) {
176    match value {
177        LinoValue::Object(pairs) => {
178            if pairs.is_empty() {
179                // An empty object spans two lines; `()` on one line is an empty array.
180                out.push_str("(\n");
181                push_indent(indent, level, out);
182                out.push(')');
183                return;
184            }
185
186            out.push('(');
187            for (key, child) in pairs {
188                out.push('\n');
189                push_indent(indent, level + 1, out);
190                out.push_str(&format_key(key, Form::Indented));
191                out.push(' ');
192                write_value(child, indent, level + 1, out);
193            }
194            out.push('\n');
195            push_indent(indent, level, out);
196            out.push(')');
197        }
198
199        LinoValue::Array(items) => {
200            if items.is_empty() {
201                out.push_str("()");
202                return;
203            }
204
205            out.push('(');
206            for item in items {
207                out.push('\n');
208                push_indent(indent, level + 1, out);
209                write_value(item, indent, level + 1, out);
210            }
211            out.push('\n');
212            push_indent(indent, level, out);
213            out.push(')');
214        }
215
216        scalar => out.push_str(&format_scalar(scalar, Form::Indented)),
217    }
218}
219
220/// Write a value on one line. Objects name themselves with the `o` link id and
221/// write each pair as its own link, so nothing depends on where lines break.
222fn write_line_value(value: &LinoValue, out: &mut String) {
223    match value {
224        LinoValue::Object(pairs) => {
225            if pairs.is_empty() {
226                // `()` is the empty array, so the empty object keeps its marker.
227                out.push('(');
228                out.push_str(OBJECT_MARKER);
229                out.push_str(":)");
230                return;
231            }
232
233            out.push('(');
234            out.push_str(OBJECT_MARKER);
235            out.push(':');
236            for (key, child) in pairs {
237                out.push_str(" (");
238                out.push_str(&format_key(key, Form::Line));
239                out.push(' ');
240                write_line_value(child, out);
241                out.push(')');
242            }
243            out.push(')');
244        }
245
246        LinoValue::Array(items) => {
247            out.push('(');
248            // `(null)` is a compact null; this array needs an explicit marker.
249            if matches!(items.as_slice(), [LinoValue::Null]) {
250                out.push_str("a: ");
251            }
252            for (index, item) in items.iter().enumerate() {
253                if index > 0 {
254                    out.push(' ');
255                }
256                write_line_value(item, out);
257            }
258            out.push(')');
259        }
260
261        scalar => out.push_str(&format_scalar(scalar, Form::Line)),
262    }
263}
264
265fn push_indent(indent: &str, level: usize, out: &mut String) {
266    for _ in 0..level {
267        out.push_str(indent);
268    }
269}
270
271/// Format a scalar value. Strings are quoted, everything else stays bare so that
272/// its type is recoverable when reading the document back.
273fn format_scalar(value: &LinoValue, form: Form) -> String {
274    match value {
275        LinoValue::Null => "null".to_string(),
276        LinoValue::Bool(b) => b.to_string(),
277        LinoValue::Int(i) => i.to_string(),
278        LinoValue::Float(f) => format_float(*f),
279        #[cfg(feature = "serde_json")]
280        LinoValue::JsonNumber(n) => format!("(json-number {})", quote(&n.to_string())),
281        LinoValue::String(s) => format_string(s, form),
282        // Containers are handled by write_value.
283        LinoValue::Array(_) | LinoValue::Object(_) => String::new(),
284    }
285}
286
287fn format_float(f: f64) -> String {
288    if f.is_nan() {
289        "NaN".to_string()
290    } else if f.is_infinite() {
291        if f.is_sign_positive() {
292            "Infinity".to_string()
293        } else {
294            "-Infinity".to_string()
295        }
296    } else {
297        // `{:?}` keeps the decimal point for whole floats (`1.0`), which is what
298        // tells a float apart from an integer when reading the document back.
299        format!("{:?}", f)
300    }
301}
302
303/// Format a string value. The text is written as text; when it holds characters
304/// this form cannot carry, those characters — and only those — are percent-escaped
305/// and the value is marked, so the rest of it stays readable and greppable.
306fn format_string(value: &str, form: Form) -> String {
307    match escape_unwritable(value, form) {
308        Some(escaped) => format!("({} {})", ESCAPED_MARKER, quote(&escaped)),
309        None => quote(value),
310    }
311}
312
313/// Percent-escape the characters this form cannot carry, or `None` when the text
314/// can be written as it is. `%` is escaped too, so escaping is reversible.
315fn escape_unwritable(value: &str, form: Form) -> Option<String> {
316    if !value.chars().any(|c| is_unwritable(c, form)) {
317        return None;
318    }
319
320    let mut out = String::with_capacity(value.len());
321    let mut buffer = [0u8; 4];
322    for c in value.chars() {
323        if c == '%' || is_unwritable(c, form) {
324            for byte in c.encode_utf8(&mut buffer).as_bytes() {
325                out.push('%');
326                out.push(hex_digit(byte >> 4));
327                out.push(hex_digit(byte & 0xf));
328            }
329        } else {
330            out.push(c);
331        }
332    }
333    Some(out)
334}
335
336/// One upper-case hexadecimal digit of a percent escape.
337fn hex_digit(value: u8) -> char {
338    char::from_digit(u32::from(value), 16).map_or('0', |digit| digit.to_ascii_uppercase())
339}
340
341/// Whether a character has to be escaped in this form. A tab is text a reader can
342/// see, and so is a newline in the indented form, where a value may span lines.
343/// A carriage return is escaped because CRLF normalisation rewrites it, and the
344/// remaining control characters because they are not text at all.
345fn is_unwritable(c: char, form: Form) -> bool {
346    if !c.is_control() {
347        return false;
348    }
349    match c {
350        '\t' => false,
351        '\n' => form == Form::Line,
352        _ => true,
353    }
354}
355
356/// Quote a value so that both this reader and the notation's own parser read it
357/// back unchanged.
358///
359/// One delimiter is enough while the text holds none of that
360/// kind; when it holds both kinds, a run of at least three opens the notation's
361/// n-quote form, where the text is literal and only a run at least as long closes
362/// it. A value starting with the delimiter would lengthen the opening run, so the
363/// other delimiter is used for it.
364pub fn quote(value: &str) -> String {
365    if !value.contains('"') {
366        return format!("\"{}\"", value);
367    }
368    if !value.contains('\'') {
369        return format!("'{}'", value);
370    }
371
372    let delimiter = if value.starts_with('"') { '\'' } else { '"' };
373    // A run of two delimiters is the empty value, so the n-quote form starts at
374    // three; beyond that the run only has to outrun the longest one inside.
375    let count = (longest_run(value, delimiter) + 1).max(3);
376    let run: String = std::iter::repeat_n(delimiter, count).collect();
377    format!("{run}{value}{run}")
378}
379
380/// The length of the longest run of `c` in `value`.
381fn longest_run(value: &str, c: char) -> usize {
382    let mut longest = 0;
383    let mut current = 0;
384    for candidate in value.chars() {
385        if candidate == c {
386            current += 1;
387            longest = longest.max(current);
388        } else {
389            current = 0;
390        }
391    }
392    longest
393}
394
395/// Format an object key. Keys are bare when they read as plain identifiers.
396fn format_key(key: &str, form: Form) -> String {
397    let plain = !key.is_empty()
398        && key != BASE64_MARKER
399        && key != ESCAPED_MARKER
400        && key != "json-number"
401        && !key.chars().any(|c| {
402            c.is_whitespace() || c.is_control() || matches!(c, '(' | ')' | '\'' | '"' | ':' | '`')
403        });
404
405    if plain {
406        key.to_string()
407    } else {
408        format_string(key, form)
409    }
410}
411
412// === Decoding ===
413
414#[derive(Debug, Clone, PartialEq, Eq)]
415enum Token {
416    Open,
417    Close,
418    Newline,
419    Ref { value: String, quoted: bool },
420}
421
422/// A parsed element of the readable form: either a reference (remembering whether
423/// it was quoted, which is what distinguishes a string from a number) or a link.
424#[derive(Debug, Clone)]
425enum Node {
426    Ref {
427        value: String,
428        quoted: bool,
429    },
430    Link {
431        rows: Vec<Vec<Node>>,
432        multiline: bool,
433        /// Whether the link named itself an object with the `o:` marker.
434        object: bool,
435    },
436}
437
438fn tokenize(text: &str) -> Result<Vec<Token>, CodecError> {
439    let chars: Vec<char> = text.chars().collect();
440    let mut tokens = Vec::new();
441    let mut i = 0;
442
443    while i < chars.len() {
444        let c = chars[i];
445
446        if c == '\n' {
447            tokens.push(Token::Newline);
448            i += 1;
449        } else if c.is_whitespace() {
450            i += 1;
451        } else if c == '(' {
452            tokens.push(Token::Open);
453            i += 1;
454        } else if c == ')' {
455            tokens.push(Token::Close);
456            i += 1;
457        } else if matches!(c, '"' | '\'' | '`') {
458            let (value, next) = read_quoted(&chars, i, c)?;
459            tokens.push(Token::Ref {
460                value,
461                quoted: true,
462            });
463            i = next;
464        } else {
465            let start = i;
466            while i < chars.len()
467                && !chars[i].is_whitespace()
468                && !matches!(chars[i], '(' | ')' | '"' | '\'' | '`')
469            {
470                i += 1;
471            }
472            tokens.push(Token::Ref {
473                value: chars[start..i].iter().collect(),
474                quoted: false,
475            });
476        }
477    }
478
479    Ok(tokens)
480}
481
482/// Read a quoted reference. The opening run of delimiters says how it is read,
483/// which is what the notation's own parser does:
484///
485/// * one delimiter — the text is literal and a doubled delimiter is one literal
486///   delimiter, which is how versions up to 0.6.0 wrote such values;
487/// * two — the empty value;
488/// * three or more — the n-quote form: the text is literal, and the value ends at
489///   the first run at least as long, whose last delimiters close it. A longer run
490///   therefore belongs to the text, so a value may end with a delimiter.
491fn read_quoted(
492    chars: &[char],
493    start: usize,
494    quote_char: char,
495) -> Result<(String, usize), CodecError> {
496    let opening = run_length(chars, start, quote_char);
497
498    if opening == 2 {
499        return Ok((String::new(), start + 2));
500    }
501
502    if opening == 1 {
503        let mut value = String::new();
504        let mut i = start + 1;
505
506        while i < chars.len() {
507            if chars[i] == quote_char {
508                if chars.get(i + 1) == Some(&quote_char) {
509                    value.push(quote_char);
510                    i += 2;
511                    continue;
512                }
513                return Ok((value, i + 1));
514            }
515            value.push(chars[i]);
516            i += 1;
517        }
518
519        return Err(unterminated_quote(start));
520    }
521
522    let mut i = start + opening;
523    while i < chars.len() {
524        if chars[i] != quote_char {
525            i += 1;
526            continue;
527        }
528
529        let run = run_length(chars, i, quote_char);
530        if run >= opening {
531            let value = chars[start + opening..i + run - opening].iter().collect();
532            return Ok((value, i + run));
533        }
534        i += run;
535    }
536
537    Err(unterminated_quote(start))
538}
539
540/// The length of the run of `c` that starts at `start`.
541fn run_length(chars: &[char], start: usize, c: char) -> usize {
542    chars[start..].iter().take_while(|&&x| x == c).count()
543}
544
545fn unterminated_quote(start: usize) -> CodecError {
546    CodecError::ParseError(format!(
547        "unterminated quoted value starting at character {}",
548        start
549    ))
550}
551
552struct Cursor {
553    tokens: Vec<Token>,
554    pos: usize,
555}
556
557impl Cursor {
558    /// Parse rows until the matching `)` (or the end of input at the top level).
559    /// A row is one line: the values written between two newlines.
560    fn parse_rows(&mut self, top_level: bool) -> Result<Vec<Vec<Node>>, CodecError> {
561        let mut rows: Vec<Vec<Node>> = Vec::new();
562        let mut row: Vec<Node> = Vec::new();
563
564        while self.pos < self.tokens.len() {
565            match &self.tokens[self.pos] {
566                Token::Close => {
567                    if top_level {
568                        break;
569                    }
570                    self.pos += 1;
571                    if !row.is_empty() {
572                        rows.push(row);
573                    }
574                    return Ok(rows);
575                }
576                Token::Newline => {
577                    self.pos += 1;
578                    if !row.is_empty() {
579                        rows.push(std::mem::take(&mut row));
580                    }
581                }
582                _ => row.push(self.parse_node()?),
583            }
584        }
585
586        if !top_level {
587            return Err(CodecError::ParseError(
588                "unterminated '(' in readable notation".to_string(),
589            ));
590        }
591
592        if !row.is_empty() {
593            rows.push(row);
594        }
595        Ok(rows)
596    }
597
598    fn parse_node(&mut self) -> Result<Node, CodecError> {
599        match self.tokens[self.pos].clone() {
600            Token::Ref { value, quoted } => {
601                self.pos += 1;
602                Ok(Node::Ref { value, quoted })
603            }
604            Token::Open => {
605                self.pos += 1;
606                let object = self.take_object_marker();
607                let multiline = self.link_is_multiline();
608                let rows = self.parse_rows(false)?;
609                Ok(Node::Link {
610                    rows,
611                    multiline,
612                    object,
613                })
614            }
615            Token::Close | Token::Newline => Err(CodecError::ParseError(
616                "unexpected token in readable notation".to_string(),
617            )),
618        }
619    }
620
621    /// Consume the `o:` marker if the link that just opened carries one, which is
622    /// how the single-line form says "this link is an object, not an array".
623    fn take_object_marker(&mut self) -> bool {
624        let marker = format!("{}:", OBJECT_MARKER);
625        let is_marker = matches!(
626            self.tokens.get(self.pos),
627            Some(Token::Ref { value, quoted: false }) if *value == marker
628        );
629        let array_marker = matches!(self.tokens.get(self.pos),
630            Some(Token::Ref { value, quoted: false }) if value == "a:");
631        if is_marker || array_marker {
632            self.pos += 1;
633        }
634        is_marker
635    }
636
637    /// Whether the link that just opened spans more than one line, which is what
638    /// tells an empty object (`(\n)`) from an empty array (`()`).
639    fn link_is_multiline(&self) -> bool {
640        self.tokens[self.pos..]
641            .iter()
642            .take_while(|t| **t != Token::Close)
643            .any(|t| *t == Token::Newline)
644    }
645}
646
647fn node_to_value(node: &Node) -> Result<LinoValue, CodecError> {
648    match node {
649        Node::Ref { value, quoted } => Ok(ref_to_value(value, *quoted)),
650        Node::Link {
651            rows,
652            multiline,
653            object,
654        } => rows_to_value(rows, *multiline, *object),
655    }
656}
657
658fn rows_to_value(
659    rows: &[Vec<Node>],
660    multiline: bool,
661    object_marker: bool,
662) -> Result<LinoValue, CodecError> {
663    if object_marker {
664        return marked_object_to_value(rows);
665    }
666
667    if rows.is_empty() {
668        return Ok(if multiline {
669            LinoValue::Object(vec![])
670        } else {
671            LinoValue::Array(vec![])
672        });
673    }
674
675    if let Some(marked) = decode_marked_value(rows) {
676        return marked;
677    }
678
679    // Written on one line, a link is a list of values: an object on one line says
680    // so with the `o:` marker, which is what keeps `(key value)` unambiguous.
681    if !multiline {
682        let mut items = Vec::new();
683        for row in rows {
684            for node in row {
685                items.push(node_to_value(node)?);
686            }
687        }
688        return Ok(LinoValue::Array(items));
689    }
690
691    // `key value` on every line makes an object; anything else is a list of values.
692    let is_object = rows
693        .iter()
694        .all(|row| row.len() == 2 && node_to_key(&row[0]).is_some());
695
696    if is_object {
697        let mut pairs = Vec::with_capacity(rows.len());
698        for row in rows {
699            let Some(key) = node_to_key(&row[0]) else {
700                unreachable!("checked by is_object")
701            };
702            pairs.push((key, node_to_value(&row[1])?));
703        }
704        return Ok(LinoValue::Object(pairs));
705    }
706
707    let mut items = Vec::new();
708    for row in rows {
709        for node in row {
710            items.push(node_to_value(node)?);
711        }
712    }
713    Ok(LinoValue::Array(items))
714}
715
716/// Build the object a `(o: (key value) …)` link describes. Every value in it is
717/// a pair, so anything else is a malformed document rather than a silent array.
718fn marked_object_to_value(rows: &[Vec<Node>]) -> Result<LinoValue, CodecError> {
719    let mut pairs = Vec::new();
720
721    for node in rows.iter().flatten() {
722        let Node::Link {
723            rows: pair,
724            object: false,
725            ..
726        } = node
727        else {
728            return Err(CodecError::ParseError(format!(
729                "an object marked '{}:' holds (key value) pairs, found a value that is not a pair",
730                OBJECT_MARKER
731            )));
732        };
733
734        let [row] = pair.as_slice() else {
735            return Err(CodecError::ParseError(format!(
736                "an object marked '{}:' holds (key value) pairs, found a link of {} lines",
737                OBJECT_MARKER,
738                pair.len()
739            )));
740        };
741
742        let [key_node, value] = row.as_slice() else {
743            return Err(CodecError::ParseError(format!(
744                "an object marked '{}:' holds (key value) pairs, found a link of {} values",
745                OBJECT_MARKER,
746                row.len()
747            )));
748        };
749
750        let Some(key) = node_to_key(key_node) else {
751            return Err(CodecError::ParseError(format!(
752                "an object marked '{}:' holds (key value) pairs, found a pair whose key is not text",
753                OBJECT_MARKER
754            )));
755        };
756
757        pairs.push((key, node_to_value(value)?));
758    }
759
760    Ok(LinoValue::Object(pairs))
761}
762
763/// The key a node in key position spells: a reference is the key itself, and a
764/// marked link is the text its marker escapes, which is how a key holding a
765/// character the form cannot carry stays a key instead of turning its object into
766/// an array.
767fn node_to_key(node: &Node) -> Option<String> {
768    match node {
769        Node::Ref { value, .. } => Some(value.clone()),
770        Node::Link {
771            rows,
772            object: false,
773            ..
774        } => match decode_marked_value(rows)? {
775            Ok(LinoValue::String(key)) => Some(key),
776            _ => None,
777        },
778        Node::Link { .. } => None,
779    }
780}
781
782/// Recognise a marked value: `(escaped "…")`, whose text is written as it is
783/// except for the percent-escaped characters this form cannot carry, and
784/// `(base64 "…")`, which versions up to 0.6.0 wrote and which is still read. A
785/// quoted marker is an ordinary object key, not a marker.
786fn decode_marked_value(rows: &[Vec<Node>]) -> Option<Result<LinoValue, CodecError>> {
787    if rows.len() != 1 || rows[0].len() != 2 {
788        return None;
789    }
790
791    let Node::Ref {
792        value: marker,
793        quoted: false,
794    } = &rows[0][0]
795    else {
796        return None;
797    };
798
799    let Node::Ref {
800        value: payload,
801        quoted: true,
802    } = &rows[0][1]
803    else {
804        return None;
805    };
806
807    #[cfg(feature = "serde_json")]
808    if marker == "json-number" {
809        return Some(
810            payload
811                .parse::<serde_json::Number>()
812                .map(LinoValue::JsonNumber)
813                .map_err(|e| CodecError::DecodeError(format!("invalid JSON number: {e}"))),
814        );
815    }
816
817    if marker == ESCAPED_MARKER {
818        return Some(unescape(payload).map(LinoValue::String));
819    }
820
821    if marker == BASE64_MARKER {
822        return Some(
823            BASE64
824                .decode(payload)
825                .map_err(|e| CodecError::DecodeError(format!("invalid base64 value: {}", e)))
826                .and_then(|bytes| {
827                    String::from_utf8(bytes)
828                        .map(LinoValue::String)
829                        .map_err(|e| CodecError::DecodeError(format!("invalid UTF-8 value: {}", e)))
830                }),
831        );
832    }
833
834    None
835}
836
837/// Undo the percent-escaping of an `(escaped "…")` payload. Escapes stand for
838/// bytes, so a character outside ASCII is written as its UTF-8 bytes and read
839/// back from them.
840pub fn unescape(payload: &str) -> Result<String, CodecError> {
841    let bytes = payload.as_bytes();
842    let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
843    let mut i = 0;
844
845    while i < bytes.len() {
846        if bytes[i] != b'%' {
847            out.push(bytes[i]);
848            i += 1;
849            continue;
850        }
851
852        let escape = payload.get(i + 1..i + 3).ok_or_else(|| {
853            CodecError::DecodeError(format!(
854                "truncated escape at character {} of an escaped value",
855                i
856            ))
857        })?;
858        let byte = u8::from_str_radix(escape, 16).map_err(|_| {
859            CodecError::DecodeError(format!("invalid escape '%{}' in an escaped value", escape))
860        })?;
861        out.push(byte);
862        i += 3;
863    }
864
865    String::from_utf8(out)
866        .map_err(|e| CodecError::DecodeError(format!("invalid UTF-8 escaped value: {}", e)))
867}
868
869/// Convert a reference to a value. Quoted references are always strings; bare
870/// references keep the type they were written with.
871fn ref_to_value(value: &str, quoted: bool) -> LinoValue {
872    if quoted {
873        return LinoValue::String(value.to_string());
874    }
875
876    match value {
877        "null" => return LinoValue::Null,
878        "true" => return LinoValue::Bool(true),
879        "false" => return LinoValue::Bool(false),
880        "NaN" => return LinoValue::Float(f64::NAN),
881        "Infinity" => return LinoValue::Float(f64::INFINITY),
882        "-Infinity" => return LinoValue::Float(f64::NEG_INFINITY),
883        _ => {}
884    }
885
886    if let Ok(i) = value.parse::<i64>() {
887        return LinoValue::Int(i);
888    }
889    if value.contains(['.', 'e', 'E']) {
890        if let Ok(f) = value.parse::<f64>() {
891            return LinoValue::Float(f);
892        }
893    }
894
895    LinoValue::String(value.to_string())
896}
897
898#[cfg(test)]
899mod tests {
900    use super::*;
901
902    fn roundtrip(value: &LinoValue) -> LinoValue {
903        let text = encode(value, DEFAULT_INDENT);
904        decode(&text).unwrap_or_else(|e| panic!("failed to decode {:?}: {}", text, e))
905    }
906
907    #[test]
908    fn empty_containers_keep_their_type() {
909        assert_eq!(encode(&LinoValue::Array(vec![]), DEFAULT_INDENT), "()");
910        assert_eq!(encode(&LinoValue::Object(vec![]), DEFAULT_INDENT), "(\n)");
911        assert_eq!(
912            roundtrip(&LinoValue::Array(vec![])),
913            LinoValue::Array(vec![])
914        );
915        assert_eq!(
916            roundtrip(&LinoValue::Object(vec![])),
917            LinoValue::Object(vec![])
918        );
919    }
920
921    #[test]
922    fn single_pair_object_is_not_an_array() {
923        let value = LinoValue::object([("a", LinoValue::Int(1))]);
924        assert_eq!(encode(&value, DEFAULT_INDENT), "(\n  a 1\n)");
925        assert_eq!(roundtrip(&value), value);
926    }
927
928    #[test]
929    fn numeric_looking_strings_stay_strings() {
930        let value = LinoValue::object([
931            ("count", LinoValue::Int(42)),
932            ("zip", LinoValue::String("10001".to_string())),
933            ("flag", LinoValue::String("true".to_string())),
934        ]);
935        assert_eq!(roundtrip(&value), value);
936    }
937
938    #[test]
939    fn whole_floats_stay_floats() {
940        let value = LinoValue::Float(1.0);
941        assert_eq!(encode(&value, DEFAULT_INDENT), "1.0");
942        assert!(matches!(roundtrip(&value), LinoValue::Float(f) if (f - 1.0).abs() < f64::EPSILON));
943    }
944
945    #[test]
946    fn base64_key_is_quoted_so_it_is_not_a_marker() {
947        let value = LinoValue::object([("base64", LinoValue::String("plain".to_string()))]);
948        assert_eq!(
949            encode(&value, DEFAULT_INDENT),
950            "(\n  \"base64\" \"plain\"\n)"
951        );
952        assert_eq!(roundtrip(&value), value);
953    }
954
955    #[test]
956    fn text_is_written_as_text_and_only_the_rest_is_escaped() {
957        let value = LinoValue::object([
958            ("plain", LinoValue::String("visible".to_string())),
959            ("raw", LinoValue::String("line1\nline2".to_string())),
960            ("returned", LinoValue::String("line1\r\nline2".to_string())),
961        ]);
962        let text = encode(&value, DEFAULT_INDENT);
963        assert!(text.contains("plain \"visible\""), "{}", text);
964        assert!(text.contains("raw \"line1\nline2\""), "{}", text);
965        assert!(
966            text.contains("returned (escaped \"line1%0D\nline2\")"),
967            "{}",
968            text
969        );
970        assert!(!text.contains("base64"), "{}", text);
971        assert_eq!(roundtrip(&value), value);
972    }
973
974    /// The readable form is Links Notation, so the notation's own parser has to
975    /// read every document it writes -- quotes, newlines and escapes included.
976    #[test]
977    fn every_written_document_parses_as_links_notation() {
978        let texts = [
979            "plain",
980            "it's",
981            "he said \"hello\"",
982            "both \"kinds\" of 'quotes'",
983            "a\"\"b'c",
984            "a\"\"\"b'c",
985            "trailing quote\"'",
986            "'\"",
987            "line one\nline two",
988            "a\tb",
989            "null\u{0}byte",
990            "unicode: 你好世界 🌍",
991        ];
992
993        for text in texts {
994            let value = LinoValue::object([
995                ("message", LinoValue::String(text.to_string())),
996                ("level", LinoValue::String("info".to_string())),
997            ]);
998
999            for document in [encode(&value, DEFAULT_INDENT), encode_line(&value)] {
1000                assert!(
1001                    links_notation::parse_lino(&document).is_ok(),
1002                    "links-notation rejected {:?}",
1003                    document
1004                );
1005            }
1006        }
1007    }
1008
1009    /// The value the notation's parser reads back has to be the value written,
1010    /// not merely something that parses.
1011    #[test]
1012    fn links_notation_reads_back_the_text_that_was_written() {
1013        for text in [
1014            "he said \"hello\"",
1015            "both \"kinds\" of 'quotes'",
1016            "a\"\"b'c",
1017            "line one\nline two",
1018        ] {
1019            let document = encode(
1020                &LinoValue::object([("message", LinoValue::String(text.to_string()))]),
1021                DEFAULT_INDENT,
1022            );
1023            let parsed = links_notation::parse_lino(&document)
1024                .unwrap_or_else(|e| panic!("links-notation rejected {:?}: {}", document, e));
1025
1026            let mut refs = Vec::new();
1027            collect_refs(&parsed, &mut refs);
1028            assert!(
1029                refs.contains(&text.to_string()),
1030                "links-notation read {:?} out of {:?}",
1031                refs,
1032                document
1033            );
1034        }
1035    }
1036
1037    fn collect_refs(node: &links_notation::LiNo<String>, out: &mut Vec<String>) {
1038        match node {
1039            links_notation::LiNo::Ref(value) => out.push(value.clone()),
1040            links_notation::LiNo::Link { values, .. } => {
1041                for value in values {
1042                    collect_refs(value, out);
1043                }
1044            }
1045        }
1046    }
1047
1048    #[test]
1049    fn custom_indent_is_used() {
1050        let value = LinoValue::object([("a", LinoValue::Int(1))]);
1051        assert_eq!(encode(&value, "    "), "(\n    a 1\n)");
1052    }
1053
1054    #[test]
1055    fn handwritten_document_without_root_parentheses_decodes() {
1056        let decoded = decode("a 1\nb \"two\"").unwrap();
1057        assert_eq!(
1058            decoded,
1059            LinoValue::object([
1060                ("a", LinoValue::Int(1)),
1061                ("b", LinoValue::String("two".into()))
1062            ])
1063        );
1064    }
1065
1066    fn line_roundtrip(value: &LinoValue) -> LinoValue {
1067        let text = encode_line(value);
1068        assert!(
1069            !text.contains('\n'),
1070            "line form must hold no newline: {:?}",
1071            text
1072        );
1073        decode_line(&text).unwrap_or_else(|e| panic!("failed to decode {:?}: {}", text, e))
1074    }
1075
1076    #[test]
1077    fn line_form_marks_objects_and_leaves_arrays_bare() {
1078        assert_eq!(encode_line(&LinoValue::Array(vec![])), "()");
1079        assert_eq!(encode_line(&LinoValue::Object(vec![])), "(o:)");
1080        assert_eq!(
1081            encode_line(&LinoValue::object([("a", LinoValue::Int(1))])),
1082            "(o: (a 1))"
1083        );
1084        assert_eq!(
1085            encode_line(&LinoValue::array([
1086                LinoValue::String("key".into()),
1087                LinoValue::String("value".into()),
1088            ])),
1089            "(\"key\" \"value\")"
1090        );
1091    }
1092
1093    #[test]
1094    fn line_form_tells_a_one_pair_object_from_a_two_element_array() {
1095        let object = LinoValue::object([("key", LinoValue::String("value".into()))]);
1096        let array = LinoValue::array([
1097            LinoValue::String("key".into()),
1098            LinoValue::String("value".into()),
1099        ]);
1100        assert_ne!(encode_line(&object), encode_line(&array));
1101        assert_eq!(line_roundtrip(&object), object);
1102        assert_eq!(line_roundtrip(&array), array);
1103    }
1104
1105    #[test]
1106    fn line_form_roundtrips_nested_and_empty_containers() {
1107        let value = LinoValue::object([
1108            ("empty_array", LinoValue::Array(vec![])),
1109            ("empty_object", LinoValue::Object(vec![])),
1110            (
1111                "records",
1112                LinoValue::array([
1113                    LinoValue::object([("id", LinoValue::Int(1))]),
1114                    LinoValue::object([("id", LinoValue::Int(2))]),
1115                ]),
1116            ),
1117        ]);
1118        assert_eq!(
1119            encode_line(&value),
1120            "(o: (empty_array ()) (empty_object (o:)) (records ((o: (id 1)) (o: (id 2)))))"
1121        );
1122        assert_eq!(line_roundtrip(&value), value);
1123    }
1124
1125    #[test]
1126    fn line_form_keeps_the_empty_key() {
1127        let value = LinoValue::object([("", LinoValue::Int(2))]);
1128        assert_eq!(encode_line(&value), "(o: (\"\" 2))");
1129        assert_eq!(line_roundtrip(&value), value);
1130    }
1131
1132    #[test]
1133    fn line_form_keeps_strings_on_one_line() {
1134        let value = LinoValue::object([
1135            (
1136                "quotes",
1137                LinoValue::String("both \"kinds\" of 'quotes'".into()),
1138            ),
1139            ("unicode", LinoValue::String("héllo 世界 🌍".into())),
1140            ("multiline", LinoValue::String("line1\nline2".into())),
1141        ]);
1142        let text = encode_line(&value);
1143        assert!(!text.contains('\n'), "{}", text);
1144        assert_eq!(line_roundtrip(&value), value);
1145    }
1146
1147    #[test]
1148    fn a_line_form_object_holds_pairs_only() {
1149        assert!(decode_line("(o: 1 2)").is_err());
1150        assert!(decode_line("(o: (a 1 2))").is_err());
1151        assert!(decode_line("(o: (a))").is_err());
1152    }
1153
1154    #[test]
1155    fn decode_line_rejects_a_document_of_several_lines() {
1156        assert!(decode_line("(o: (a 1))\n(o: (a 2))").is_err());
1157        // A trailing line break is what a line-based reader hands over, so it is
1158        // stripped rather than rejected.
1159        assert_eq!(
1160            decode_line("(o: (a 1))\n").unwrap(),
1161            LinoValue::object([("a", LinoValue::Int(1))])
1162        );
1163    }
1164
1165    #[test]
1166    fn unterminated_input_is_an_error() {
1167        assert!(decode("(\n  a 1\n").is_err());
1168        assert!(decode("(\n  a \"unterminated\n").is_err());
1169        assert!(decode("(\n  a \"\"\"unterminated\n").is_err());
1170        assert!(decode("a 1)").is_err());
1171    }
1172
1173    /// The three ways a run of delimiters reads, which is what keeps documents
1174    /// written by earlier versions decoding as they did.
1175    #[test]
1176    fn a_run_of_delimiters_says_how_the_value_is_read() {
1177        // One delimiter: a doubled delimiter is one literal delimiter.
1178        assert_eq!(
1179            decode("\"both \"\"kinds\"\" of 'quotes'\"").unwrap(),
1180            LinoValue::String("both \"kinds\" of 'quotes'".to_string())
1181        );
1182        // Two: the empty value.
1183        assert_eq!(decode("\"\"").unwrap(), LinoValue::String(String::new()));
1184        // Three or more: the text is literal, and the last delimiters close it.
1185        assert_eq!(
1186            decode("\"\"\"say \"hi\"\"\"\"").unwrap(),
1187            LinoValue::String("say \"hi\"".to_string())
1188        );
1189    }
1190}