use std::path::Path;
pub struct Leaf {
pub path: String,
pub span: std::ops::Range<usize>,
pub text: String,
}
pub fn scan_ron_leaves(text: &str) -> Result<Vec<Leaf>, String> {
let b = text.as_bytes();
let mut i = 0usize;
let mut out = Vec::new();
fn skip(b: &[u8], mut i: usize) -> usize {
loop {
while i < b.len() && (b[i] as char).is_whitespace() {
i += 1;
}
if i + 1 < b.len() && b[i] == b'/' && b[i + 1] == b'/' {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
continue;
}
if i + 1 < b.len() && b[i] == b'/' && b[i + 1] == b'*' {
i += 2;
while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') {
i += 1;
}
i = (i + 2).min(b.len());
continue;
}
return i;
}
}
fn ident(b: &[u8], mut i: usize) -> (usize, String) {
let s = i;
while i < b.len() && ((b[i] as char).is_alphanumeric() || b[i] == b'_') {
i += 1;
}
(i, String::from_utf8_lossy(&b[s..i]).into_owned())
}
fn value(
b: &[u8],
text: &str,
mut i: usize,
path: &str,
out: &mut Vec<Leaf>,
depth: usize,
) -> Result<usize, String> {
if depth > 32 {
return Err("ron: value nested deeper than 32 — refusing".to_string());
}
i = skip(b, i);
if i >= b.len() {
return Err(format!("ron: unexpected end of input at `{path}`"));
}
if (b[i] as char).is_alphabetic() {
let (after, id) = ident(b, i);
let j = skip(b, after);
if id == "Some" && j < b.len() && b[j] == b'(' {
i = value(b, text, j + 1, path, out, depth + 1)?;
i = skip(b, i);
if i >= b.len() || b[i] != b')' {
return Err(format!("ron: unclosed `Some(` at `{path}`"));
}
return Ok(i + 1);
}
if j < b.len() && b[j] == b'(' {
return strukt(b, text, j + 1, path, out, depth + 1);
}
out.push(Leaf {
path: path.to_string(),
span: i..after,
text: id,
});
return Ok(after);
}
match b[i] {
b'(' => strukt(b, text, i + 1, path, out, depth + 1),
b'[' => {
let mut n = 0usize;
i += 1;
loop {
i = skip(b, i);
if i >= b.len() {
return Err(format!("ron: unclosed `[` at `{path}`"));
}
if b[i] == b']' {
return Ok(i + 1);
}
i = value(b, text, i, &format!("{path}[{n}]"), out, depth + 1)?;
n += 1;
i = skip(b, i);
if i < b.len() && b[i] == b',' {
i += 1;
}
}
}
b'"' => {
let s = i;
i += 1;
while i < b.len() && b[i] != b'"' {
if b[i] == b'\\' {
i += 1;
}
i += 1;
}
i = (i + 1).min(b.len());
out.push(Leaf {
path: path.to_string(),
span: s..i,
text: text[s..i].to_string(),
});
Ok(i)
}
_ => {
let s = i;
while i < b.len() && !matches!(b[i], b',' | b')' | b']' | b'\n') {
i += 1;
}
let raw = text[s..i].trim_end();
out.push(Leaf {
path: path.to_string(),
span: s..s + raw.len(),
text: raw.to_string(),
});
Ok(s + raw.len())
}
}
}
fn strukt(
b: &[u8],
text: &str,
mut i: usize,
path: &str,
out: &mut Vec<Leaf>,
depth: usize,
) -> Result<usize, String> {
let mut tuple_idx = 0usize;
loop {
i = skip(b, i);
if i >= b.len() {
return Err(format!("ron: unclosed `(` at `{path}`"));
}
if b[i] == b')' {
return Ok(i + 1);
}
let mut child = format!("{path}[{tuple_idx}]");
if (b[i] as char).is_alphabetic() || b[i] == b'_' {
let (after, id) = ident(b, i);
let j = skip(b, after);
if j < b.len() && b[j] == b':' {
child = if path.is_empty() {
id
} else {
format!("{path}.{id}")
};
i = j + 1;
} else {
tuple_idx += 1;
}
} else {
tuple_idx += 1;
}
i = value(b, text, i, &child, out, depth + 1)?;
i = skip(b, i);
if i < b.len() && b[i] == b',' {
i += 1;
}
}
}
i = skip(b, i);
if i >= b.len() || b[i] != b'(' {
return Err("ron: block value does not start with `(`".to_string());
}
let end = strukt(b, text, i + 1, "", &mut out, 0)?;
let tail = skip(b, end);
if tail < b.len() {
return Err(format!(
"ron: trailing input after block value: {:?}",
&text[tail..]
));
}
Ok(out)
}
pub fn scalar_eq(a: &str, b: &str) -> bool {
if a == b {
return true;
}
match (ron::from_str::<ron::Value>(a), ron::from_str::<ron::Value>(b)) {
(Ok(ron::Value::Unit), _) | (_, Ok(ron::Value::Unit)) => false,
(Ok(x), Ok(y)) => x == y,
_ => false,
}
}
pub fn find_block_value(text: &str, name: &str) -> Result<std::ops::Range<usize>, String> {
let header = format!("{name}: (");
let mut search = 0usize;
let at = loop {
let rel = text[search..]
.find(&header)
.ok_or_else(|| format!("ron: no `{name}:` block header"))?;
let abs = search + rel;
let line_start = text[..abs].rfind('\n').map(|n| n + 1).unwrap_or(0);
let prefix = &text[line_start..abs];
let in_comment = prefix.contains("//");
let boundary = prefix.chars().last().is_none_or(|c| c.is_whitespace());
if !in_comment && boundary {
break abs;
}
search = abs + header.len();
};
let open = at + name.len() + 2; let open = text[open..]
.find('(')
.map(|o| open + o)
.ok_or("ron: malformed block header")?;
let tail = &text[open..];
let mut depth = 0usize;
let b = tail.as_bytes();
let (mut i, mut in_str, mut in_line_comment, mut in_block_comment) =
(0usize, false, false, false);
while i < b.len() {
let c = b[i];
if in_line_comment {
if c == b'\n' {
in_line_comment = false;
}
} else if in_block_comment {
if c == b'*' && i + 1 < b.len() && b[i + 1] == b'/' {
in_block_comment = false;
i += 1;
}
} else if in_str {
if c == b'\\' {
i += 1;
} else if c == b'"' {
in_str = false;
}
} else if c == b'/' && i + 1 < b.len() && b[i + 1] == b'/' {
in_line_comment = true;
i += 1;
} else if c == b'/' && i + 1 < b.len() && b[i + 1] == b'*' {
in_block_comment = true;
i += 1;
} else if c == b'"' {
in_str = true;
} else if c == b'(' {
depth += 1;
} else if c == b')' {
depth -= 1;
if depth == 0 {
return Ok(open..open + i + 1);
}
}
i += 1;
}
Err(format!("ron: unbalanced `{name}:` block"))
}
pub struct LineDoc {
lines: Vec<String>,
lists: Vec<ListSpan>,
trailing_newline: bool,
}
struct ListSpan {
key: String,
open: usize,
records: Vec<usize>,
end: usize,
}
#[derive(Clone, Debug)]
pub struct Removed {
pub line: String,
gap: usize,
}
impl LineDoc {
pub fn parse(text: &str, list_keys: &[&str]) -> Result<LineDoc, String> {
let trailing_newline = text.ends_with('\n');
let lines: Vec<String> = text.lines().map(str::to_owned).collect();
let mut lists = Vec::with_capacity(list_keys.len());
for key in list_keys {
let (open, records, end) = scan_list(&lines, key)?;
lists.push(ListSpan {
key: (*key).to_owned(),
open,
records,
end,
});
}
Ok(LineDoc {
lines,
lists,
trailing_newline,
})
}
pub fn render(&self) -> String {
let mut out = self.lines.join("\n");
if self.trailing_newline {
out.push('\n');
}
out
}
pub fn len(&self, key: &str) -> Result<usize, String> {
Ok(self.list(key)?.records.len())
}
pub fn is_empty(&self, key: &str) -> Result<bool, String> {
Ok(self.len(key)? == 0)
}
pub fn line(&self, key: &str, i: usize) -> Result<&str, String> {
let at = self.record_line(key, i)?;
self.lines
.get(at)
.map(String::as_str)
.ok_or_else(|| format!("ron: `{key}` record {i} points past the end of the file"))
}
pub fn set_line(&mut self, key: &str, i: usize, line: String) -> Result<(), String> {
let at = self.record_line(key, i)?;
let slot = self
.lines
.get_mut(at)
.ok_or_else(|| format!("ron: `{key}` record {i} points past the end of the file"))?;
*slot = line;
Ok(())
}
pub fn edit_field(
&mut self,
key: &str,
i: usize,
field: &str,
value: &str,
) -> Result<(), String> {
let at = self.record_line(key, i)?;
let line = self
.lines
.get(at)
.ok_or_else(|| format!("ron: `{key}` record {i} points past the end of the file"))?;
let edited = replace_field(line, field, value)?;
self.lines[at] = edited;
Ok(())
}
pub fn remove(&mut self, key: &str, i: usize) -> Result<Removed, String> {
let at = self.record_line(key, i)?;
if at >= self.lines.len() {
return Err(format!(
"ron: `{key}` record {i} points past the end of the file"
));
}
let ix = self.list_ix(key)?;
let gap = at.saturating_sub(self.anchor(ix, i));
let line = self.lines.remove(at);
self.lists[ix].records.remove(i);
self.shift_after(at, -1);
Ok(Removed { line, gap })
}
pub fn restore(&mut self, key: &str, i: usize, removed: Removed) -> Result<(), String> {
let ix = self.list_ix(key)?;
if i > self.lists[ix].records.len() {
return Err(format!(
"ron: cannot restore `{key}` record {i}; only {} exist",
self.lists[ix].records.len()
));
}
let at = self
.anchor(ix, i)
.saturating_add(removed.gap)
.min(self.lists[ix].end);
self.lines.insert(at, removed.line);
self.shift_after(at, 1);
self.lists[ix].records.insert(i, at);
Ok(())
}
fn anchor(&self, ix: usize, i: usize) -> usize {
let list = &self.lists[ix];
match i.checked_sub(1).and_then(|p| list.records.get(p)) {
Some(prev) => prev + 1,
None => list.open + 1,
}
}
pub fn append(&mut self, key: &str, line: String) -> Result<usize, String> {
let ix = self.list_ix(key)?;
let at = self.lists[ix].end;
self.lines.insert(at, line);
self.shift_after(at, 1);
self.lists[ix].records.push(at);
Ok(self.lists[ix].records.len() - 1)
}
fn list_ix(&self, key: &str) -> Result<usize, String> {
self.lists
.iter()
.position(|l| l.key == key)
.ok_or_else(|| format!("ron: `{key}` is not a list this document was parsed with"))
}
fn list(&self, key: &str) -> Result<&ListSpan, String> {
self.lists
.iter()
.find(|l| l.key == key)
.ok_or_else(|| format!("ron: `{key}` is not a list this document was parsed with"))
}
fn record_line(&self, key: &str, i: usize) -> Result<usize, String> {
self.list(key)?
.records
.get(i)
.copied()
.ok_or_else(|| format!("ron: no `{key}` record at index {i}"))
}
fn shift_after(&mut self, at: usize, delta: isize) {
for list in &mut self.lists {
for ix in &mut list.records {
if *ix >= at {
*ix = ix.saturating_add_signed(delta);
}
}
if list.end >= at {
list.end = list.end.saturating_add_signed(delta);
}
if list.open >= at {
list.open = list.open.saturating_add_signed(delta);
}
}
}
}
fn scan_list(lines: &[String], key: &str) -> Result<(usize, Vec<usize>, usize), String> {
let header = format!("{key}: [");
let start = lines
.iter()
.position(|l| l.trim_start().starts_with(&header))
.ok_or_else(|| format!("ron: `{key}` list not found"))?;
let mut records = Vec::new();
for (offset, line) in lines.iter().enumerate().skip(start + 1) {
let t = line.trim_start();
if t.starts_with("],") || t == "]" {
return Ok((start, records, offset));
}
if t.starts_with('(') {
records.push(offset);
}
}
Err(format!("ron: `{key}` list is never closed"))
}
pub fn replace_field(line: &str, key: &str, value: &str) -> Result<String, String> {
let needle = format!("{key}:");
let code_len = comment_split(line);
let code = &line[..code_len];
let key_at = code
.find(&needle)
.ok_or_else(|| format!("ron: no `{key}:` field in `{}`", line.trim()))?;
let after_key = key_at + needle.len();
let pad_len = code[after_key..]
.len()
.saturating_sub(code[after_key..].trim_start_matches(' ').len());
let value_start = after_key + pad_len;
let mut depth = 0usize;
let mut value_end = None;
for (ix, ch) in code[value_start..].char_indices() {
match ch {
'(' | '[' => depth += 1,
')' | ']' if depth > 0 => depth -= 1,
')' | ']' if depth == 0 => {
value_end = Some(value_start + ix);
break;
}
',' if depth == 0 => {
value_end = Some(value_start + ix);
break;
}
_ => {}
}
}
let value_end =
value_end.ok_or_else(|| format!("ron: `{key}:` value is not terminated in `{}`", line.trim()))?;
let span = &code[value_start..value_end];
let value_end = value_end - (span.len() - span.trim_end().len());
let mut out = String::with_capacity(line.len() + value.len());
out.push_str(&line[..value_start]);
out.push_str(value);
out.push_str(&line[value_end..]);
Ok(out)
}
fn comment_split(line: &str) -> usize {
let bytes = line.as_bytes();
let mut in_quotes = false;
let mut ix = 0;
while ix < bytes.len() {
match bytes[ix] {
b'\\' if in_quotes => ix += 1,
b'"' => in_quotes = !in_quotes,
b'/' if !in_quotes && bytes.get(ix + 1) == Some(&b'/') => return ix,
_ => {}
}
ix += 1;
}
line.len()
}
pub fn trailing_comment(line: &str) -> Option<&str> {
let at = comment_split(line);
(at < line.len()).then(|| line[at..].trim_end())
}
pub fn fmt_f32(v: f32) -> String {
for places in 1..=6 {
let s = format!("{v:.places$}");
if s.parse::<f32>() == Ok(v) {
return s;
}
}
format!("{v}")
}
pub fn save_atomic(path: &Path, text: &str) -> Result<(), String> {
let parent = path
.parent()
.ok_or_else(|| format!("ron: {} has no parent directory", path.display()))?;
std::fs::create_dir_all(parent).map_err(|e| format!("ron: {}: {e}", parent.display()))?;
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = std::path::PathBuf::from(tmp);
std::fs::write(&tmp, text).map_err(|e| format!("ron: {}: {e}", tmp.display()))?;
std::fs::rename(&tmp, path).map_err(|e| format!("ron: {}: {e}", path.display()))?;
Ok(())
}
pub fn save_atomic_no_replace(path: &Path, text: &str) -> Result<(), String> {
let parent = path
.parent()
.ok_or_else(|| format!("ron: {} has no parent directory", path.display()))?;
std::fs::create_dir_all(parent).map_err(|e| format!("ron: {}: {e}", parent.display()))?;
let mut tmp = path.as_os_str().to_owned();
tmp.push(".tmp");
let tmp = std::path::PathBuf::from(tmp);
std::fs::write(&tmp, text).map_err(|e| format!("ron: {}: {e}", tmp.display()))?;
if let Err(e) = std::fs::hard_link(&tmp, path) {
let _ = std::fs::remove_file(&tmp);
if e.kind() == std::io::ErrorKind::AlreadyExists {
return Err(format!(
"ron: {} already exists, and this write refuses to replace it",
path.display()
));
}
return Err(format!("ron: {}: {e}", path.display()));
}
std::fs::remove_file(&tmp).map_err(|e| format!("ron: {}: {e}", tmp.display()))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
const DOC: &str = "layout: (\n\
\x20 // The props the editor owns.\n\
\x20 props: [\n\
\x20 ( piece: Crate, pos: (41.5, 26.5), yaw: 0.0 ), // by the door\n\
\x20\n\
\x20 ( piece: Stool, pos: ( 1.5, 2.5), yaw: 90.0 ),\n\
\x20 ],\n\
\x20 spawns: [\n\
\x20 ( at: (0.0, 0.0) ),\n\
\x20 ],\n\
)\n";
#[test]
fn a_no_op_parse_and_render_is_byte_identical() {
let doc = LineDoc::parse(DOC, &["props", "spawns"]).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(doc.render(), DOC);
}
#[test]
fn comment_lines_and_blanks_are_not_records() {
let doc = LineDoc::parse(DOC, &["props", "spawns"]).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(doc.len("props"), Ok(2));
assert_eq!(doc.len("spawns"), Ok(1));
}
#[test]
fn editing_a_field_changes_exactly_one_line() {
let mut doc = LineDoc::parse(DOC, &["props", "spawns"]).unwrap_or_else(|e| panic!("{e}"));
doc.edit_field("props", 0, "pos", "(10.0, 11.0)")
.unwrap_or_else(|e| panic!("{e}"));
let out = doc.render();
let changed: Vec<_> = DOC
.lines()
.zip(out.lines())
.filter(|(a, b)| a != b)
.collect();
assert_eq!(changed.len(), 1, "expected one changed line, got {changed:?}");
assert!(
out.contains("( piece: Crate, pos: (10.0, 11.0), yaw: 0.0 ), // by the door"),
"alignment or comment was disturbed:\n{out}"
);
}
#[test]
fn padding_before_the_terminator_survives_a_same_width_edit() {
let line = "( piece: Stool, pos: ( 1.5, 2.5), yaw: 90.0 ),";
let out = replace_field(line, "yaw", "45.0").unwrap_or_else(|e| panic!("{e}"));
assert_eq!(out, "( piece: Stool, pos: ( 1.5, 2.5), yaw: 45.0 ),");
}
#[test]
fn the_comment_split_is_quote_aware() {
let line = r#"( label: "RESEARCH // wing", yaw: 0.0 ), // set yaw: later"#;
assert_eq!(trailing_comment(line), Some("// set yaw: later"));
let out = replace_field(line, "yaw", "90.0").unwrap_or_else(|e| panic!("{e}"));
assert_eq!(
out,
r#"( label: "RESEARCH // wing", yaw: 90.0 ), // set yaw: later"#
);
}
#[test]
fn remove_then_restore_is_byte_identical() {
let mut doc = LineDoc::parse(DOC, &["props", "spawns"]).unwrap_or_else(|e| panic!("{e}"));
let removed = doc.remove("props", 0).unwrap_or_else(|e| panic!("{e}"));
assert!(removed.line.contains("// by the door"));
assert_eq!(doc.len("props"), Ok(1));
doc.restore("props", 0, removed)
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(doc.render(), DOC);
}
#[test]
fn restoring_a_record_that_sat_above_a_comment_block_stays_above_it() {
let doc_text = "props: [\n\
\x20 ( piece: Crate, pos: (1.0, 1.0), yaw: 0.0 ),\n\
\x20 // The stools, which are a different matter entirely.\n\
\x20 ( piece: Stool, pos: (2.0, 2.0), yaw: 0.0 ),\n\
],\n";
let mut doc = LineDoc::parse(doc_text, &["props"]).unwrap_or_else(|e| panic!("{e}"));
let removed = doc.remove("props", 0).unwrap_or_else(|e| panic!("{e}"));
doc.restore("props", 0, removed)
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(doc.render(), doc_text);
}
#[test]
fn restoring_the_last_record_lands_below_its_own_comment() {
let doc_text = "props: [\n\
\x20 ( piece: Crate, pos: (1.0, 1.0), yaw: 0.0 ),\n\
\x20 // And one stool.\n\
\x20 ( piece: Stool, pos: (2.0, 2.0), yaw: 0.0 ),\n\
],\n";
let mut doc = LineDoc::parse(doc_text, &["props"]).unwrap_or_else(|e| panic!("{e}"));
let removed = doc.remove("props", 1).unwrap_or_else(|e| panic!("{e}"));
doc.restore("props", 1, removed)
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(doc.render(), doc_text);
}
#[test]
fn deleting_from_one_list_keeps_the_others_pointing_at_the_right_lines() {
let mut doc = LineDoc::parse(DOC, &["props", "spawns"]).unwrap_or_else(|e| panic!("{e}"));
let before = doc.line("spawns", 0).map(str::to_owned);
doc.remove("props", 1).unwrap_or_else(|e| panic!("{e}"));
assert_eq!(doc.line("spawns", 0).map(str::to_owned), before);
}
#[test]
fn appending_lands_inside_the_list_and_renders() {
let mut doc = LineDoc::parse(DOC, &["props", "spawns"]).unwrap_or_else(|e| panic!("{e}"));
let i = doc
.append("props", " ( piece: Bench, pos: (0.0, 0.0), yaw: 0.0 ),".to_owned())
.unwrap_or_else(|e| panic!("{e}"));
assert_eq!(i, 2);
assert_eq!(doc.len("props"), Ok(3));
let out = doc.render();
let bench = out.find("Bench").unwrap_or_else(|| panic!("appended line missing"));
let spawns = out.find("spawns: [").unwrap_or_else(|| panic!("spawns list missing"));
assert!(bench < spawns, "appended record escaped the props list:\n{out}");
assert_eq!(doc.line("spawns", 0).map(|l| l.contains("at:")), Ok(true));
}
#[test]
fn an_unclosed_list_is_refused_rather_than_guessed() {
let bad = "props: [\n ( piece: Crate ),\n";
let err = LineDoc::parse(bad, &["props"]).err().unwrap_or_default();
assert!(err.contains("never closed"), "unhelpful error: {err}");
}
#[test]
fn a_missing_list_names_itself() {
let err = LineDoc::parse(DOC, &["cells"]).err().unwrap_or_default();
assert!(err.contains("cells"), "unhelpful error: {err}");
}
#[test]
fn floats_are_written_at_the_shortest_round_tripping_width() {
assert_eq!(fmt_f32(41.5), "41.5");
assert_eq!(fmt_f32(0.0), "0.0");
assert_eq!(fmt_f32(-1.25), "-1.25");
for v in [0.1f32, 1.0 / 3.0, 1e-6, 12345.678] {
assert_eq!(fmt_f32(v).parse::<f32>(), Ok(v), "{v} did not round-trip");
}
}
#[test]
fn scalars_compare_by_value_not_by_spelling() {
assert!(scalar_eq("0x5C09191", "96506257"));
assert!(scalar_eq("1.0", "1.0"));
assert!(!scalar_eq("1.0", "2.0"));
}
#[test]
fn two_different_enum_variants_are_never_equal() {
assert!(!scalar_eq("Grid", "Hex"));
assert!(!scalar_eq("Chase", "Patrol"));
assert!(scalar_eq("Grid", "Grid"), "a variant still equals itself");
assert!(!scalar_eq("None", "Grid"));
}
#[test]
fn the_leaf_scanner_paths_nested_values_and_skips_comments() {
let text = "( a: 1, // a: 99 is a lie\n b: ( c: 2.5 ), d: [ ( e: \"x\" ) ] )";
let leaves = scan_ron_leaves(text).unwrap_or_else(|e| panic!("{e}"));
let paths: Vec<&str> = leaves.iter().map(|l| l.path.as_str()).collect();
assert_eq!(paths, ["a", "b.c", "d[0].e"]);
let a = &leaves[0];
assert_eq!(&text[a.span.clone()], "1");
}
#[test]
fn a_block_header_inside_a_comment_is_not_the_block() {
let text = "root: (\n // dungeon: ( not this one )\n dungeon: ( w: 6 ),\n)";
let span = find_block_value(text, "dungeon").unwrap_or_else(|e| panic!("{e}"));
assert_eq!(&text[span], "( w: 6 )");
}
}