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