use crate::debug::trace;
use crate::{CodecError, LinoValue};
use base64::{Engine, engine::general_purpose::STANDARD as BASE64};
pub const DEFAULT_INDENT: &str = " ";
pub const BASE64_MARKER: &str = "base64";
pub const ESCAPED_MARKER: &str = "escaped";
pub const OBJECT_MARKER: &str = "o";
pub fn encode(value: &LinoValue, indent: &str) -> String {
let mut out = String::new();
write_value(value, indent, 0, &mut out);
out
}
pub fn encode_line(value: &LinoValue) -> String {
let mut out = String::new();
write_line_value(value, &mut out);
out
}
pub fn decode_line(text: &str) -> Result<LinoValue, CodecError> {
let line = text.trim_matches(|c: char| c == '\n' || c == '\r');
if line.contains('\n') || line.contains('\r') {
return Err(CodecError::ParseError(
"a single-line document cannot contain a line break".to_string(),
));
}
decode(line)
}
pub fn decode(text: &str) -> Result<LinoValue, CodecError> {
let tokens = tokenize(text)?;
trace("readable.decode", || format!("{} tokens", tokens.len()));
let mut cursor = Cursor { tokens, pos: 0 };
let rows = cursor.parse_rows(true)?;
if cursor.pos < cursor.tokens.len() {
return Err(CodecError::ParseError(
"unexpected ')' in readable notation".to_string(),
));
}
if rows.len() == 1 && rows[0].len() == 1 {
return node_to_value(&rows[0][0]);
}
rows_to_value(&rows, true, false)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Form {
Indented,
Line,
}
fn write_value(value: &LinoValue, indent: &str, level: usize, out: &mut String) {
match value {
LinoValue::Object(pairs) => {
if pairs.is_empty() {
out.push_str("(\n");
push_indent(indent, level, out);
out.push(')');
return;
}
out.push('(');
for (key, child) in pairs {
out.push('\n');
push_indent(indent, level + 1, out);
out.push_str(&format_key(key, Form::Indented));
out.push(' ');
write_value(child, indent, level + 1, out);
}
out.push('\n');
push_indent(indent, level, out);
out.push(')');
}
LinoValue::Array(items) => {
if items.is_empty() {
out.push_str("()");
return;
}
out.push('(');
for item in items {
out.push('\n');
push_indent(indent, level + 1, out);
write_value(item, indent, level + 1, out);
}
out.push('\n');
push_indent(indent, level, out);
out.push(')');
}
scalar => out.push_str(&format_scalar(scalar, Form::Indented)),
}
}
fn write_line_value(value: &LinoValue, out: &mut String) {
match value {
LinoValue::Object(pairs) => {
if pairs.is_empty() {
out.push('(');
out.push_str(OBJECT_MARKER);
out.push_str(":)");
return;
}
out.push('(');
out.push_str(OBJECT_MARKER);
out.push(':');
for (key, child) in pairs {
out.push_str(" (");
out.push_str(&format_key(key, Form::Line));
out.push(' ');
write_line_value(child, out);
out.push(')');
}
out.push(')');
}
LinoValue::Array(items) => {
out.push('(');
for (index, item) in items.iter().enumerate() {
if index > 0 {
out.push(' ');
}
write_line_value(item, out);
}
out.push(')');
}
scalar => out.push_str(&format_scalar(scalar, Form::Line)),
}
}
fn push_indent(indent: &str, level: usize, out: &mut String) {
for _ in 0..level {
out.push_str(indent);
}
}
fn format_scalar(value: &LinoValue, form: Form) -> String {
match value {
LinoValue::Null => "null".to_string(),
LinoValue::Bool(b) => b.to_string(),
LinoValue::Int(i) => i.to_string(),
LinoValue::Float(f) => format_float(*f),
LinoValue::String(s) => format_string(s, form),
LinoValue::Array(_) | LinoValue::Object(_) => String::new(),
}
}
fn format_float(f: f64) -> String {
if f.is_nan() {
"NaN".to_string()
} else if f.is_infinite() {
if f.is_sign_positive() {
"Infinity".to_string()
} else {
"-Infinity".to_string()
}
} else {
format!("{:?}", f)
}
}
fn format_string(value: &str, form: Form) -> String {
match escape_unwritable(value, form) {
Some(escaped) => format!("({} {})", ESCAPED_MARKER, quote(&escaped)),
None => quote(value),
}
}
fn escape_unwritable(value: &str, form: Form) -> Option<String> {
if !value.chars().any(|c| is_unwritable(c, form)) {
return None;
}
let mut out = String::with_capacity(value.len());
let mut buffer = [0u8; 4];
for c in value.chars() {
if c == '%' || is_unwritable(c, form) {
for byte in c.encode_utf8(&mut buffer).as_bytes() {
out.push('%');
out.push(hex_digit(byte >> 4));
out.push(hex_digit(byte & 0xf));
}
} else {
out.push(c);
}
}
Some(out)
}
fn hex_digit(value: u8) -> char {
char::from_digit(u32::from(value), 16).map_or('0', |digit| digit.to_ascii_uppercase())
}
fn is_unwritable(c: char, form: Form) -> bool {
if !c.is_control() {
return false;
}
match c {
'\t' => false,
'\n' => form == Form::Line,
_ => true,
}
}
fn quote(value: &str) -> String {
if !value.contains('"') {
return format!("\"{}\"", value);
}
if !value.contains('\'') {
return format!("'{}'", value);
}
let delimiter = if value.starts_with('"') { '\'' } else { '"' };
let count = (longest_run(value, delimiter) + 1).max(3);
let run: String = std::iter::repeat_n(delimiter, count).collect();
format!("{run}{value}{run}")
}
fn longest_run(value: &str, c: char) -> usize {
let mut longest = 0;
let mut current = 0;
for candidate in value.chars() {
if candidate == c {
current += 1;
longest = longest.max(current);
} else {
current = 0;
}
}
longest
}
fn format_key(key: &str, form: Form) -> String {
let plain = !key.is_empty()
&& key != BASE64_MARKER
&& key != ESCAPED_MARKER
&& !key.chars().any(|c| {
c.is_whitespace() || c.is_control() || matches!(c, '(' | ')' | '\'' | '"' | ':' | '`')
});
if plain {
key.to_string()
} else {
format_string(key, form)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum Token {
Open,
Close,
Newline,
Ref { value: String, quoted: bool },
}
#[derive(Debug, Clone)]
enum Node {
Ref {
value: String,
quoted: bool,
},
Link {
rows: Vec<Vec<Node>>,
multiline: bool,
object: bool,
},
}
fn tokenize(text: &str) -> Result<Vec<Token>, CodecError> {
let chars: Vec<char> = text.chars().collect();
let mut tokens = Vec::new();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
if c == '\n' {
tokens.push(Token::Newline);
i += 1;
} else if c.is_whitespace() {
i += 1;
} else if c == '(' {
tokens.push(Token::Open);
i += 1;
} else if c == ')' {
tokens.push(Token::Close);
i += 1;
} else if matches!(c, '"' | '\'' | '`') {
let (value, next) = read_quoted(&chars, i, c)?;
tokens.push(Token::Ref {
value,
quoted: true,
});
i = next;
} else {
let start = i;
while i < chars.len()
&& !chars[i].is_whitespace()
&& !matches!(chars[i], '(' | ')' | '"' | '\'' | '`')
{
i += 1;
}
tokens.push(Token::Ref {
value: chars[start..i].iter().collect(),
quoted: false,
});
}
}
Ok(tokens)
}
fn read_quoted(
chars: &[char],
start: usize,
quote_char: char,
) -> Result<(String, usize), CodecError> {
let opening = run_length(chars, start, quote_char);
if opening == 2 {
return Ok((String::new(), start + 2));
}
if opening == 1 {
let mut value = String::new();
let mut i = start + 1;
while i < chars.len() {
if chars[i] == quote_char {
if chars.get(i + 1) == Some("e_char) {
value.push(quote_char);
i += 2;
continue;
}
return Ok((value, i + 1));
}
value.push(chars[i]);
i += 1;
}
return Err(unterminated_quote(start));
}
let mut i = start + opening;
while i < chars.len() {
if chars[i] != quote_char {
i += 1;
continue;
}
let run = run_length(chars, i, quote_char);
if run >= opening {
let value = chars[start + opening..i + run - opening].iter().collect();
return Ok((value, i + run));
}
i += run;
}
Err(unterminated_quote(start))
}
fn run_length(chars: &[char], start: usize, c: char) -> usize {
chars[start..].iter().take_while(|&&x| x == c).count()
}
fn unterminated_quote(start: usize) -> CodecError {
CodecError::ParseError(format!(
"unterminated quoted value starting at character {}",
start
))
}
struct Cursor {
tokens: Vec<Token>,
pos: usize,
}
impl Cursor {
fn parse_rows(&mut self, top_level: bool) -> Result<Vec<Vec<Node>>, CodecError> {
let mut rows: Vec<Vec<Node>> = Vec::new();
let mut row: Vec<Node> = Vec::new();
while self.pos < self.tokens.len() {
match &self.tokens[self.pos] {
Token::Close => {
if top_level {
break;
}
self.pos += 1;
if !row.is_empty() {
rows.push(row);
}
return Ok(rows);
}
Token::Newline => {
self.pos += 1;
if !row.is_empty() {
rows.push(std::mem::take(&mut row));
}
}
_ => row.push(self.parse_node()?),
}
}
if !top_level {
return Err(CodecError::ParseError(
"unterminated '(' in readable notation".to_string(),
));
}
if !row.is_empty() {
rows.push(row);
}
Ok(rows)
}
fn parse_node(&mut self) -> Result<Node, CodecError> {
match self.tokens[self.pos].clone() {
Token::Ref { value, quoted } => {
self.pos += 1;
Ok(Node::Ref { value, quoted })
}
Token::Open => {
self.pos += 1;
let object = self.take_object_marker();
let multiline = self.link_is_multiline();
let rows = self.parse_rows(false)?;
Ok(Node::Link {
rows,
multiline,
object,
})
}
Token::Close | Token::Newline => Err(CodecError::ParseError(
"unexpected token in readable notation".to_string(),
)),
}
}
fn take_object_marker(&mut self) -> bool {
let marker = format!("{}:", OBJECT_MARKER);
let is_marker = matches!(
self.tokens.get(self.pos),
Some(Token::Ref { value, quoted: false }) if *value == marker
);
if is_marker {
self.pos += 1;
}
is_marker
}
fn link_is_multiline(&self) -> bool {
self.tokens[self.pos..]
.iter()
.take_while(|t| **t != Token::Close)
.any(|t| *t == Token::Newline)
}
}
fn node_to_value(node: &Node) -> Result<LinoValue, CodecError> {
match node {
Node::Ref { value, quoted } => Ok(ref_to_value(value, *quoted)),
Node::Link {
rows,
multiline,
object,
} => rows_to_value(rows, *multiline, *object),
}
}
fn rows_to_value(
rows: &[Vec<Node>],
multiline: bool,
object_marker: bool,
) -> Result<LinoValue, CodecError> {
if object_marker {
return marked_object_to_value(rows);
}
if rows.is_empty() {
return Ok(if multiline {
LinoValue::Object(vec![])
} else {
LinoValue::Array(vec![])
});
}
if let Some(marked) = decode_marked_value(rows) {
return marked;
}
if !multiline {
let mut items = Vec::new();
for row in rows {
for node in row {
items.push(node_to_value(node)?);
}
}
return Ok(LinoValue::Array(items));
}
let is_object = rows
.iter()
.all(|row| row.len() == 2 && node_to_key(&row[0]).is_some());
if is_object {
let mut pairs = Vec::with_capacity(rows.len());
for row in rows {
let Some(key) = node_to_key(&row[0]) else {
unreachable!("checked by is_object")
};
pairs.push((key, node_to_value(&row[1])?));
}
return Ok(LinoValue::Object(pairs));
}
let mut items = Vec::new();
for row in rows {
for node in row {
items.push(node_to_value(node)?);
}
}
Ok(LinoValue::Array(items))
}
fn marked_object_to_value(rows: &[Vec<Node>]) -> Result<LinoValue, CodecError> {
let mut pairs = Vec::new();
for node in rows.iter().flatten() {
let Node::Link {
rows: pair,
object: false,
..
} = node
else {
return Err(CodecError::ParseError(format!(
"an object marked '{}:' holds (key value) pairs, found a value that is not a pair",
OBJECT_MARKER
)));
};
let [row] = pair.as_slice() else {
return Err(CodecError::ParseError(format!(
"an object marked '{}:' holds (key value) pairs, found a link of {} lines",
OBJECT_MARKER,
pair.len()
)));
};
let [key_node, value] = row.as_slice() else {
return Err(CodecError::ParseError(format!(
"an object marked '{}:' holds (key value) pairs, found a link of {} values",
OBJECT_MARKER,
row.len()
)));
};
let Some(key) = node_to_key(key_node) else {
return Err(CodecError::ParseError(format!(
"an object marked '{}:' holds (key value) pairs, found a pair whose key is not text",
OBJECT_MARKER
)));
};
pairs.push((key, node_to_value(value)?));
}
Ok(LinoValue::Object(pairs))
}
fn node_to_key(node: &Node) -> Option<String> {
match node {
Node::Ref { value, .. } => Some(value.clone()),
Node::Link {
rows,
object: false,
..
} => match decode_marked_value(rows)? {
Ok(LinoValue::String(key)) => Some(key),
_ => None,
},
Node::Link { .. } => None,
}
}
fn decode_marked_value(rows: &[Vec<Node>]) -> Option<Result<LinoValue, CodecError>> {
if rows.len() != 1 || rows[0].len() != 2 {
return None;
}
let Node::Ref {
value: marker,
quoted: false,
} = &rows[0][0]
else {
return None;
};
let Node::Ref {
value: payload,
quoted: true,
} = &rows[0][1]
else {
return None;
};
if marker == ESCAPED_MARKER {
return Some(unescape(payload).map(LinoValue::String));
}
if marker == BASE64_MARKER {
return Some(
BASE64
.decode(payload)
.map_err(|e| CodecError::DecodeError(format!("invalid base64 value: {}", e)))
.and_then(|bytes| {
String::from_utf8(bytes)
.map(LinoValue::String)
.map_err(|e| CodecError::DecodeError(format!("invalid UTF-8 value: {}", e)))
}),
);
}
None
}
fn unescape(payload: &str) -> Result<String, CodecError> {
let bytes = payload.as_bytes();
let mut out: Vec<u8> = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if bytes[i] != b'%' {
out.push(bytes[i]);
i += 1;
continue;
}
let escape = payload.get(i + 1..i + 3).ok_or_else(|| {
CodecError::DecodeError(format!(
"truncated escape at character {} of an escaped value",
i
))
})?;
let byte = u8::from_str_radix(escape, 16).map_err(|_| {
CodecError::DecodeError(format!("invalid escape '%{}' in an escaped value", escape))
})?;
out.push(byte);
i += 3;
}
String::from_utf8(out)
.map_err(|e| CodecError::DecodeError(format!("invalid UTF-8 escaped value: {}", e)))
}
fn ref_to_value(value: &str, quoted: bool) -> LinoValue {
if quoted {
return LinoValue::String(value.to_string());
}
match value {
"null" => return LinoValue::Null,
"true" => return LinoValue::Bool(true),
"false" => return LinoValue::Bool(false),
"NaN" => return LinoValue::Float(f64::NAN),
"Infinity" => return LinoValue::Float(f64::INFINITY),
"-Infinity" => return LinoValue::Float(f64::NEG_INFINITY),
_ => {}
}
if let Ok(i) = value.parse::<i64>() {
return LinoValue::Int(i);
}
if value.contains(['.', 'e', 'E']) {
if let Ok(f) = value.parse::<f64>() {
return LinoValue::Float(f);
}
}
LinoValue::String(value.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
fn roundtrip(value: &LinoValue) -> LinoValue {
let text = encode(value, DEFAULT_INDENT);
decode(&text).unwrap_or_else(|e| panic!("failed to decode {:?}: {}", text, e))
}
#[test]
fn empty_containers_keep_their_type() {
assert_eq!(encode(&LinoValue::Array(vec![]), DEFAULT_INDENT), "()");
assert_eq!(encode(&LinoValue::Object(vec![]), DEFAULT_INDENT), "(\n)");
assert_eq!(
roundtrip(&LinoValue::Array(vec![])),
LinoValue::Array(vec![])
);
assert_eq!(
roundtrip(&LinoValue::Object(vec![])),
LinoValue::Object(vec![])
);
}
#[test]
fn single_pair_object_is_not_an_array() {
let value = LinoValue::object([("a", LinoValue::Int(1))]);
assert_eq!(encode(&value, DEFAULT_INDENT), "(\n a 1\n)");
assert_eq!(roundtrip(&value), value);
}
#[test]
fn numeric_looking_strings_stay_strings() {
let value = LinoValue::object([
("count", LinoValue::Int(42)),
("zip", LinoValue::String("10001".to_string())),
("flag", LinoValue::String("true".to_string())),
]);
assert_eq!(roundtrip(&value), value);
}
#[test]
fn whole_floats_stay_floats() {
let value = LinoValue::Float(1.0);
assert_eq!(encode(&value, DEFAULT_INDENT), "1.0");
assert!(matches!(roundtrip(&value), LinoValue::Float(f) if (f - 1.0).abs() < f64::EPSILON));
}
#[test]
fn base64_key_is_quoted_so_it_is_not_a_marker() {
let value = LinoValue::object([("base64", LinoValue::String("plain".to_string()))]);
assert_eq!(
encode(&value, DEFAULT_INDENT),
"(\n \"base64\" \"plain\"\n)"
);
assert_eq!(roundtrip(&value), value);
}
#[test]
fn text_is_written_as_text_and_only_the_rest_is_escaped() {
let value = LinoValue::object([
("plain", LinoValue::String("visible".to_string())),
("raw", LinoValue::String("line1\nline2".to_string())),
("returned", LinoValue::String("line1\r\nline2".to_string())),
]);
let text = encode(&value, DEFAULT_INDENT);
assert!(text.contains("plain \"visible\""), "{}", text);
assert!(text.contains("raw \"line1\nline2\""), "{}", text);
assert!(
text.contains("returned (escaped \"line1%0D\nline2\")"),
"{}",
text
);
assert!(!text.contains("base64"), "{}", text);
assert_eq!(roundtrip(&value), value);
}
#[test]
fn every_written_document_parses_as_links_notation() {
let texts = [
"plain",
"it's",
"he said \"hello\"",
"both \"kinds\" of 'quotes'",
"a\"\"b'c",
"a\"\"\"b'c",
"trailing quote\"'",
"'\"",
"line one\nline two",
"a\tb",
"null\u{0}byte",
"unicode: 你好世界 🌍",
];
for text in texts {
let value = LinoValue::object([
("message", LinoValue::String(text.to_string())),
("level", LinoValue::String("info".to_string())),
]);
for document in [encode(&value, DEFAULT_INDENT), encode_line(&value)] {
assert!(
links_notation::parse_lino(&document).is_ok(),
"links-notation rejected {:?}",
document
);
}
}
}
#[test]
fn links_notation_reads_back_the_text_that_was_written() {
for text in [
"he said \"hello\"",
"both \"kinds\" of 'quotes'",
"a\"\"b'c",
"line one\nline two",
] {
let document = encode(
&LinoValue::object([("message", LinoValue::String(text.to_string()))]),
DEFAULT_INDENT,
);
let parsed = links_notation::parse_lino(&document)
.unwrap_or_else(|e| panic!("links-notation rejected {:?}: {}", document, e));
let mut refs = Vec::new();
collect_refs(&parsed, &mut refs);
assert!(
refs.contains(&text.to_string()),
"links-notation read {:?} out of {:?}",
refs,
document
);
}
}
fn collect_refs(node: &links_notation::LiNo<String>, out: &mut Vec<String>) {
match node {
links_notation::LiNo::Ref(value) => out.push(value.clone()),
links_notation::LiNo::Link { values, .. } => {
for value in values {
collect_refs(value, out);
}
}
}
}
#[test]
fn custom_indent_is_used() {
let value = LinoValue::object([("a", LinoValue::Int(1))]);
assert_eq!(encode(&value, " "), "(\n a 1\n)");
}
#[test]
fn handwritten_document_without_root_parentheses_decodes() {
let decoded = decode("a 1\nb \"two\"").unwrap();
assert_eq!(
decoded,
LinoValue::object([
("a", LinoValue::Int(1)),
("b", LinoValue::String("two".into()))
])
);
}
fn line_roundtrip(value: &LinoValue) -> LinoValue {
let text = encode_line(value);
assert!(
!text.contains('\n'),
"line form must hold no newline: {:?}",
text
);
decode_line(&text).unwrap_or_else(|e| panic!("failed to decode {:?}: {}", text, e))
}
#[test]
fn line_form_marks_objects_and_leaves_arrays_bare() {
assert_eq!(encode_line(&LinoValue::Array(vec![])), "()");
assert_eq!(encode_line(&LinoValue::Object(vec![])), "(o:)");
assert_eq!(
encode_line(&LinoValue::object([("a", LinoValue::Int(1))])),
"(o: (a 1))"
);
assert_eq!(
encode_line(&LinoValue::array([
LinoValue::String("key".into()),
LinoValue::String("value".into()),
])),
"(\"key\" \"value\")"
);
}
#[test]
fn line_form_tells_a_one_pair_object_from_a_two_element_array() {
let object = LinoValue::object([("key", LinoValue::String("value".into()))]);
let array = LinoValue::array([
LinoValue::String("key".into()),
LinoValue::String("value".into()),
]);
assert_ne!(encode_line(&object), encode_line(&array));
assert_eq!(line_roundtrip(&object), object);
assert_eq!(line_roundtrip(&array), array);
}
#[test]
fn line_form_roundtrips_nested_and_empty_containers() {
let value = LinoValue::object([
("empty_array", LinoValue::Array(vec![])),
("empty_object", LinoValue::Object(vec![])),
(
"records",
LinoValue::array([
LinoValue::object([("id", LinoValue::Int(1))]),
LinoValue::object([("id", LinoValue::Int(2))]),
]),
),
]);
assert_eq!(
encode_line(&value),
"(o: (empty_array ()) (empty_object (o:)) (records ((o: (id 1)) (o: (id 2)))))"
);
assert_eq!(line_roundtrip(&value), value);
}
#[test]
fn line_form_keeps_the_empty_key() {
let value = LinoValue::object([("", LinoValue::Int(2))]);
assert_eq!(encode_line(&value), "(o: (\"\" 2))");
assert_eq!(line_roundtrip(&value), value);
}
#[test]
fn line_form_keeps_strings_on_one_line() {
let value = LinoValue::object([
(
"quotes",
LinoValue::String("both \"kinds\" of 'quotes'".into()),
),
("unicode", LinoValue::String("héllo 世界 🌍".into())),
("multiline", LinoValue::String("line1\nline2".into())),
]);
let text = encode_line(&value);
assert!(!text.contains('\n'), "{}", text);
assert_eq!(line_roundtrip(&value), value);
}
#[test]
fn a_line_form_object_holds_pairs_only() {
assert!(decode_line("(o: 1 2)").is_err());
assert!(decode_line("(o: (a 1 2))").is_err());
assert!(decode_line("(o: (a))").is_err());
}
#[test]
fn decode_line_rejects_a_document_of_several_lines() {
assert!(decode_line("(o: (a 1))\n(o: (a 2))").is_err());
assert_eq!(
decode_line("(o: (a 1))\n").unwrap(),
LinoValue::object([("a", LinoValue::Int(1))])
);
}
#[test]
fn unterminated_input_is_an_error() {
assert!(decode("(\n a 1\n").is_err());
assert!(decode("(\n a \"unterminated\n").is_err());
assert!(decode("(\n a \"\"\"unterminated\n").is_err());
assert!(decode("a 1)").is_err());
}
#[test]
fn a_run_of_delimiters_says_how_the_value_is_read() {
assert_eq!(
decode("\"both \"\"kinds\"\" of 'quotes'\"").unwrap(),
LinoValue::String("both \"kinds\" of 'quotes'".to_string())
);
assert_eq!(decode("\"\"").unwrap(), LinoValue::String(String::new()));
assert_eq!(
decode("\"\"\"say \"hi\"\"\"\"").unwrap(),
LinoValue::String("say \"hi\"".to_string())
);
}
}