Skip to main content

links_notation/binary/
format.rs

1//! Canonical LiNo text for documents, shared by every protocol.
2//!
3//! `links_notation::format_links` does not quote references that contain
4//! spaces or other delimiters, so it cannot be used to put a decoded binary
5//! message back on the wire. This formatter guarantees that
6//! `parse(format(document)) == document` for every parsed document, which is
7//! what makes the text and binary protocols interchangeable.
8
9use super::error::{BinaryError, BinaryResult};
10use super::mapping::LinoDocument;
11use crate::{parse_lino_to_links, LiNo};
12
13/// Parses LiNo text into a canonical document. Blank input is the empty document.
14pub fn parse_document(text: &str) -> BinaryResult<LinoDocument> {
15    if text
16        .bytes()
17        .all(|byte| matches!(byte, b' ' | b'\t' | b'\r' | b'\n'))
18    {
19        return Ok(Vec::new());
20    }
21    let links =
22        parse_lino_to_links(text).map_err(|error| BinaryError::InvalidLino(error.to_string()))?;
23    Ok(links.into_iter().map(canonical).collect())
24}
25
26/// Converts a parsed link into the canonical model: an unnamed group holding
27/// exactly one reference is that reference, so `a` and `(a)` both parse as
28/// the reference `a`. links-notation (since 0.17) and its C# port keep that
29/// wrapper; removing it gives both ports the same document model.
30pub fn canonical(link: LiNo<String>) -> LiNo<String> {
31    match link {
32        LiNo::Link {
33            id: None,
34            mut values,
35        } if values.len() == 1 && matches!(values[0], LiNo::Ref(_)) => {
36            values.pop().expect("one value")
37        }
38        LiNo::Link { id, values } => LiNo::Link {
39            id,
40            values: values.into_iter().map(canonical).collect(),
41        },
42        reference => reference,
43    }
44}
45
46/// Formats a document as canonical LiNo text, one top-level link per line.
47///
48/// A top-level link without an id and with at least two values is written
49/// without its outer parentheses, the way queries are usually typed:
50/// `() ((1 1))`.
51pub fn format_document(document: &[LiNo<String>]) -> String {
52    document
53        .iter()
54        .map(format_top_level)
55        .collect::<Vec<_>>()
56        .join("\n")
57}
58
59fn format_top_level(link: &LiNo<String>) -> String {
60    match link {
61        LiNo::Link { id: None, values } if values.len() >= 2 => join_values(values),
62        link => format_link(link),
63    }
64}
65
66/// Formats one link as it appears nested inside another link.
67pub fn format_link(link: &LiNo<String>) -> String {
68    match link {
69        LiNo::Ref(reference) => format_reference(reference),
70        LiNo::Link { id: None, values } => match values.as_slice() {
71            // `(a)` parses back as the reference `a`, so a one-reference
72            // link needs a second pair of parentheses.
73            [LiNo::Ref(reference)] => format!("(({}))", format_reference(reference)),
74            values => format!("({})", join_values(values)),
75        },
76        LiNo::Link {
77            id: Some(id),
78            values,
79        } => {
80            if values.is_empty() {
81                format!("({}:)", format_reference(id))
82            } else {
83                format!("({}: {})", format_reference(id), join_values(values))
84            }
85        }
86    }
87}
88
89fn join_values(values: &[LiNo<String>]) -> String {
90    values.iter().map(format_link).collect::<Vec<_>>().join(" ")
91}
92
93/// Quotes a reference when it would not survive parsing as a bare word.
94///
95/// links-notation opens a quoted reference with a run of `N` equal quote
96/// characters, closes it with the next run of exactly `N`, and reads `2N`
97/// quotes inside as `N` literal ones. The opening run is counted greedily, so
98/// the chosen quote must differ from the first character; `N` is one more
99/// than the longest run of that quote inside, and odd, because an even
100/// delimiter run may be read as an empty reference.
101pub fn format_reference(reference: &str) -> String {
102    let needs_quotes = reference.is_empty()
103        || reference.starts_with('#')
104        || reference.chars().any(|character| {
105            character.is_whitespace()
106                || matches!(
107                    character,
108                    '\u{1c}'..='\u{1f}' | '\u{feff}' | '(' | ')' | ':' | '\'' | '"' | '`'
109                )
110        });
111    if !needs_quotes {
112        return reference.to_string();
113    }
114    let first = reference.chars().next();
115    let (quote, count) = ['\'', '"', '`']
116        .into_iter()
117        .filter(|&quote| first != Some(quote))
118        .map(|quote| (quote, (longest_run(reference, quote) + 1) | 1))
119        .min_by_key(|&(_, count)| count)
120        .expect("a reference starts with at most one of three quote characters");
121    let delimiter = quote.to_string().repeat(count);
122    format!("{delimiter}{reference}{delimiter}")
123}
124
125fn longest_run(text: &str, quote: char) -> usize {
126    let (mut longest, mut current) = (0, 0);
127    for character in text.chars() {
128        current = if character == quote { current + 1 } else { 0 };
129        longest = longest.max(current);
130    }
131    longest
132}