Skip to main content

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