Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,15 @@ using @cirru_parser {type Cirru}
// parse Cirru code
Cirru::parse(code: String) : Array[Cirru] raise CirruParseError

// parse a one-line Cirru expression
Cirru::parse_expr_one_liner(code: String) : Cirru raise CirruParseError

// format Cirru code
Cirru::format(cirru: Array[Cirru], use_inline=false) : String raise FormatCirruError

// format one expression into one line
Cirru::format_expr_one_liner(expr: Cirru) : String raise FormatCirruError
Cirru::format_one_liner(expr: Cirru) : String raise FormatCirruError
```

### License
Expand Down
18 changes: 15 additions & 3 deletions src/parser.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ fn build_exprs(
idx += 1
Some(tokens[pos])
}
for {
for ;; {
let chunk = pull_token()
match chunk {
None => return acc
Expand All @@ -23,7 +23,7 @@ fn build_exprs(
let mut pointer : Array[Cirru] = Array::new(capacity=8)
// guess a nested level of 16
let pointer_stack : Array[Array[Cirru]] = Array::new(capacity=16)
for {
for ;; {
let cursor = pull_token()
match cursor {
None => raise CirruParseError("unexpected end of file")
Expand Down Expand Up @@ -68,6 +68,18 @@ pub fn Cirru::parse(code : String) -> Array[Cirru] raise CirruParseError {
resolve_comma(resolve_dollar(tree))
}

///|
/// parse a one-line Cirru expression into exactly one expression
pub fn Cirru::parse_expr_one_liner(
code : String,
) -> Cirru raise CirruParseError {
let xs = Cirru::parse(code)
if xs.length() != 1 {
raise CirruParseError("expected 1 expression, got \{xs.length()}")
}
xs[0]
}

///|
suberror CirruParseError {
CirruParseError(String)
Expand Down Expand Up @@ -280,7 +292,7 @@ fn resolve_indentations(tokens : Array[CirruLexItem]) -> Array[CirruLexItem] {
let mut acc : Array[CirruLexItem] = Array::new()
let mut level = 0
let mut pointer = 0
for {
for ;; {
if pointer >= size {
if acc.is_empty() {
return Array::new()
Expand Down
86 changes: 85 additions & 1 deletion src/parser_test.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,91 @@ using @lib {type Cirru}

///|
test "parser" {
assert_eq(Cirru::parse("def a"), [List([Leaf("def"), Leaf("a")])])
assert_eq(Cirru::parse("def a"), [
Cirru::List([Cirru::Leaf("def"), Cirru::Leaf("a")]),
])
}

///|
test "parse and format one-liner expression" {
let tree = Cirru::List([
Cirru::Leaf("defn"),
Cirru::Leaf("main"),
Cirru::List([]),
Cirru::List([Cirru::Leaf("println"), Cirru::Leaf("Hello, world!")]),
])

let one_liner = try! tree.format_one_liner()
assert_eq(one_liner, "defn main () $ println \"Hello, world!\"")

let parsed = try! Cirru::parse_expr_one_liner(one_liner)
assert_eq(parsed, tree)
}

///|
test "reject multiple expressions in one-liner parser" {
let result = try? Cirru::parse_expr_one_liner("a\nb")
match result {
Err(err) => assert_eq(err.to_string(), "expected 1 expression, got 2")
Ok(_) => assert_eq("ok", "err")
}
}

///|
test "format complex one-liner expression" {
let tree = Cirru::List([
Cirru::Leaf("a"),
Cirru::List([Cirru::Leaf("b"), Cirru::List([Cirru::Leaf("c")])]),
Cirru::Leaf("d"),
])

let one_liner = try! tree.format_one_liner()
assert_eq(one_liner, "a (b (c)) d")

let parsed = try! Cirru::parse_expr_one_liner(one_liner)
assert_eq(parsed, tree)
}

///|
test "format tail expression one-liner" {
let tree = Cirru::List([
Cirru::Leaf("defn"),
Cirru::Leaf("main"),
Cirru::List([]),
Cirru::List([Cirru::Leaf("println"), Cirru::Leaf("Hello")]),
])

let one_liner = try! tree.format_one_liner()
assert_eq(one_liner, "defn main () $ println Hello")

let parsed = try! Cirru::parse_expr_one_liner(one_liner)
assert_eq(parsed, tree)
}

///|
test "format nested tail expression one-liner" {
let tree = Cirru::List([
Cirru::Leaf("if"),
Cirru::Leaf("condition"),
Cirru::List([Cirru::Leaf("do"), Cirru::List([Cirru::Leaf("action")])]),
])

let one_liner = try! tree.format_one_liner()
assert_eq(one_liner, "if condition $ do $ action")

let parsed = try! Cirru::parse_expr_one_liner(one_liner)
assert_eq(parsed, tree)
}

///|
test "format empty tail expression one-liner" {
let tree = Cirru::List([Cirru::Leaf("a"), Cirru::Leaf("b"), Cirru::List([])])

let one_liner = try! tree.format_one_liner()
assert_eq(one_liner, "a b $")

let parsed = try! Cirru::parse_expr_one_liner(one_liner)
assert_eq(parsed, tree)
}

///|
Expand Down
6 changes: 6 additions & 0 deletions src/primes.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,12 @@ pub fn Cirru::is_comment(self : Cirru) -> Bool {
}
}

///|
/// format this expression into a single line of Cirru code
pub fn Cirru::format_one_liner(self : Cirru) -> String raise FormatCirruError {
Cirru::format_expr_one_liner(self)
}

///|
/// lexer is a simpler state machine to tokenize Cirru code
priv enum CirruLexState {
Expand Down
4 changes: 2 additions & 2 deletions src/s_expr.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ fn Cirru::format_lispy_expr(
chunk = "\{chunk}\{next}"
}
// TODO dirty way, but intuitive for now
if idx < xs.length() - 1 && not(ends_with_newline(chunk)) {
if idx < xs.length() - 1 && !ends_with_newline(chunk) {
chunk = "\{chunk} "
}
}
Expand All @@ -58,7 +58,7 @@ fn Cirru::format_lispy_expr(
} else {
let s0 = token[0]
if s0 == '|' || s0 == '"' {
let sliced = (try! token[1:]).to_string()
let sliced = token[1:].to_owned()
"\"" + escape_string(sliced) + "\""
} else if token.contains(" ") ||
token.contains("\n") ||
Expand Down
8 changes: 4 additions & 4 deletions src/tree.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,18 @@ fn comma_helper(initial_after : Array[Cirru]) -> Array[Cirru] {
let before : Array[Cirru] = Array::new(capacity=initial_after.length())
let after : Array[Cirru] = initial_after
let mut pointer = 0
for {
for ;; {
if pointer >= after.length() {
return before
}
match after[pointer] {
List(xs) =>
if not(xs.is_empty()) {
if !xs.is_empty() {
match xs[0] {
List(_) => before.push(List(resolve_comma(xs)))
Leaf(s) =>
if s == "," {
before.push_iter(resolve_comma(xs[1:].to_array()).iter())
before.push_iter(resolve_comma(xs[1:].to_owned()).iter())
} else {
before.push(List(resolve_comma(xs)))
}
Expand Down Expand Up @@ -51,7 +51,7 @@ fn dollar_helper(initial_after : Array[Cirru]) -> Array[Cirru] {
let before : Array[Cirru] = Array::new(capacity=initial_after.length())
let after : Array[Cirru] = initial_after
let mut pointer = 0
for {
for ;; {
if pointer >= after.length() {
return before
} else {
Expand Down
45 changes: 42 additions & 3 deletions src/writer.mbt
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ fn is_char_allowed(x : Char) -> Bool {
fn generate_leaf(s : String) -> String {
let mut all_allowed = true
for x in s {
if not(is_char_allowed(x)) {
if !is_char_allowed(x) {
all_allowed = false
break
}
Expand Down Expand Up @@ -131,6 +131,33 @@ fn generate_inline_expr(xs : Array[Cirru]) -> String {
result
}

///|
fn generate_statement_one_liner(xs : Array[Cirru]) -> String {

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

generate_statement_one_liner returns an empty string when xs is empty, so Cirru::format_expr_one_liner(List([])) would format to "" instead of the valid empty expr representation "()". Add an explicit empty-case (e.g., return generate_empty_expr() / "()") and consider adding a regression test for formatting/parsing an empty expression.

Suggested change
fn generate_statement_one_liner(xs : Array[Cirru]) -> String {
fn generate_statement_one_liner(xs : Array[Cirru]) -> String {
if xs.is_empty() {
return generate_empty_expr()
}

Copilot uses AI. Check for mistakes.
let mut ret = ""
let size = xs.length()
for idx, cursor in xs {
if idx > 0 {
ret += " "
}
let at_tail = idx > 0 && idx == size - 1
match cursor {
Leaf(s) => ret += generate_leaf(s)
List(ys) =>
if at_tail {
Comment on lines +142 to +146

Copilot AI Apr 30, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tail $ formatting here is triggered for any last List element (idx > 0 && idx == size - 1). In the main formatter (generate_tree), tail handling only applies when the previous node is a Leaf (prev_kind == Leaf). To keep one-liner formatting consistent/canonical, compute at_tail using the previous element kind (e.g., only treat the last list as tail when it is immediately preceded by a leaf).

Copilot uses AI. Check for mistakes.
if ys.is_empty() {
ret += "$"
} else {
ret += "$ "
ret += generate_statement_one_liner(ys)
}
} else {
ret += generate_inline_expr(ys)
}
}
}
ret
}

///|
/// by 2 spaces
fn push_spaces(buf : String, n : Int) -> String {
Expand Down Expand Up @@ -170,7 +197,7 @@ fn Cirru::get_node_kind(self : Cirru) -> WriterNode {
///|
pub(all) suberror FormatCirruError {
FormatCirruError(String)
} derive(Show)
} derive(Debug)

///|
fn generate_tree(
Expand All @@ -188,7 +215,7 @@ fn generate_tree(
let next_level = level + 1
let child_insist_head = prev_kind == BoxedExpr || prev_kind == Expr
let at_tail = idx != 0 &&
not(in_tail) &&
!in_tail &&
prev_kind == Leaf &&
idx == xs.length() - 1

Expand Down Expand Up @@ -343,3 +370,15 @@ pub fn Cirru::format(
) -> String raise FormatCirruError {
generate_statements(xs, use_inline~)
}

///|
/// format a single Cirru expression as a single line
pub fn Cirru::format_expr_one_liner(
expr : Cirru,
) -> String raise FormatCirruError {
match expr {
Leaf(_) =>
raise FormatCirruError("format_expr_one_liner expects an expr (list)")
List(xs) => generate_statement_one_liner(xs)
}
}
Loading