use crate::diagnostic::{DwCode, ExitTier};
use std::cmp::Ordering;
use std::path::{Path, PathBuf};
pub const INDENT: usize = 2;
#[derive(Debug, Clone, PartialEq)]
pub enum Node {
Null,
Bool(bool),
Number(String),
Str(String),
Array(Vec<Node>),
Object(Vec<(String, Node)>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct ParseError {
pub code: DwCode,
pub line: usize,
pub col: usize,
pub message: String,
}
impl std::fmt::Display for ParseError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}:{}: {}", self.line, self.col, self.message)
}
}
crate::dw_code! {
pub const DW_FMT_PARSE: DwCode = DwCode::new("DW0770", ExitTier::Build);
}
crate::dw_code! {
pub const DW_FMT_DUPLICATE_KEY: DwCode = DwCode::new("DW0771", ExitTier::Build);
}
crate::dw_code! {
pub const DW_FMT_NOT_EQUIVALENT: DwCode = DwCode::new("DW0772", ExitTier::Build);
}
crate::dw_code! {
pub const DW_FMT_UNFORMATTED: DwCode = DwCode::new("DW0773", ExitTier::Build);
}
crate::dw_code! {
pub const DW_FMT_NO_BINDING: DwCode = DwCode::new("DW0774", ExitTier::Build);
}
struct Parser<'a> {
src: &'a [u8],
pos: usize,
line: usize,
col: usize,
}
impl<'a> Parser<'a> {
fn new(src: &'a str) -> Self {
Parser {
src: src.as_bytes(),
pos: 0,
line: 1,
col: 1,
}
}
fn err(&self, code: DwCode, message: impl Into<String>) -> ParseError {
ParseError {
code,
line: self.line,
col: self.col,
message: message.into(),
}
}
fn peek(&self) -> Option<u8> {
self.src.get(self.pos).copied()
}
fn bump(&mut self) -> Option<u8> {
let b = self.src.get(self.pos).copied()?;
self.pos += 1;
if b == b'\n' {
self.line += 1;
self.col = 1;
} else if b & 0xC0 != 0x80 {
self.col += 1;
}
Some(b)
}
fn skip_ws(&mut self) {
while matches!(self.peek(), Some(b' ' | b'\t' | b'\n' | b'\r')) {
self.bump();
}
}
fn expect(&mut self, want: u8) -> Result<(), ParseError> {
match self.peek() {
Some(b) if b == want => {
self.bump();
Ok(())
}
Some(b) => Err(self.err(
DW_FMT_PARSE,
format!(
"expected `{}`, found `{}`",
want as char,
escape_for_message(b)
),
)),
None => Err(self.err(
DW_FMT_PARSE,
format!("expected `{}`, found end of file", want as char),
)),
}
}
fn literal(&mut self, word: &str, node: Node) -> Result<Node, ParseError> {
if self.src[self.pos..].starts_with(word.as_bytes()) {
for _ in 0..word.len() {
self.bump();
}
Ok(node)
} else {
Err(self.err(DW_FMT_PARSE, format!("expected `{word}`")))
}
}
fn value(&mut self) -> Result<Node, ParseError> {
self.skip_ws();
match self.peek() {
Some(b'{') => self.object(),
Some(b'[') => self.array(),
Some(b'"') => Ok(Node::Str(self.string()?)),
Some(b't') => self.literal("true", Node::Bool(true)),
Some(b'f') => self.literal("false", Node::Bool(false)),
Some(b'n') => self.literal("null", Node::Null),
Some(b'-' | b'0'..=b'9') => self.number(),
Some(b) => Err(self.err(
DW_FMT_PARSE,
format!("unexpected `{}`", escape_for_message(b)),
)),
None => Err(self.err(DW_FMT_PARSE, "unexpected end of file")),
}
}
fn object(&mut self) -> Result<Node, ParseError> {
self.expect(b'{')?;
let mut entries: Vec<(String, Node)> = Vec::new();
self.skip_ws();
if self.peek() == Some(b'}') {
self.bump();
return Ok(Node::Object(entries));
}
loop {
self.skip_ws();
let key_line = self.line;
let key_col = self.col;
let key = self.string()?;
if entries.iter().any(|(k, _)| *k == key) {
return Err(ParseError {
code: DW_FMT_DUPLICATE_KEY,
line: key_line,
col: key_col,
message: format!(
"duplicate object key `{key}`. JSON allows it and the compiler's \
parser silently keeps the LAST one, so one of these two values is \
already being discarded without a word. Formatting would make that \
loss permanent and invisible, so `delvec fmt` refuses: delete or \
rename whichever occurrence is wrong."
),
});
}
self.skip_ws();
self.expect(b':')?;
let value = self.value()?;
entries.push((key, value));
self.skip_ws();
match self.peek() {
Some(b',') => {
self.bump();
self.skip_ws();
if self.peek() == Some(b'}') {
return Err(self.err(
DW_FMT_PARSE,
"trailing comma before `}` (JSON has no trailing commas)",
));
}
}
Some(b'}') => {
self.bump();
return Ok(Node::Object(entries));
}
Some(b) => {
return Err(self.err(
DW_FMT_PARSE,
format!("expected `,` or `}}`, found `{}`", escape_for_message(b)),
));
}
None => {
return Err(self.err(DW_FMT_PARSE, "unterminated object"));
}
}
}
}
fn array(&mut self) -> Result<Node, ParseError> {
self.expect(b'[')?;
let mut items = Vec::new();
self.skip_ws();
if self.peek() == Some(b']') {
self.bump();
return Ok(Node::Array(items));
}
loop {
items.push(self.value()?);
self.skip_ws();
match self.peek() {
Some(b',') => {
self.bump();
self.skip_ws();
if self.peek() == Some(b']') {
return Err(self.err(
DW_FMT_PARSE,
"trailing comma before `]` (JSON has no trailing commas)",
));
}
}
Some(b']') => {
self.bump();
return Ok(Node::Array(items));
}
Some(b) => {
return Err(self.err(
DW_FMT_PARSE,
format!("expected `,` or `]`, found `{}`", escape_for_message(b)),
));
}
None => {
return Err(self.err(DW_FMT_PARSE, "unterminated array"));
}
}
}
}
fn number(&mut self) -> Result<Node, ParseError> {
let start = self.pos;
if self.peek() == Some(b'-') {
self.bump();
}
match self.peek() {
Some(b'0') => {
self.bump();
}
Some(b'1'..=b'9') => {
while matches!(self.peek(), Some(b'0'..=b'9')) {
self.bump();
}
}
_ => return Err(self.err(DW_FMT_PARSE, "expected a digit after `-`")),
}
if self.peek() == Some(b'.') {
self.bump();
if !matches!(self.peek(), Some(b'0'..=b'9')) {
return Err(self.err(DW_FMT_PARSE, "expected a digit after `.`"));
}
while matches!(self.peek(), Some(b'0'..=b'9')) {
self.bump();
}
}
if matches!(self.peek(), Some(b'e' | b'E')) {
self.bump();
if matches!(self.peek(), Some(b'+' | b'-')) {
self.bump();
}
if !matches!(self.peek(), Some(b'0'..=b'9')) {
return Err(self.err(DW_FMT_PARSE, "expected a digit in the exponent"));
}
while matches!(self.peek(), Some(b'0'..=b'9')) {
self.bump();
}
}
let raw = std::str::from_utf8(&self.src[start..self.pos])
.expect("a JSON number literal is ASCII")
.to_string();
Ok(Node::Number(raw))
}
fn string(&mut self) -> Result<String, ParseError> {
self.expect(b'"')?;
let mut out = String::new();
loop {
let Some(b) = self.peek() else {
return Err(self.err(DW_FMT_PARSE, "unterminated string"));
};
match b {
b'"' => {
self.bump();
return Ok(out);
}
b'\\' => {
self.bump();
let Some(esc) = self.bump() else {
return Err(self.err(DW_FMT_PARSE, "unterminated escape"));
};
match esc {
b'"' => out.push('"'),
b'\\' => out.push('\\'),
b'/' => out.push('/'),
b'b' => out.push('\u{0008}'),
b'f' => out.push('\u{000c}'),
b'n' => out.push('\n'),
b'r' => out.push('\r'),
b't' => out.push('\t'),
b'u' => {
let hi = self.hex4()?;
let ch = if (0xD800..0xDC00).contains(&hi) {
if self.peek() != Some(b'\\') {
return Err(self.err(
DW_FMT_PARSE,
"lone high surrogate: `\\uD800`–`\\uDBFF` must be \
followed by a low surrogate escape",
));
}
self.bump();
if self.peek() != Some(b'u') {
return Err(self.err(
DW_FMT_PARSE,
"lone high surrogate: expected `\\u` low surrogate",
));
}
self.bump();
let lo = self.hex4()?;
if !(0xDC00..0xE000).contains(&lo) {
return Err(self.err(
DW_FMT_PARSE,
"high surrogate not followed by a low surrogate",
));
}
let cp = 0x1_0000u32
+ ((hi as u32 - 0xD800) << 10)
+ (lo as u32 - 0xDC00);
char::from_u32(cp)
.ok_or_else(|| self.err(DW_FMT_PARSE, "invalid code point"))?
} else if (0xDC00..0xE000).contains(&hi) {
return Err(self.err(
DW_FMT_PARSE,
"lone low surrogate escape (`\\uDC00`–`\\uDFFF`)",
));
} else {
char::from_u32(hi as u32)
.ok_or_else(|| self.err(DW_FMT_PARSE, "invalid code point"))?
};
out.push(ch);
}
other => {
return Err(self.err(
DW_FMT_PARSE,
format!("invalid escape `\\{}`", escape_for_message(other)),
));
}
}
}
0x00..=0x1F => {
return Err(self.err(
DW_FMT_PARSE,
format!("raw control character U+{b:04X} in a string; escape it"),
));
}
_ => {
let start = self.pos;
self.bump();
while matches!(self.peek(), Some(c) if c & 0xC0 == 0x80) {
self.bump();
}
out.push_str(
std::str::from_utf8(&self.src[start..self.pos])
.expect("the source was a &str"),
);
}
}
}
}
fn hex4(&mut self) -> Result<u16, ParseError> {
let mut v: u16 = 0;
for _ in 0..4 {
let Some(b) = self.bump() else {
return Err(self.err(DW_FMT_PARSE, "truncated `\\u` escape"));
};
let d = match b {
b'0'..=b'9' => b - b'0',
b'a'..=b'f' => b - b'a' + 10,
b'A'..=b'F' => b - b'A' + 10,
_ => {
return Err(self.err(
DW_FMT_PARSE,
format!(
"`\\u` escape needs 4 hex digits, found `{}`",
escape_for_message(b)
),
));
}
};
v = v * 16 + d as u16;
}
Ok(v)
}
}
fn escape_for_message(b: u8) -> String {
if (0x20..0x7F).contains(&b) {
(b as char).to_string()
} else {
format!("\\x{b:02x}")
}
}
pub fn parse(text: &str) -> Result<Node, ParseError> {
let mut p = Parser::new(text);
if p.src.starts_with(&[0xEF, 0xBB, 0xBF]) {
return Err(p.err(
DW_FMT_PARSE,
"file starts with a UTF-8 BOM; Delvewright JSON is plain UTF-8 with no BOM",
));
}
let node = p.value()?;
p.skip_ws();
if p.pos != p.src.len() {
return Err(p.err(DW_FMT_PARSE, "trailing content after the top-level value"));
}
Ok(node)
}
pub fn canonical(node: &Node) -> String {
let mut out = String::new();
write_node(node, 0, &mut out);
out.push('\n');
out
}
fn write_node(node: &Node, depth: usize, out: &mut String) {
match node {
Node::Null => out.push_str("null"),
Node::Bool(true) => out.push_str("true"),
Node::Bool(false) => out.push_str("false"),
Node::Number(raw) => out.push_str(raw),
Node::Str(s) => write_string(s, out),
Node::Array(items) => {
if items.is_empty() {
out.push_str("[]");
return;
}
out.push_str("[\n");
for (i, item) in items.iter().enumerate() {
indent(depth + 1, out);
write_node(item, depth + 1, out);
if i + 1 < items.len() {
out.push(',');
}
out.push('\n');
}
indent(depth, out);
out.push(']');
}
Node::Object(entries) => {
if entries.is_empty() {
out.push_str("{}");
return;
}
let mut sorted: Vec<&(String, Node)> = entries.iter().collect();
sorted.sort_by(|a, b| cmp_key(&a.0, &b.0));
out.push_str("{\n");
for (i, (key, value)) in sorted.iter().enumerate() {
indent(depth + 1, out);
write_string(key, out);
out.push_str(": ");
write_node(value, depth + 1, out);
if i + 1 < sorted.len() {
out.push(',');
}
out.push('\n');
}
indent(depth, out);
out.push('}');
}
}
}
fn cmp_key(a: &str, b: &str) -> Ordering {
a.cmp(b)
}
fn indent(depth: usize, out: &mut String) {
for _ in 0..depth * INDENT {
out.push(' ');
}
}
fn write_string(s: &str, out: &mut String) {
out.push('"');
for ch in s.chars() {
match ch {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{0008}' => out.push_str("\\b"),
'\u{000c}' => out.push_str("\\f"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if (c as u32) < 0x20 => {
use std::fmt::Write as _;
let _ = write!(out, "\\u{:04x}", c as u32);
}
c => out.push(c),
}
}
out.push('"');
}
pub fn equivalent(before: &Node, after: &Node) -> Result<(), String> {
fn walk(a: &Node, b: &Node, path: &str) -> Result<(), String> {
match (a, b) {
(Node::Null, Node::Null) => Ok(()),
(Node::Bool(x), Node::Bool(y)) if x == y => Ok(()),
(Node::Number(x), Node::Number(y)) if x == y => Ok(()),
(Node::Str(x), Node::Str(y)) if x == y => Ok(()),
(Node::Array(x), Node::Array(y)) => {
if x.len() != y.len() {
return Err(format!(
"{path}: array length changed ({} → {})",
x.len(),
y.len()
));
}
for (i, (xi, yi)) in x.iter().zip(y.iter()).enumerate() {
walk(xi, yi, &format!("{path}/{i}"))?;
}
Ok(())
}
(Node::Object(x), Node::Object(y)) => {
let mut xs: Vec<&(String, Node)> = x.iter().collect();
let mut ys: Vec<&(String, Node)> = y.iter().collect();
xs.sort_by(|p, q| cmp_key(&p.0, &q.0));
ys.sort_by(|p, q| cmp_key(&p.0, &q.0));
if xs.len() != ys.len() {
return Err(format!(
"{path}: object key count changed ({} → {})",
xs.len(),
ys.len()
));
}
for (xe, ye) in xs.iter().zip(ys.iter()) {
if xe.0 != ye.0 {
return Err(format!("{path}: key `{}` became `{}`", xe.0, ye.0));
}
walk(&xe.1, &ye.1, &format!("{path}/{}", xe.0))?;
}
Ok(())
}
_ => Err(format!("{path}: value kind or content changed")),
}
}
walk(before, after, "")
}
pub fn format_text(text: &str) -> Result<String, ParseError> {
format_with(text, canonical)
}
fn format_with(text: &str, render: impl Fn(&Node) -> String) -> Result<String, ParseError> {
let mut before = parse(text)?;
stamp_version(&mut before);
let out = render(&before);
let after = parse(&out).map_err(|e| ParseError {
code: DW_FMT_NOT_EQUIVALENT,
line: e.line,
col: e.col,
message: format!(
"the formatter emitted JSON it cannot itself parse: {}",
e.message
),
})?;
if let Err(why) = equivalent(&before, &after) {
return Err(ParseError {
code: DW_FMT_NOT_EQUIVALENT,
line: 1,
col: 1,
message: format!(
"internal error: formatting would change what this document means \
({why}). Nothing was written. This is a compiler bug — arrays are \
ordered and must never be reordered; please report it."
),
});
}
Ok(out)
}
fn stamp_version(node: &mut Node) {
if let Node::Object(fields) = node {
for (key, value) in fields.iter_mut() {
if key == "dsl_version"
&& let Node::Str(v) = value
&& v != crate::DSL_VERSION
{
*v = crate::DSL_VERSION.to_string();
}
}
}
}
pub const BUILD_OUTPUT_MARKER: &str = "manifest.json";
pub fn discover(root: &Path) -> std::io::Result<Vec<PathBuf>> {
let mut out = Vec::new();
if root.is_file() {
out.push(root.to_path_buf());
return Ok(out);
}
walk(root, &mut out)?;
Ok(out)
}
fn walk(dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
if dir.join(BUILD_OUTPUT_MARKER).is_file() {
return Ok(());
}
let mut entries: Vec<PathBuf> = std::fs::read_dir(dir)?
.map(|e| e.map(|e| e.path()))
.collect::<Result<_, _>>()?;
entries.sort();
for path in entries {
let name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or_default();
if name.starts_with('.') {
continue;
}
let meta = std::fs::symlink_metadata(&path)?;
if meta.is_dir() {
walk(&path, out)?;
} else if meta.is_file() && path.extension().and_then(|e| e.to_str()) == Some("json") {
out.push(path);
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_formatter_writes_the_one_dsl_version() {
let src = r#"{"dsl_version":"0.2.0","stage":"world","content":{"dsl_version":"0.2.0"}}"#;
let out = format_text(src).unwrap();
let stamped = format!(r#" "dsl_version": "{}","#, crate::DSL_VERSION);
assert!(out.contains(&stamped), "{out}");
assert!(
out.contains(r#" "dsl_version": "0.2.0""#),
"nested content stayed: {out}"
);
let already = format_text(&out).unwrap();
assert_eq!(already, out, "a stamped document is canonical");
}
#[test]
fn object_keys_are_sorted_arrays_are_not() {
let src = r#"{"b":1,"a":[3,1,2],"c":{"z":0,"y":0}}"#;
assert_eq!(
format_text(src).unwrap(),
"{\n \"a\": [\n 3,\n 1,\n 2\n ],\n \"b\": 1,\n \"c\": {\n \"y\": 0,\n \"z\": 0\n }\n}\n"
);
}
#[test]
fn number_literals_survive_verbatim() {
let src = r#"{"big":9007199254740993,"exact":1.50,"exp":1e3,"neg":-0.0}"#;
let out = format_text(src).unwrap();
assert!(out.contains("9007199254740993"), "{out}");
assert!(out.contains("1.50"), "{out}");
assert!(out.contains("1e3"), "{out}");
assert!(out.contains("-0.0"), "{out}");
}
#[test]
fn non_ascii_is_never_escaped_and_escapes_are_decoded() {
let src = r#"{"zh":"洞中公羊","emoji":"😀"}"#;
let out = format_text(src).unwrap();
assert!(out.contains("洞中公羊"), "{out}");
assert!(out.contains('\u{1F600}'), "{out}");
assert!(!out.contains("\\u"), "{out}");
}
#[test]
fn control_characters_keep_their_shortest_escape() {
let src = "{\"a\":\"x\\ny\\tz\\u0001\"}";
let out = format_text(src).unwrap();
assert_eq!(out, "{\n \"a\": \"x\\ny\\tz\\u0001\"\n}\n");
}
#[test]
fn idempotent() {
let src = r#"{"b":[{"q":1,"p":[2,1]}],"a":"é"}"#;
let once = format_text(src).unwrap();
assert_eq!(format_text(&once).unwrap(), once);
}
#[test]
fn duplicate_key_is_refused() {
let e = format_text(r#"{"a":1,"a":2}"#).unwrap_err();
assert_eq!(e.code, "DW0771");
}
#[test]
fn syntax_errors_are_located() {
let e = format_text("{\n \"a\": 1,\n}\n").unwrap_err();
assert_eq!(e.code, "DW0770");
assert_eq!(e.line, 3);
}
#[test]
fn empty_containers_stay_on_one_line() {
assert_eq!(
format_text(r#"{"a":[],"b":{}}"#).unwrap(),
"{\n \"a\": [],\n \"b\": {}\n}\n"
);
}
#[test]
fn the_guard_catches_a_renderer_that_sorts_arrays() {
fn sorting_render(node: &Node) -> String {
fn sabotage(n: &Node) -> Node {
match n {
Node::Array(items) => {
let mut v: Vec<Node> = items.iter().map(sabotage).collect();
v.sort_by_key(|n| match n {
Node::Number(raw) => raw.clone(),
Node::Str(s) => s.clone(),
_ => String::new(),
});
Node::Array(v)
}
Node::Object(e) => {
Node::Object(e.iter().map(|(k, v)| (k.clone(), sabotage(v))).collect())
}
other => other.clone(),
}
}
canonical(&sabotage(node))
}
let src = r#"{"objectives":["3","1","2"]}"#;
assert!(format_with(src, canonical).is_ok());
let e = format_with(src, sorting_render).unwrap_err();
assert_eq!(e.code, "DW0772");
assert!(e.message.contains("/objectives/0"), "{}", e.message);
}
#[test]
fn equivalent_catches_a_reordered_array() {
let a = parse("[1,2,3]").unwrap();
let b = parse("[3,2,1]").unwrap();
assert!(equivalent(&a, &b).is_err());
assert!(equivalent(&a, &a).is_ok());
}
}