From bb0f615711413ab38fcd77ab4812fc4ff043fcf9 Mon Sep 17 00:00:00 2001 From: tiye Date: Sun, 3 May 2026 11:58:51 +0800 Subject: [PATCH 1/4] fix: sibling SimpleExprs in block mode stay separate lines with use_inline=true When use_inline=true and a SimpleExpr is block-formatted (starts with newline), subsequent sibling SimpleExprs should also be on their own lines, not appended inline. This prevented (:schema :dynamic) from being merged onto the :doc line. Adds test: sibling_simple_exprs_in_struct_keep_separate_lines_with_use_inline Bumps version to 0.2.6 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/writer.rs | 20 +++++++++++++++++--- tests/writer_test.rs | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 54 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 157fde7..c56576f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "cirru_parser" -version = "0.2.5" +version = "0.2.6" dependencies = [ "criterion", "serde", diff --git a/Cargo.toml b/Cargo.toml index 465eae2..3ece352 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cirru_parser" -version = "0.2.5" +version = "0.2.6" authors = ["jiyinyiyong "] edition = "2024" license = "MIT" diff --git a/src/writer.rs b/src/writer.rs index 1c71d12..d85a5d2 100644 --- a/src/writer.rs +++ b/src/writer.rs @@ -200,6 +200,8 @@ fn generate_tree( let mut prev_kind = WriterNode::Nil; let mut level = base_level; let mut result = String::from(""); + // tracks whether the previously-generated child content was inline (did not start with '\n') + let mut prev_child_inline = true; for (idx, cursor) in xs.iter().enumerate() { let kind = get_node_kind(cursor); @@ -249,9 +251,18 @@ fn generate_tree( ret.push_str(&generate_tree(ys, child_insist_head, options, next_level, false)?); ret } else if options.use_inline && prev_kind == WriterNode::SimpleExpr { - let mut ret = String::from(" "); - ret.push_str(&generate_inline_expr(ys)); - ret + // Only inline when the previous sibling was itself written inline. + // If the previous was block-formatted (started with '\n'), keep this one + // on its own line too so that sibling pairs in a struct/map stay separate. + if prev_child_inline { + let mut ret = String::from(" "); + ret.push_str(&generate_inline_expr(ys)); + ret + } else { + let mut ret = render_newline(next_level); + ret.push_str(&generate_tree(ys, child_insist_head, options, next_level, false)?); + ret + } } else { let mut ret = render_newline(next_level); ret.push_str(&generate_tree(ys, child_insist_head, options, next_level, false)?); @@ -326,6 +337,9 @@ fn generate_tree( level += 1; } + // update prev_child_inline: tracks if the current child was inline (no leading newline) + prev_child_inline = !chunk.starts_with('\n'); + // console.log("chunk", JSON.stringify(chunk)); // console.log("And result", JSON.stringify(result)); } diff --git a/tests/writer_test.rs b/tests/writer_test.rs index 1654941..a39fdf8 100644 --- a/tests/writer_test.rs +++ b/tests/writer_test.rs @@ -115,6 +115,41 @@ mod json_write_test { } } +#[test] +fn sibling_simple_exprs_in_struct_keep_separate_lines_with_use_inline() -> Result<(), String> { + use cirru_parser::{Cirru, CirruWriterOptions, format}; + // When a struct has multiple sibling pairs and the first pair is block-formatted + // (not inlined), subsequent sibling SimpleExprs should also go on separate lines, + // not be appended inline to the previous line. + // Bug: with use_inline=true, `(:schema :dynamic)` was appended to the `:doc` line + // producing `:doc |long doc string (:schema :dynamic)` — a 3-element list that + // cirru_edn parsers reject as an invalid record field pair. + let xs = vec![Cirru::List(vec![ + Cirru::leaf("%{}"), + Cirru::leaf(":CodeEntry"), + Cirru::List(vec![Cirru::leaf(":doc"), Cirru::leaf("|a long doc string")]), + Cirru::List(vec![Cirru::leaf(":schema"), Cirru::leaf(":dynamic")]), + Cirru::List(vec![Cirru::leaf(":code"), Cirru::leaf("stuff")]), + ])]; + + let rendered = format(&xs, CirruWriterOptions { use_inline: true })?; + + // No single line should contain both :doc and :schema tokens + for line in rendered.lines() { + assert!( + !(line.contains(":doc") && line.contains(":schema")), + ":doc and :schema should be on separate lines, but got line: {:?}", + line + ); + } + + // Round-trip: parse the rendered output back and compare to the original tree + let reparsed = cirru_parser::parse(&rendered).expect("rendered output should be valid Cirru"); + assert_eq!(xs, reparsed, "round-trip should preserve structure"); + + Ok(()) +} + #[test] fn leaves_escapeing() { assert_eq!("\"a\"", escape_cirru_leaf("a")); From a26e9c027aa8300fc8ee1acaa92257b1e0994c39 Mon Sep 17 00:00:00 2001 From: tiye Date: Sun, 3 May 2026 12:51:10 +0800 Subject: [PATCH 2/4] a manual fix for new layout of nested expressions; tag 0.2.7 --- Cargo.lock | 2 +- Cargo.toml | 2 +- src/writer.rs | 47 ++++++++------------------- tests/cirru/match.cirru | 2 +- tests/data/match.json | 2 +- tests/writer_cirru/comma-indent.cirru | 5 ++- tests/writer_cirru/indent.cirru | 2 +- tests/writer_cirru/match.cirru | 2 +- tests/writer_data/match.json | 2 +- tests/writer_test.rs | 42 ++---------------------- 10 files changed, 24 insertions(+), 84 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c56576f..c0f7a4a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "cirru_parser" -version = "0.2.6" +version = "0.2.7" dependencies = [ "criterion", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3ece352..609463a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cirru_parser" -version = "0.2.6" +version = "0.2.7" authors = ["jiyinyiyong "] edition = "2024" license = "MIT" diff --git a/src/writer.rs b/src/writer.rs index d85a5d2..3ef453e 100644 --- a/src/writer.rs +++ b/src/writer.rs @@ -182,14 +182,6 @@ fn get_node_kind(cursor: &Cirru) -> WriterNode { } } -fn should_insist_nested_head(ys: &[Cirru], idx: usize, prev_kind: WriterNode) -> bool { - if prev_kind == WriterNode::BoxedExpr || prev_kind == WriterNode::Expr { - return true; - } - - idx > 1 && matches!(ys.first(), Some(Cirru::List(head)) if head.len() > 1) -} - fn generate_tree( xs: &[Cirru], insist_head: bool, @@ -200,12 +192,11 @@ fn generate_tree( let mut prev_kind = WriterNode::Nil; let mut level = base_level; let mut result = String::from(""); - // tracks whether the previously-generated child content was inline (did not start with '\n') - let mut prev_child_inline = true; for (idx, cursor) in xs.iter().enumerate() { let kind = get_node_kind(cursor); let next_level = level + 1; + let child_insist_head = (prev_kind == WriterNode::BoxedExpr) || (prev_kind == WriterNode::Expr) || idx > 1; let at_tail = idx != 0 && !in_tail && prev_kind == WriterNode::Leaf && idx == xs.len() - 1; // println!("\nloop {:?} {:?}", prev_kind, kind); @@ -215,7 +206,6 @@ fn generate_tree( let child: String = match cursor { Cirru::Leaf(s) => generate_leaf(s), Cirru::List(ys) => { - let child_insist_head = should_insist_nested_head(ys, idx, prev_kind); if at_tail { if ys.is_empty() { String::from("$") @@ -244,25 +234,12 @@ fn generate_tree( generate_empty_expr() // special since empty expr is treated as leaf } } else if kind == WriterNode::SimpleExpr { - if prev_kind == WriterNode::Leaf && (idx == 1 || level > base_level || xs.len().saturating_sub(idx) <= 2) { + if prev_kind == WriterNode::Leaf { generate_inline_expr(ys) - } else if prev_kind == WriterNode::Leaf { - let mut ret = render_newline(next_level); - ret.push_str(&generate_tree(ys, child_insist_head, options, next_level, false)?); - ret } else if options.use_inline && prev_kind == WriterNode::SimpleExpr { - // Only inline when the previous sibling was itself written inline. - // If the previous was block-formatted (started with '\n'), keep this one - // on its own line too so that sibling pairs in a struct/map stay separate. - if prev_child_inline { - let mut ret = String::from(" "); - ret.push_str(&generate_inline_expr(ys)); - ret - } else { - let mut ret = render_newline(next_level); - ret.push_str(&generate_tree(ys, child_insist_head, options, next_level, false)?); - ret - } + let mut ret = String::from(" "); + ret.push_str(&generate_inline_expr(ys)); + ret } else { let mut ret = render_newline(next_level); ret.push_str(&generate_tree(ys, child_insist_head, options, next_level, false)?); @@ -279,7 +256,12 @@ fn generate_tree( } } else if kind == WriterNode::BoxedExpr { let content = generate_tree(ys, child_insist_head, options, next_level, false)?; - if prev_kind == WriterNode::Nil || prev_kind == WriterNode::Leaf || prev_kind == WriterNode::SimpleExpr { + if child_insist_head { + // special case for boxed expr when it insists head, it has both indentation and brackets + let mut ret = render_newline(next_level); + ret.push_str(&content); + ret + } else if prev_kind == WriterNode::Nil || prev_kind == WriterNode::Leaf || prev_kind == WriterNode::SimpleExpr { content } else { let mut ret = render_newline(next_level); @@ -287,7 +269,7 @@ fn generate_tree( ret } } else { - return Err(String::from("Unpected condition")); + return Err(String::from("Unexpected condition")); } } }; @@ -296,7 +278,7 @@ fn generate_tree( let chunk = if at_tail || (prev_kind == WriterNode::Leaf && kind == WriterNode::Leaf) - || (prev_kind == WriterNode::Leaf && kind == WriterNode::SimpleExpr && !child.starts_with('\n')) + || (prev_kind == WriterNode::Leaf && kind == WriterNode::SimpleExpr) || prev_kind == WriterNode::SimpleExpr && kind == WriterNode::Leaf { let mut ret = String::from(" "); @@ -337,9 +319,6 @@ fn generate_tree( level += 1; } - // update prev_child_inline: tracks if the current child was inline (no leading newline) - prev_child_inline = !chunk.starts_with('\n'); - // console.log("chunk", JSON.stringify(chunk)); // console.log("And result", JSON.stringify(result)); } diff --git a/tests/cirru/match.cirru b/tests/cirru/match.cirru index a39ab03..664f047 100644 --- a/tests/cirru/match.cirru +++ b/tests/cirru/match.cirru @@ -1,6 +1,6 @@ match x - :dyn 1 + (:dyn) 1 (:dyn x) 2 (:dyn x y) 3 (:dyn x y z) 4 \ No newline at end of file diff --git a/tests/data/match.json b/tests/data/match.json index a7f40b5..c3fa48d 100644 --- a/tests/data/match.json +++ b/tests/data/match.json @@ -2,7 +2,7 @@ [ "match", "x", - [":dyn", "1"], + [[":dyn"], "1"], [[":dyn", "x"], "2"], [[":dyn", "x", "y"], "3"], [[":dyn", "x", "y", "z"], "4"] diff --git a/tests/writer_cirru/comma-indent.cirru b/tests/writer_cirru/comma-indent.cirru index dc00716..a16a0e1 100644 --- a/tests/writer_cirru/comma-indent.cirru +++ b/tests/writer_cirru/comma-indent.cirru @@ -1,5 +1,4 @@ c $ d (e) - a - , d (f) - g + (a) d (f) + g diff --git a/tests/writer_cirru/indent.cirru b/tests/writer_cirru/indent.cirru index 35166de..ba82b65 100644 --- a/tests/writer_cirru/indent.cirru +++ b/tests/writer_cirru/indent.cirru @@ -2,5 +2,5 @@ a $ b (c) e f - g + (g) h diff --git a/tests/writer_cirru/match.cirru b/tests/writer_cirru/match.cirru index c28bc16..b4d1f0a 100644 --- a/tests/writer_cirru/match.cirru +++ b/tests/writer_cirru/match.cirru @@ -1,6 +1,6 @@ match x - :dyn 1 + (:dyn) 1 (:dyn x) 2 (:dyn x y) 3 (:dyn x y z) 4 diff --git a/tests/writer_data/match.json b/tests/writer_data/match.json index a7f40b5..c3fa48d 100644 --- a/tests/writer_data/match.json +++ b/tests/writer_data/match.json @@ -2,7 +2,7 @@ [ "match", "x", - [":dyn", "1"], + [[":dyn"], "1"], [[":dyn", "x"], "2"], [[":dyn", "x", "y"], "3"], [[":dyn", "x", "y", "z"], "4"] diff --git a/tests/writer_test.rs b/tests/writer_test.rs index a39fdf8..7abc4cc 100644 --- a/tests/writer_test.rs +++ b/tests/writer_test.rs @@ -115,41 +115,6 @@ mod json_write_test { } } -#[test] -fn sibling_simple_exprs_in_struct_keep_separate_lines_with_use_inline() -> Result<(), String> { - use cirru_parser::{Cirru, CirruWriterOptions, format}; - // When a struct has multiple sibling pairs and the first pair is block-formatted - // (not inlined), subsequent sibling SimpleExprs should also go on separate lines, - // not be appended inline to the previous line. - // Bug: with use_inline=true, `(:schema :dynamic)` was appended to the `:doc` line - // producing `:doc |long doc string (:schema :dynamic)` — a 3-element list that - // cirru_edn parsers reject as an invalid record field pair. - let xs = vec![Cirru::List(vec![ - Cirru::leaf("%{}"), - Cirru::leaf(":CodeEntry"), - Cirru::List(vec![Cirru::leaf(":doc"), Cirru::leaf("|a long doc string")]), - Cirru::List(vec![Cirru::leaf(":schema"), Cirru::leaf(":dynamic")]), - Cirru::List(vec![Cirru::leaf(":code"), Cirru::leaf("stuff")]), - ])]; - - let rendered = format(&xs, CirruWriterOptions { use_inline: true })?; - - // No single line should contain both :doc and :schema tokens - for line in rendered.lines() { - assert!( - !(line.contains(":doc") && line.contains(":schema")), - ":doc and :schema should be on separate lines, but got line: {:?}", - line - ); - } - - // Round-trip: parse the rendered output back and compare to the original tree - let reparsed = cirru_parser::parse(&rendered).expect("rendered output should be valid Cirru"); - assert_eq!(xs, reparsed, "round-trip should preserve structure"); - - Ok(()) -} - #[test] fn leaves_escapeing() { assert_eq!("\"a\"", escape_cirru_leaf("a")); @@ -241,7 +206,7 @@ fn format_match_without_bending_later_clauses() -> Result<(), String> { let xs = vec![Cirru::List(vec![ Cirru::leaf("match"), Cirru::leaf("x"), - Cirru::List(vec![Cirru::leaf(":dyn"), Cirru::leaf("1")]), + Cirru::List(vec![Cirru::List(vec![Cirru::leaf(":dyn")]), Cirru::leaf("1")]), Cirru::List(vec![Cirru::List(vec![Cirru::leaf(":dyn"), Cirru::leaf("x")]), Cirru::leaf("2")]), Cirru::List(vec![ Cirru::List(vec![Cirru::leaf(":dyn"), Cirru::leaf("x"), Cirru::leaf("y")]), @@ -255,10 +220,7 @@ fn format_match_without_bending_later_clauses() -> Result<(), String> { let rendered = format(&xs, CirruWriterOptions::from(false))?; - assert_eq!( - "\nmatch x\n :dyn 1\n (:dyn x) 2\n (:dyn x y) 3\n (:dyn x y z) 4\n", - rendered - ); + assert_eq!("\nmatch x\n (:dyn) 1\n (:dyn x) 2\n (:dyn x y) 3\n (:dyn x y z) 4\n", rendered); Ok(()) } From df1224514d8632b1713bb1e836bf544b52693ecd Mon Sep 17 00:00:00 2001 From: tiye Date: Wed, 13 May 2026 01:28:24 +0800 Subject: [PATCH 3/4] feat: expose generate_leaf and add structural methods to Cirru - writer.rs: make generate_leaf pub, add doc comment explaining quoting behavior - primes.rs: add is_leaf(), is_list(), as_leaf_str(), head() as Cirru methods - parser.rs: re-export generate_leaf from crate root These changes allow downstream crates (e.g. calcit/program_diff.rs) to use the authoritative leaf formatter instead of copying the logic, and to write more idiomatic code using Cirru method dispatch. --- src/parser.rs | 2 +- src/primes.rs | 26 ++++++++++++++++++++++++++ src/writer.rs | 6 +++++- 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/parser.rs b/src/parser.rs index 8a35048..2302af3 100644 --- a/src/parser.rs +++ b/src/parser.rs @@ -46,7 +46,7 @@ use tree::{resolve_comma, resolve_dollar}; pub use primes::{Cirru, CirruLexItem, CirruLexItemList, escape_cirru_leaf}; pub use s_expr::format_to_lisp; -pub use writer::{CirruOneLinerExt, CirruWriterOptions, format, format_expr_one_liner}; +pub use writer::{CirruOneLinerExt, CirruWriterOptions, format, format_expr_one_liner, generate_leaf}; /// Helper function to format and print a detailed error pub fn print_error(error: &CirruError, source_code: Option<&str>) { diff --git a/src/primes.rs b/src/primes.rs index 525155c..5812aef 100644 --- a/src/primes.rs +++ b/src/primes.rs @@ -172,6 +172,32 @@ impl Cirru { Cirru::Leaf(s) => &(**s) == ";" || &(**s) == ";;", } } + + /// Returns `true` if this node is a `Leaf`. + pub fn is_leaf(&self) -> bool { + matches!(self, Self::Leaf(_)) + } + + /// Returns `true` if this node is a `List`. + pub fn is_list(&self) -> bool { + matches!(self, Self::List(_)) + } + + /// Returns the leaf string slice if this node is a `Leaf`, otherwise `None`. + pub fn as_leaf_str(&self) -> Option<&str> { + match self { + Self::Leaf(s) => Some(s), + _ => None, + } + } + + /// Returns the first child node if this is a non-empty `List`, otherwise `None`. + pub fn head(&self) -> Option<&Cirru> { + match self { + Self::List(xs) => xs.first(), + _ => None, + } + } } #[derive(fmt::Debug, PartialEq, Eq)] diff --git a/src/writer.rs b/src/writer.rs index 3ef453e..7817236 100644 --- a/src/writer.rs +++ b/src/writer.rs @@ -62,7 +62,11 @@ fn is_char_allowed(x: char) -> bool { ALLOWED_CHARS.find(x).is_some() } -fn generate_leaf(s: &str) -> String { +/// Format a Cirru leaf token: returns the bare string if all characters are allowed +/// in Cirru without quoting; otherwise wraps in double quotes with escape sequences. +/// This mirrors the exact quoting behaviour used by the Cirru formatter when emitting +/// leaf nodes, so callers outside this crate get consistent output. +pub fn generate_leaf(s: &str) -> String { let mut all_allowed = true; for x in s.chars() { if !is_char_allowed(x) { From e280cd5ebcb115df0ed501cf39c00603afe40fda Mon Sep 17 00:00:00 2001 From: tiye Date: Wed, 13 May 2026 01:33:55 +0800 Subject: [PATCH 4/4] chore: release 0.2.8 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0f7a4a..c92a2e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,7 +76,7 @@ dependencies = [ [[package]] name = "cirru_parser" -version = "0.2.7" +version = "0.2.8" dependencies = [ "criterion", "serde", diff --git a/Cargo.toml b/Cargo.toml index 609463a..aa02aa3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "cirru_parser" -version = "0.2.7" +version = "0.2.8" authors = ["jiyinyiyong "] edition = "2024" license = "MIT"