use crate::{CodecError, LinoValue};
use base64::{engine::general_purpose::STANDARD as BASE64, Engine};
pub const DEFAULT_INDENT: &str = " ";
pub const BASE64_MARKER: &str = "base64";
pub fn encode(value: &LinoValue, indent: &str) -> String {
let mut out = String::new();
write_value(value, indent, 0, &mut out);
out
}
pub fn decode(text: &str) -> Result<LinoValue, CodecError> {
let tokens = tokenize(text)?;
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)
}
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));
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)),
}
}
fn push_indent(indent: &str, level: usize, out: &mut String) {
for _ in 0..level {
out.push_str(indent);
}
}
fn format_scalar(value: &LinoValue) -> 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),
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) -> String {
if needs_encoding(value) {
return format!(
"({} {})",
BASE64_MARKER,
quote(&BASE64.encode(value.as_bytes()))
);
}
quote(value)
}
fn needs_encoding(value: &str) -> bool {
value.chars().any(char::is_control)
}
fn quote(value: &str) -> String {
let has_double = value.contains('"');
let has_single = value.contains('\'');
if !has_double {
return format!("\"{}\"", value);
}
if !has_single {
return format!("'{}'", value);
}
format!("\"{}\"", value.replace('"', "\"\""))
}
fn format_key(key: &str) -> String {
let plain = !key.is_empty()
&& key != BASE64_MARKER
&& !needs_encoding(key)
&& !key
.chars()
.any(|c| c.is_whitespace() || matches!(c, '(' | ')' | '\'' | '"' | ':' | '`'));
if plain {
key.to_string()
} else {
format_string(key)
}
}
#[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,
},
}
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 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;
}
Err(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 multiline = self.link_is_multiline();
let rows = self.parse_rows(false)?;
Ok(Node::Link { rows, multiline })
}
Token::Close | Token::Newline => Err(CodecError::ParseError(
"unexpected token in readable notation".to_string(),
)),
}
}
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 } => rows_to_value(rows, *multiline),
}
}
fn rows_to_value(rows: &[Vec<Node>], multiline: bool) -> Result<LinoValue, CodecError> {
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;
}
let is_object = rows
.iter()
.all(|row| row.len() == 2 && matches!(row[0], Node::Ref { .. }));
if is_object {
let mut pairs = Vec::with_capacity(rows.len());
for row in rows {
let Node::Ref { value: key, .. } = &row[0] else {
unreachable!("checked by is_object")
};
pairs.push((key.clone(), 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 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;
};
if marker != BASE64_MARKER {
return None;
}
let Node::Ref {
value: payload,
quoted: true,
} = &rows[0][1]
else {
return None;
};
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)))
}),
)
}
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 control_characters_are_marked_individually() {
let value = LinoValue::object([
("plain", LinoValue::String("visible".to_string())),
("raw", LinoValue::String("line1\nline2".to_string())),
]);
let text = encode(&value, DEFAULT_INDENT);
assert!(text.contains("plain \"visible\""), "{}", text);
assert!(
text.contains("raw (base64 \"bGluZTEKbGluZTI=\")"),
"{}",
text
);
assert_eq!(roundtrip(&value), value);
}
#[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()))
])
);
}
#[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("a 1)").is_err());
}
}