use indexmap::IndexMap;
use nom::{
IResult, Parser,
branch::alt,
bytes::complete::{escaped_transform, is_not, tag, take_till1, take_until, take_while1},
character::complete::{alphanumeric1, char, multispace0, one_of},
combinator::{opt, recognize, value},
error::ParseError,
multi::{many0, separated_list0},
sequence::{delimited, tuple},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Value {
String(String),
Number(f64),
Boolean(bool),
Array(Vec<Value>),
Object(IndexMap<String, Value>),
Null,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ZokoFile {
pub entries: IndexMap<String, Value>,
}
#[derive(Debug, Error)]
pub enum ParseErrorKind {
#[error("Unexpected character: {0}")]
UnexpectedChar(char),
#[error("Unexpected end of input")]
UnexpectedEof,
#[error("Invalid number format: {0}")]
InvalidNumber(String),
#[error("Invalid escape sequence: {0}")]
InvalidEscape(String),
#[error("Expected {expected}, found {found}")]
Expected { expected: String, found: String },
}
impl<I> ParseError<I> for ParseErrorKind {
fn from_error_kind(_input: I, kind: nom::error::ErrorKind) -> Self {
ParseErrorKind::Expected {
expected: format!("{:?}", kind),
found: "unknown".to_string(),
}
}
fn append(_input: I, _kind: nom::error::ErrorKind, other: Self) -> Self {
other
}
}
pub type ParseResult<'a, T> = IResult<&'a str, T, ParseErrorKind>;
fn ws<'a, F, O, E: ParseError<&'a str>>(inner: F) -> impl FnMut(&'a str) -> IResult<&'a str, O, E>
where
F: FnMut(&'a str) -> IResult<&'a str, O, E>,
{
delimited(multispace0, inner, multispace0)
}
fn parse_identifier(input: &str) -> ParseResult<'_, String> {
let (input, ident) = recognize(tuple((
alt((alphanumeric1, tag("_"), tag("-"), tag("@"), tag("/"))),
many0(alt((
alphanumeric1,
tag("_"),
tag("-"),
tag("@"),
tag("/"),
tag("."),
))),
)))
.parse(input)?;
Ok((input, ident.to_string()))
}
fn parse_string_single_quoted(input: &str) -> ParseResult<'_, String> {
let (input, _) = char('\'')(input)?;
let (input, content) = take_until("'")(input)?;
let (input, _) = char('\'')(input)?;
Ok((input, content.to_string()))
}
fn parse_string_double_quoted(input: &str) -> ParseResult<'_, String> {
let (input, _) = char('"')(input)?;
let (input, content) = escaped_transform(
is_not("\"\\"),
'\\',
alt((
value("\n", char('n')),
value("\r", char('r')),
value("\t", char('t')),
value("\\", char('\\')),
value("\"", char('"')),
value("'", char('\'')),
)),
)(input)?;
let (input, _) = char('"')(input)?;
Ok((input, content))
}
fn parse_string_backtick(input: &str) -> ParseResult<'_, String> {
let (input, _) = char('`')(input)?;
let (input, content) = take_until("`")(input)?;
let (input, _) = char('`')(input)?;
let lines: Vec<&str> = content.lines().collect();
if lines.is_empty() {
Ok((input, String::new()))
} else {
let min_whitespace = lines
.iter()
.filter(|line| !line.is_empty())
.map(|line| line.len() - line.trim_start().len())
.min()
.unwrap_or(0);
let stripped: Vec<String> = lines
.iter()
.map(|line| {
if line.is_empty() {
String::new()
} else {
line[min_whitespace..].to_string()
}
})
.collect();
Ok((input, stripped.join("\n")))
}
}
fn parse_string(input: &str) -> ParseResult<'_, String> {
alt((
parse_string_double_quoted,
parse_string_single_quoted,
parse_string_backtick,
))(input)
}
fn parse_number(input: &str) -> ParseResult<'_, Value> {
let (input, num_str) = recognize(tuple((
opt(char('-')),
take_while1(|c: char| c.is_ascii_digit()),
opt(tuple((
char('.'),
take_while1(|c: char| c.is_ascii_digit()),
))),
opt(tuple((
one_of("eE"),
opt(one_of("+-")),
take_while1(|c: char| c.is_ascii_digit()),
))),
)))(input)?;
let num = num_str
.parse::<f64>()
.map_err(|_| nom::Err::Error(ParseErrorKind::InvalidNumber(num_str.to_string())))?;
Ok((input, Value::Number(num)))
}
fn parse_boolean(input: &str) -> ParseResult<'_, Value> {
alt((
value(Value::Boolean(true), tag("true")),
value(Value::Boolean(false), tag("false")),
))(input)
}
fn parse_null(input: &str) -> ParseResult<'_, Value> {
value(Value::Null, tag("null"))(input)
}
fn parse_array(input: &str) -> ParseResult<'_, Value> {
let (input, values) = delimited(
ws(char('[')),
tuple((
separated_list0(ws(char(',')), ws(parse_value)),
opt(ws(char(','))),
)),
ws(char(']')),
)(input)?;
Ok((input, Value::Array(values.0)))
}
fn parse_object_entry(input: &str) -> ParseResult<'_, (String, Value)> {
let (input, key) = ws(parse_identifier)(input)?;
let (input, _) = ws(char(':'))(input)?;
let (input, value) = ws(parse_value)(input)?;
Ok((input, (key, value)))
}
fn parse_object(input: &str) -> ParseResult<'_, Value> {
let (input, entries) = delimited(
ws(char('{')),
tuple((
separated_list0(ws(char(',')), parse_object_entry),
opt(ws(char(','))),
)),
ws(char('}')),
)(input)?;
let mut map = IndexMap::new();
for (k, v) in entries.0 {
map.insert(k, v);
}
Ok((input, Value::Object(map)))
}
fn parse_value(input: &str) -> ParseResult<'_, Value> {
ws(alt((
parse_string.map(Value::String),
parse_number,
parse_boolean,
parse_null,
parse_array,
parse_object,
)))(input)
}
fn parse_comment(input: &str) -> ParseResult<'_, ()> {
alt((
value(
(),
tuple((tag("//"), take_till1(|c| c == '\n'), multispace0)),
),
value(
(),
tuple((tag("/*"), take_until("*/"), tag("*/"), multispace0)),
),
))(input)
}
fn parse_comments(input: &str) -> ParseResult<'_, ()> {
let (input, _) = multispace0(input)?;
many0(parse_comment)(input).map(|(i, _)| (i, ()))
}
fn parse_entry(input: &str) -> ParseResult<'_, (String, Value)> {
let (input, _) = parse_comments(input)?;
let (input, key) = ws(parse_identifier)(input)?;
let (input, _) = ws(char(':'))(input)?;
let (input, value) = ws(parse_value)(input)?;
let (input, _) = parse_comments(input)?;
let (input, _) = opt(ws(char(',')))(input)?;
Ok((input, (key, value)))
}
pub fn parse_zoko(input: &str) -> Result<ZokoFile, ParseErrorKind> {
let (remaining, _) = parse_comments(input).map_err(|e| ParseErrorKind::Expected {
expected: "valid zoko input".to_string(),
found: format!("{:?}", e),
})?;
let (remaining, entries) =
many0(parse_entry)(remaining).map_err(|e| ParseErrorKind::Expected {
expected: "valid zoko entries".to_string(),
found: format!("{:?}", e),
})?;
let (_, _) = parse_comments(remaining).map_err(|e| ParseErrorKind::Expected {
expected: "end of input".to_string(),
found: format!("{:?}", e),
})?;
let mut map = IndexMap::new();
for (k, v) in entries {
map.insert(k, v);
}
Ok(ZokoFile { entries: map })
}
pub fn parse_zoko_to_json(input: &str) -> Result<String, ParseErrorKind> {
let zoko = parse_zoko(input)?;
serde_json::to_string_pretty(&zoko).map_err(|e| ParseErrorKind::Expected {
expected: "valid JSON serialization".to_string(),
found: e.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_parse_simple_object() {
let input = r#"name: "value""#;
let result = parse_zoko(input).unwrap();
assert_eq!(
result.entries.get("name"),
Some(&Value::String("value".to_string()))
);
}
#[test]
fn test_parse_map() {
let input = r#"
map: {
id: "value",
id2: "value2",
}
"#;
let result = parse_zoko(input).unwrap();
let map = result.entries.get("map").unwrap();
if let Value::Object(obj) = map {
assert_eq!(obj.get("id"), Some(&Value::String("value".to_string())));
assert_eq!(obj.get("id2"), Some(&Value::String("value2".to_string())));
} else {
panic!("Expected object");
}
}
#[test]
fn test_parse_array() {
let input = r#"tags: ["Hello", "Zoil"]"#;
let result = parse_zoko(input).unwrap();
let arr = result.entries.get("tags").unwrap();
if let Value::Array(vec) = arr {
assert_eq!(vec.len(), 2);
assert_eq!(vec[0], Value::String("Hello".to_string()));
assert_eq!(vec[1], Value::String("Zoil".to_string()));
} else {
panic!("Expected array");
}
}
#[test]
fn test_parse_comments() {
let input = r#"
// Single line comment
name: "value"
/* Multi line
Comment */
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(
result.entries.get("name"),
Some(&Value::String("value".to_string()))
);
}
#[test]
fn test_parse_complex() {
let input = r#"
name: "@Main/Hello",
channel: "main",
branch: "Production",
status: "Release",
version: 1.0.0,
description: "Hello package for Zoil",
tags: [
"Hello",
"Zoil",
],
website: "https://hello.nel.co",
dependencies: [
"Hola": 1.0.2,
"@German/Hallo": {
channel: "main",
version: "latest",
},
],
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(
result.entries.get("name"),
Some(&Value::String("@Main/Hello".to_string()))
);
assert_eq!(
result.entries.get("channel"),
Some(&Value::String("main".to_string()))
);
assert_eq!(result.entries.get("version"), Some(&Value::Number(1.0)));
}
#[test]
fn test_parse_number_formats() {
let input = r#"
int: 42,
float: 3.14,
negative: -10,
scientific: 1.5e10,
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(result.entries.get("int"), Some(&Value::Number(42.0)));
assert_eq!(result.entries.get("float"), Some(&Value::Number(3.14)));
assert_eq!(result.entries.get("negative"), Some(&Value::Number(-10.0)));
assert_eq!(
result.entries.get("scientific"),
Some(&Value::Number(1.5e10))
);
}
#[test]
fn test_parse_boolean() {
let input = r#"
yes: true,
no: false,
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(result.entries.get("yes"), Some(&Value::Boolean(true)));
assert_eq!(result.entries.get("no"), Some(&Value::Boolean(false)));
}
#[test]
fn test_parse_null() {
let input = r#"value: null"#;
let result = parse_zoko(input).unwrap();
assert_eq!(result.entries.get("value"), Some(&Value::Null));
}
#[test]
fn test_parse_different_string_types() {
let input = r#"
double: "hello",
single: 'world',
backtick: `multiline
string`,
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(
result.entries.get("double"),
Some(&Value::String("hello".to_string()))
);
assert_eq!(
result.entries.get("single"),
Some(&Value::String("world".to_string()))
);
assert_eq!(
result.entries.get("backtick"),
Some(&Value::String("multiline\nstring".to_string()))
);
}
#[test]
fn test_trailing_comma() {
let input = r#"
a: 1,
b: 2,
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(result.entries.len(), 2);
}
#[test]
fn test_to_json() {
let input = r#"name: "test", value: 42"#;
let json = parse_zoko_to_json(input).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["entries"]["name"], "test");
assert_eq!(parsed["entries"]["value"], 42.0);
}
#[test]
fn test_array_with_objects() {
let input = r#"dependencies: [{name: "hola", version: "1.1.0"}, {name: "@german/hallo", version: "latest"}]"#;
let result = parse_zoko(input).unwrap();
let deps = result.entries.get("dependencies").unwrap();
if let Value::Array(vec) = deps {
assert_eq!(vec.len(), 2);
if let Value::Object(obj) = &vec[0] {
assert_eq!(obj.get("name"), Some(&Value::String("hola".to_string())));
assert_eq!(
obj.get("version"),
Some(&Value::String("1.1.0".to_string()))
);
} else {
panic!("Expected object for first dependency");
}
if let Value::Object(obj) = &vec[1] {
assert_eq!(
obj.get("name"),
Some(&Value::String("@german/hallo".to_string()))
);
assert_eq!(
obj.get("version"),
Some(&Value::String("latest".to_string()))
);
} else {
panic!("Expected object for second dependency");
}
} else {
panic!("Expected array");
}
}
#[test]
fn test_json_compatibility() {
let zoko_input = r#"dependencies: [{name: "hola", version: "1.1.0"}, {name: "@german/hallo", version: "latest"}]"#;
let json_output = parse_zoko_to_json(zoko_input).unwrap();
let json_value: serde_json::Value = serde_json::from_str(&json_output).unwrap();
assert!(json_value["entries"]["dependencies"].is_array());
assert_eq!(
json_value["entries"]["dependencies"]
.as_array()
.unwrap()
.len(),
2
);
}
#[test]
fn test_entry_order_preservation() {
let input = r#"first: "value1", second: "value2", third: "value3""#;
let result = parse_zoko(input).unwrap();
let keys: Vec<&String> = result.entries.keys().collect();
assert_eq!(keys, vec!["first", "second", "third"]);
}
#[test]
fn test_complex_file_parsing() {
let input = r#"name: "@Main/Hello",
channel: "main",
branch: "Production",
status: "Release",
version: "1.0.0",
description: "Hello package for Zoil",
tags: ["Hello", "Zoil"],
website: "https://hello.nel.co",
dependencies: [
{name: "Hola", version: "1.0.2"},
{name: "@German/Hallo", channel: "main", version: "latest"},
],
"#;
let result = parse_zoko(input).unwrap();
assert_eq!(result.entries.len(), 9);
assert!(result.entries.contains_key("name"));
assert!(result.entries.contains_key("channel"));
assert!(result.entries.contains_key("branch"));
assert!(result.entries.contains_key("status"));
assert!(result.entries.contains_key("version"));
assert!(result.entries.contains_key("description"));
assert!(result.entries.contains_key("tags"));
assert!(result.entries.contains_key("website"));
assert!(result.entries.contains_key("dependencies"));
}
}