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//! Only values that cannot be written as plain text are encoded: strings holding
40//! control characters (including newlines and tabs, which line-based tooling and
41//! CRLF normalisation would corrupt) are marked individually as
42//! `(base64 "…")` instead of encoding the whole document.
43
44use crate::debug::trace;
45use crate::{CodecError, LinoValue};
46use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
47
48/// Default indentation used by [`encode`].
49pub const DEFAULT_INDENT: &str = "  ";
50
51/// Marker used for values that cannot be represented as plain text.
52pub const BASE64_MARKER: &str = "base64";
53
54/// Encode a value into the readable, indented Links Notation form.
55pub fn encode(value: &LinoValue, indent: &str) -> String {
56    let mut out = String::new();
57    write_value(value, indent, 0, &mut out);
58    out
59}
60
61/// Decode the readable, indented Links Notation form back into a value.
62pub fn decode(text: &str) -> Result<LinoValue, CodecError> {
63    let tokens = tokenize(text)?;
64    trace("readable.decode", || format!("{} tokens", tokens.len()));
65    let mut cursor = Cursor { tokens, pos: 0 };
66    let rows = cursor.parse_rows(true)?;
67
68    if cursor.pos < cursor.tokens.len() {
69        return Err(CodecError::ParseError(
70            "unexpected ')' in readable notation".to_string(),
71        ));
72    }
73
74    // A document holding a single value (for example `42`) is that value.
75    if rows.len() == 1 && rows[0].len() == 1 {
76        return node_to_value(&rows[0][0]);
77    }
78
79    rows_to_value(&rows, true)
80}
81
82// === Encoding ===
83
84fn write_value(value: &LinoValue, indent: &str, level: usize, out: &mut String) {
85    match value {
86        LinoValue::Object(pairs) => {
87            if pairs.is_empty() {
88                // An empty object spans two lines; `()` on one line is an empty array.
89                out.push_str("(\n");
90                push_indent(indent, level, out);
91                out.push(')');
92                return;
93            }
94
95            out.push('(');
96            for (key, child) in pairs {
97                out.push('\n');
98                push_indent(indent, level + 1, out);
99                out.push_str(&format_key(key));
100                out.push(' ');
101                write_value(child, indent, level + 1, out);
102            }
103            out.push('\n');
104            push_indent(indent, level, out);
105            out.push(')');
106        }
107
108        LinoValue::Array(items) => {
109            if items.is_empty() {
110                out.push_str("()");
111                return;
112            }
113
114            out.push('(');
115            for item in items {
116                out.push('\n');
117                push_indent(indent, level + 1, out);
118                write_value(item, indent, level + 1, out);
119            }
120            out.push('\n');
121            push_indent(indent, level, out);
122            out.push(')');
123        }
124
125        scalar => out.push_str(&format_scalar(scalar)),
126    }
127}
128
129fn push_indent(indent: &str, level: usize, out: &mut String) {
130    for _ in 0..level {
131        out.push_str(indent);
132    }
133}
134
135/// Format a scalar value. Strings are quoted, everything else stays bare so that
136/// its type is recoverable when reading the document back.
137fn format_scalar(value: &LinoValue) -> String {
138    match value {
139        LinoValue::Null => "null".to_string(),
140        LinoValue::Bool(b) => b.to_string(),
141        LinoValue::Int(i) => i.to_string(),
142        LinoValue::Float(f) => format_float(*f),
143        LinoValue::String(s) => format_string(s),
144        // Containers are handled by write_value.
145        LinoValue::Array(_) | LinoValue::Object(_) => String::new(),
146    }
147}
148
149fn format_float(f: f64) -> String {
150    if f.is_nan() {
151        "NaN".to_string()
152    } else if f.is_infinite() {
153        if f.is_sign_positive() {
154            "Infinity".to_string()
155        } else {
156            "-Infinity".to_string()
157        }
158    } else {
159        // `{:?}` keeps the decimal point for whole floats (`1.0`), which is what
160        // tells a float apart from an integer when reading the document back.
161        format!("{:?}", f)
162    }
163}
164
165/// Format a string value: quoted plain text, or an individually marked
166/// base64 payload when the text cannot be written literally.
167fn format_string(value: &str) -> String {
168    if needs_encoding(value) {
169        return format!(
170            "({} {})",
171            BASE64_MARKER,
172            quote(&BASE64.encode(value.as_bytes()))
173        );
174    }
175    quote(value)
176}
177
178/// A value can be written as text unless it contains control characters:
179/// newlines break the line structure and CRLF normalisation would rewrite them.
180fn needs_encoding(value: &str) -> bool {
181    value.chars().any(char::is_control)
182}
183
184fn quote(value: &str) -> String {
185    let has_double = value.contains('"');
186    let has_single = value.contains('\'');
187
188    if !has_double {
189        return format!("\"{}\"", value);
190    }
191    if !has_single {
192        return format!("'{}'", value);
193    }
194    // Both quote styles are present: double the double quotes, as the parser expects.
195    format!("\"{}\"", value.replace('"', "\"\""))
196}
197
198/// Format an object key. Keys are bare when they read as plain identifiers.
199fn format_key(key: &str) -> String {
200    let plain = !key.is_empty()
201        && key != BASE64_MARKER
202        && !needs_encoding(key)
203        && !key
204            .chars()
205            .any(|c| c.is_whitespace() || matches!(c, '(' | ')' | '\'' | '"' | ':' | '`'));
206
207    if plain {
208        key.to_string()
209    } else {
210        format_string(key)
211    }
212}
213
214// === Decoding ===
215
216#[derive(Debug, Clone, PartialEq, Eq)]
217enum Token {
218    Open,
219    Close,
220    Newline,
221    Ref { value: String, quoted: bool },
222}
223
224/// A parsed element of the readable form: either a reference (remembering whether
225/// it was quoted, which is what distinguishes a string from a number) or a link.
226#[derive(Debug, Clone)]
227enum Node {
228    Ref {
229        value: String,
230        quoted: bool,
231    },
232    Link {
233        rows: Vec<Vec<Node>>,
234        multiline: bool,
235    },
236}
237
238fn tokenize(text: &str) -> Result<Vec<Token>, CodecError> {
239    let chars: Vec<char> = text.chars().collect();
240    let mut tokens = Vec::new();
241    let mut i = 0;
242
243    while i < chars.len() {
244        let c = chars[i];
245
246        if c == '\n' {
247            tokens.push(Token::Newline);
248            i += 1;
249        } else if c.is_whitespace() {
250            i += 1;
251        } else if c == '(' {
252            tokens.push(Token::Open);
253            i += 1;
254        } else if c == ')' {
255            tokens.push(Token::Close);
256            i += 1;
257        } else if matches!(c, '"' | '\'' | '`') {
258            let (value, next) = read_quoted(&chars, i, c)?;
259            tokens.push(Token::Ref {
260                value,
261                quoted: true,
262            });
263            i = next;
264        } else {
265            let start = i;
266            while i < chars.len()
267                && !chars[i].is_whitespace()
268                && !matches!(chars[i], '(' | ')' | '"' | '\'' | '`')
269            {
270                i += 1;
271            }
272            tokens.push(Token::Ref {
273                value: chars[start..i].iter().collect(),
274                quoted: false,
275            });
276        }
277    }
278
279    Ok(tokens)
280}
281
282/// Read a quoted reference, where a doubled quote character means a literal one.
283fn read_quoted(
284    chars: &[char],
285    start: usize,
286    quote_char: char,
287) -> Result<(String, usize), CodecError> {
288    let mut value = String::new();
289    let mut i = start + 1;
290
291    while i < chars.len() {
292        if chars[i] == quote_char {
293            if chars.get(i + 1) == Some(&quote_char) {
294                value.push(quote_char);
295                i += 2;
296                continue;
297            }
298            return Ok((value, i + 1));
299        }
300        value.push(chars[i]);
301        i += 1;
302    }
303
304    Err(CodecError::ParseError(format!(
305        "unterminated quoted value starting at character {}",
306        start
307    )))
308}
309
310struct Cursor {
311    tokens: Vec<Token>,
312    pos: usize,
313}
314
315impl Cursor {
316    /// Parse rows until the matching `)` (or the end of input at the top level).
317    /// A row is one line: the values written between two newlines.
318    fn parse_rows(&mut self, top_level: bool) -> Result<Vec<Vec<Node>>, CodecError> {
319        let mut rows: Vec<Vec<Node>> = Vec::new();
320        let mut row: Vec<Node> = Vec::new();
321
322        while self.pos < self.tokens.len() {
323            match &self.tokens[self.pos] {
324                Token::Close => {
325                    if top_level {
326                        break;
327                    }
328                    self.pos += 1;
329                    if !row.is_empty() {
330                        rows.push(row);
331                    }
332                    return Ok(rows);
333                }
334                Token::Newline => {
335                    self.pos += 1;
336                    if !row.is_empty() {
337                        rows.push(std::mem::take(&mut row));
338                    }
339                }
340                _ => row.push(self.parse_node()?),
341            }
342        }
343
344        if !top_level {
345            return Err(CodecError::ParseError(
346                "unterminated '(' in readable notation".to_string(),
347            ));
348        }
349
350        if !row.is_empty() {
351            rows.push(row);
352        }
353        Ok(rows)
354    }
355
356    fn parse_node(&mut self) -> Result<Node, CodecError> {
357        match self.tokens[self.pos].clone() {
358            Token::Ref { value, quoted } => {
359                self.pos += 1;
360                Ok(Node::Ref { value, quoted })
361            }
362            Token::Open => {
363                self.pos += 1;
364                let multiline = self.link_is_multiline();
365                let rows = self.parse_rows(false)?;
366                Ok(Node::Link { rows, multiline })
367            }
368            Token::Close | Token::Newline => Err(CodecError::ParseError(
369                "unexpected token in readable notation".to_string(),
370            )),
371        }
372    }
373
374    /// Whether the link that just opened spans more than one line, which is what
375    /// tells an empty object (`(\n)`) from an empty array (`()`).
376    fn link_is_multiline(&self) -> bool {
377        self.tokens[self.pos..]
378            .iter()
379            .take_while(|t| **t != Token::Close)
380            .any(|t| *t == Token::Newline)
381    }
382}
383
384fn node_to_value(node: &Node) -> Result<LinoValue, CodecError> {
385    match node {
386        Node::Ref { value, quoted } => Ok(ref_to_value(value, *quoted)),
387        Node::Link { rows, multiline } => rows_to_value(rows, *multiline),
388    }
389}
390
391fn rows_to_value(rows: &[Vec<Node>], multiline: bool) -> Result<LinoValue, CodecError> {
392    if rows.is_empty() {
393        return Ok(if multiline {
394            LinoValue::Object(vec![])
395        } else {
396            LinoValue::Array(vec![])
397        });
398    }
399
400    if let Some(marked) = decode_marked_value(rows) {
401        return marked;
402    }
403
404    // `key value` on every line makes an object; anything else is a list of values.
405    let is_object = rows
406        .iter()
407        .all(|row| row.len() == 2 && matches!(row[0], Node::Ref { .. }));
408
409    if is_object {
410        let mut pairs = Vec::with_capacity(rows.len());
411        for row in rows {
412            let Node::Ref { value: key, .. } = &row[0] else {
413                unreachable!("checked by is_object")
414            };
415            pairs.push((key.clone(), node_to_value(&row[1])?));
416        }
417        return Ok(LinoValue::Object(pairs));
418    }
419
420    let mut items = Vec::new();
421    for row in rows {
422        for node in row {
423            items.push(node_to_value(node)?);
424        }
425    }
426    Ok(LinoValue::Array(items))
427}
428
429/// Recognise `(base64 "…")`, the individual marker for values that could not be
430/// written as text. A quoted `base64` key is an ordinary object key, not a marker.
431fn decode_marked_value(rows: &[Vec<Node>]) -> Option<Result<LinoValue, CodecError>> {
432    if rows.len() != 1 || rows[0].len() != 2 {
433        return None;
434    }
435
436    let Node::Ref {
437        value: marker,
438        quoted: false,
439    } = &rows[0][0]
440    else {
441        return None;
442    };
443    if marker != BASE64_MARKER {
444        return None;
445    }
446
447    let Node::Ref {
448        value: payload,
449        quoted: true,
450    } = &rows[0][1]
451    else {
452        return None;
453    };
454
455    Some(
456        BASE64
457            .decode(payload)
458            .map_err(|e| CodecError::DecodeError(format!("invalid base64 value: {}", e)))
459            .and_then(|bytes| {
460                String::from_utf8(bytes)
461                    .map(LinoValue::String)
462                    .map_err(|e| CodecError::DecodeError(format!("invalid UTF-8 value: {}", e)))
463            }),
464    )
465}
466
467/// Convert a reference to a value. Quoted references are always strings; bare
468/// references keep the type they were written with.
469fn ref_to_value(value: &str, quoted: bool) -> LinoValue {
470    if quoted {
471        return LinoValue::String(value.to_string());
472    }
473
474    match value {
475        "null" => return LinoValue::Null,
476        "true" => return LinoValue::Bool(true),
477        "false" => return LinoValue::Bool(false),
478        "NaN" => return LinoValue::Float(f64::NAN),
479        "Infinity" => return LinoValue::Float(f64::INFINITY),
480        "-Infinity" => return LinoValue::Float(f64::NEG_INFINITY),
481        _ => {}
482    }
483
484    if let Ok(i) = value.parse::<i64>() {
485        return LinoValue::Int(i);
486    }
487    if value.contains(['.', 'e', 'E']) {
488        if let Ok(f) = value.parse::<f64>() {
489            return LinoValue::Float(f);
490        }
491    }
492
493    LinoValue::String(value.to_string())
494}
495
496#[cfg(test)]
497mod tests {
498    use super::*;
499
500    fn roundtrip(value: &LinoValue) -> LinoValue {
501        let text = encode(value, DEFAULT_INDENT);
502        decode(&text).unwrap_or_else(|e| panic!("failed to decode {:?}: {}", text, e))
503    }
504
505    #[test]
506    fn empty_containers_keep_their_type() {
507        assert_eq!(encode(&LinoValue::Array(vec![]), DEFAULT_INDENT), "()");
508        assert_eq!(encode(&LinoValue::Object(vec![]), DEFAULT_INDENT), "(\n)");
509        assert_eq!(
510            roundtrip(&LinoValue::Array(vec![])),
511            LinoValue::Array(vec![])
512        );
513        assert_eq!(
514            roundtrip(&LinoValue::Object(vec![])),
515            LinoValue::Object(vec![])
516        );
517    }
518
519    #[test]
520    fn single_pair_object_is_not_an_array() {
521        let value = LinoValue::object([("a", LinoValue::Int(1))]);
522        assert_eq!(encode(&value, DEFAULT_INDENT), "(\n  a 1\n)");
523        assert_eq!(roundtrip(&value), value);
524    }
525
526    #[test]
527    fn numeric_looking_strings_stay_strings() {
528        let value = LinoValue::object([
529            ("count", LinoValue::Int(42)),
530            ("zip", LinoValue::String("10001".to_string())),
531            ("flag", LinoValue::String("true".to_string())),
532        ]);
533        assert_eq!(roundtrip(&value), value);
534    }
535
536    #[test]
537    fn whole_floats_stay_floats() {
538        let value = LinoValue::Float(1.0);
539        assert_eq!(encode(&value, DEFAULT_INDENT), "1.0");
540        assert!(matches!(roundtrip(&value), LinoValue::Float(f) if (f - 1.0).abs() < f64::EPSILON));
541    }
542
543    #[test]
544    fn base64_key_is_quoted_so_it_is_not_a_marker() {
545        let value = LinoValue::object([("base64", LinoValue::String("plain".to_string()))]);
546        assert_eq!(
547            encode(&value, DEFAULT_INDENT),
548            "(\n  \"base64\" \"plain\"\n)"
549        );
550        assert_eq!(roundtrip(&value), value);
551    }
552
553    #[test]
554    fn control_characters_are_marked_individually() {
555        let value = LinoValue::object([
556            ("plain", LinoValue::String("visible".to_string())),
557            ("raw", LinoValue::String("line1\nline2".to_string())),
558        ]);
559        let text = encode(&value, DEFAULT_INDENT);
560        assert!(text.contains("plain \"visible\""), "{}", text);
561        assert!(
562            text.contains("raw (base64 \"bGluZTEKbGluZTI=\")"),
563            "{}",
564            text
565        );
566        assert_eq!(roundtrip(&value), value);
567    }
568
569    #[test]
570    fn custom_indent_is_used() {
571        let value = LinoValue::object([("a", LinoValue::Int(1))]);
572        assert_eq!(encode(&value, "    "), "(\n    a 1\n)");
573    }
574
575    #[test]
576    fn handwritten_document_without_root_parentheses_decodes() {
577        let decoded = decode("a 1\nb \"two\"").unwrap();
578        assert_eq!(
579            decoded,
580            LinoValue::object([
581                ("a", LinoValue::Int(1)),
582                ("b", LinoValue::String("two".into()))
583            ])
584        );
585    }
586
587    #[test]
588    fn unterminated_input_is_an_error() {
589        assert!(decode("(\n  a 1\n").is_err());
590        assert!(decode("(\n  a \"unterminated\n").is_err());
591        assert!(decode("a 1)").is_err());
592    }
593}