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
186 changes: 173 additions & 13 deletions crates/mq-formatter/src/formatter.rs
Original file line number Diff line number Diff line change
Expand Up @@ -376,10 +376,19 @@ impl Formatter {
self.append_space();
}

// Check for 'do' keyword or colon
let do_index = Self::find_token_position(node, |kind| matches!(kind, mq_lang::TokenKind::Do));
let colon_index = Self::find_token_position(node, |kind| matches!(kind, mq_lang::TokenKind::Colon));
let uses_do_syntax = do_index.is_some();

// If there's no colon, split before the right parenthesis
let expr_index = self.calculate_split_position(node, 0);
// If there's no colon or do, split before the right parenthesis
let expr_index = if let Some(index) = do_index.or(colon_index) {
index
} else {
Self::find_token_position(node, |kind| matches!(kind, mq_lang::TokenKind::RParen))
.map(|index| index + 1)
.unwrap_or(0)
Comment on lines +384 to +390
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

The calculation of expr_index has changed significantly and may not match the original calculate_split_position logic. The removed code called self.calculate_split_position(node, 0) which likely had specific logic for determining split positions. The new implementation directly finds the RParen position, which could produce different behavior. Consider verifying that this simplified logic handles all edge cases that calculate_split_position was designed for.

Suggested change
// If there's no colon or do, split before the right parenthesis
let expr_index = if let Some(index) = do_index.or(colon_index) {
index
} else {
Self::find_token_position(node, |kind| matches!(kind, mq_lang::TokenKind::RParen))
.map(|index| index + 1)
.unwrap_or(0)
// If there's no colon or do, split before the right parenthesis.
// If no suitable token is found at all, default to the end of the children
// to avoid changing behavior by arbitrarily splitting at index 0.
let expr_index = if let Some(index) = do_index.or(colon_index) {
index
} else {
Self::find_token_position(node, |kind| matches!(kind, mq_lang::TokenKind::RParen))
.map(|index| index + 1)
.unwrap_or(node.children.len())

Copilot uses AI. Check for mistakes.
};

node.children.iter().take(expr_index).for_each(|child| {
self.format_node(
Expand All @@ -394,11 +403,17 @@ impl Formatter {

let mut expr_nodes = node.children.iter().skip(expr_index).peekable();

// Format colon if it exists
if colon_index.is_some()
&& let Some(colon_node) = expr_nodes.next()
// Format colon or do keyword if it exists
if (colon_index.is_some() || do_index.is_some())
&& let Some(separator_node) = expr_nodes.next()
{
self.format_colon_with_spacing(colon_node, &mut expr_nodes, block_indent_level + 1);
if uses_do_syntax {
// Format 'do' keyword with standardized spacing
self.format_do_with_spacing(separator_node, &mut expr_nodes);
} else {
// Format colon with spacing
self.format_colon_with_spacing(separator_node, &mut expr_nodes, block_indent_level + 1);
}
}

let block_indent_level = if is_prev_pipe {
Expand Down Expand Up @@ -803,17 +818,28 @@ impl Formatter {

let indent_adjustment = self.calculate_indent_adjustment();

// Find the colon position
let colon_pos = Self::find_token_position(node, |kind| matches!(kind, mq_lang::TokenKind::Colon)).unwrap_or(0);
// Check for 'do' keyword or colon
let do_pos = Self::find_token_position(node, |kind| matches!(kind, mq_lang::TokenKind::Do));
let colon_pos = Self::find_token_position(node, |kind| matches!(kind, mq_lang::TokenKind::Colon));
let uses_do_syntax = do_pos.is_some();
let separator_pos = do_pos.or(colon_pos).unwrap_or(0);

// Format arguments (lparen, value, rparen)
for child in node.children.iter().take(colon_pos) {
for child in node.children.iter().take(separator_pos) {
self.format_node(mq_lang::Shared::clone(child), 0);
}

// Format colon
if let Some(colon) = node.children.get(colon_pos) {
self.format_node(mq_lang::Shared::clone(colon), 0);
// Format colon or do keyword
let mut remaining_children = node.children.iter().skip(separator_pos).peekable();

if let Some(separator) = remaining_children.next() {
if uses_do_syntax {
// Format 'do' keyword with standardized spacing
self.format_do_with_spacing(separator, &mut remaining_children);
} else {
// Format colon
self.format_node(mq_lang::Shared::clone(separator), 0);
}
}

// Calculate indent level for match arms (similar to format_if)
Expand All @@ -827,7 +853,7 @@ impl Formatter {
let end_indent_level = if is_prev_pipe { indent_level + 1 } else { indent_level } + indent_adjustment;

// Format match arms and end
let remaining_children: Vec<_> = node.children.iter().skip(colon_pos + 1).collect();
let remaining_children: Vec<_> = remaining_children.collect();
Copy link

Copilot AI Jan 2, 2026

Choose a reason for hiding this comment

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

The variable remaining_children is shadowing the iterator of the same name from line 833. This creates confusion as the name is reused for a collected vector. Consider renaming the collected vector to something like match_arms_and_end or remaining_nodes_vec to distinguish it from the iterator.

Copilot uses AI. Check for mistakes.

// Check if this is a multiline match (first match arm has new line)
let is_multiline = remaining_children
Expand Down Expand Up @@ -1345,6 +1371,31 @@ impl Formatter {
}
}

/// Formats a 'do' keyword with standardized spacing.
/// Adds space before the 'do' keyword, formats it, and adds space after
/// if the next node doesn't have a newline and is not a MatchArm.
fn format_do_with_spacing<'a, I>(
&mut self,
do_node: &mq_lang::Shared<mq_lang::CstNode>,
remaining: &mut std::iter::Peekable<I>,
) where
I: Iterator<Item = &'a mq_lang::Shared<mq_lang::CstNode>>,
{
// Add space before 'do' keyword
self.append_space();

// Format the 'do' keyword
self.format_node(mq_lang::Shared::clone(do_node), 0);

// Add space after 'do' if next node doesn't have newline and is not a MatchArm
if let Some(next) = remaining.peek()
&& !next.has_new_line()
&& !matches!(next.kind, mq_lang::CstNodeKind::MatchArm)
{
self.append_space();
}
}

/// Escapes control characters in a string, preserving existing valid escape sequences
fn escape_string(s: &str) -> String {
let mut result = String::with_capacity(s.len() * 2);
Expand Down Expand Up @@ -2292,6 +2343,115 @@ end"#,
else do
test2
end
"#
)]
#[case::while_do_end_oneline("while(x > 0) do x - 1 end", "while (x > 0) do x - 1 end")]
#[case::while_do_end_multiline(
r#"while(x > 0) do
let x = x - 1 | x
end"#,
r#"while (x > 0) do
let x = x - 1 | x
end
"#
)]
#[case::while_do_end_with_break(
r#"while(x < 10) do
let x = x + 1
| if(x == 3):
break
else:
x
end"#,
r#"while (x < 10) do
let x = x + 1
| if (x == 3):
break
else:
x
end
"#
)]
#[case::foreach_do_end_oneline(
"foreach(item, arr) do process(item) end",
"foreach (item, arr) do process(item) end"
)]
#[case::foreach_do_end_multiline(
r#"foreach(x, array(1, 2, 3)) do
add(x, 1)
end"#,
r#"foreach (x, array(1, 2, 3)) do
add(x, 1)
end
"#
)]
#[case::foreach_do_end_with_continue(
r#"foreach(x, array(1, 2, 3, 4, 5)) do
if(x == 3):
continue
else:
x + 10
end"#,
r#"foreach (x, array(1, 2, 3, 4, 5)) do
if (x == 3):
continue
else:
x + 10
end
"#
)]
#[case::foreach_do_end_nested(
r#"foreach(row, arr) do
foreach(x, row) do
x * 2
end
end"#,
r#"foreach (row, arr) do
foreach (x, row) do
x * 2
end
end
"#
)]
#[case::match_do_end_oneline(
"match(x) do | 1: \"one\" | 2: \"two\" | _: \"other\" end",
"match (x) do | 1: \"one\" | 2: \"two\" | _: \"other\" end"
)]
#[case::match_do_end_multiline(
r#"match(2) do
| 1: "one"
| 2: "two"
| _: "other"
end"#,
r#"match (2) do
| 1: "one"
| 2: "two"
| _: "other"
end
"#
)]
#[case::match_do_end_type_pattern(
r#"match(array(1, 2, 3)) do
| :array: "is_array"
| :number: "is_number"
| _: "other"
end"#,
r#"match (array(1, 2, 3)) do
| :array: "is_array"
| :number: "is_number"
| _: "other"
end
"#
)]
#[case::match_do_end_with_guard(
r#"match(x) do
| n if(n > 0): "positive"
| _: "non-positive"
end"#,
r#"match (x) do
| n if (n > 0): "positive"
| _: "non-positive"
end
"#
)]
fn test_format(#[case] code: &str, #[case] expected: &str) {
Expand Down
5 changes: 4 additions & 1 deletion crates/mq-lang/builtin.mq
Original file line number Diff line number Diff line change
Expand Up @@ -498,7 +498,10 @@ end
# Executes an expression n times and returns an array of results.
macro times(t_n, t_expr) do
quote do
foreach (_, range(0, unquote(t_n) - 1)): unquote(t_expr);
if (unquote(t_n) == 0):
[]
else:
foreach (_, range(0, unquote(t_n) - 1)): unquote(t_expr);
end
end

Expand Down
2 changes: 1 addition & 1 deletion crates/mq-lang/modules/module_tests.mq
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ import "csv"

| def test_toml_to_json():
let result = toml::toml_to_json(toml::toml_parse(toml_input))
| assert_eq(result, "{\"package\":{\"name\":\"test-package\",\"authors\":[\"Test Author <test@example.com>\"],\"description\":\"A test TOML configuration file\",\"edition\":\"2021\",\"license\":\"MIT\",\"version\":\"1.0.0\"},\"dependencies\":{\"clap\":\"4.0\",\"serde\":\"1.0\",\"tokio\":{\"version\":\"1.0\",\"features\":[\"full\"]}},\"features\":{\"json\":[\"serde_json\"],\"yaml\":[\"serde_yaml\"],\"default\":[\"json\"]},\"dev-dependencies\":{\"assert_cmd\":\"2.0\",\"predicates\":\"3.0\"},\"bin\":[{\"name\":\"test-cli\",\"path\":\"src/main.rs\"}],\"build-dependencies\":{\"cc\":\"1.0\"},\"profile\":{\"release\":{\"codegen-units\":1,\"lto\":true,\"opt-level\":3}},\"workspace\":{\"members\":[\"crate1\",\"crate2\",\"subdir/crate3\"]},\"metadata\":{\"docs\":{\"rs\":{\"all-features\":true,\"rustdoc-args\":[\"--cfg\",\"docsrs\"]}}},\"config\":{\"debug\":true,\"max_connections\":100,\"timeout\":30,\"database\":{\"name\":\"testdb\",\"host\":\"localhost\",\"password\":\"secret\",\"port\":5432,\"user\":\"admin\"},\"server\":{\"host\":\"0.0.0.0\",\"port\":8080,\"workers\":4}},\"database\":{\"connection\":[{\"server\":\"192.168.1.1\",\"enabled\":true,\"connection_max\":5000,\"ports\":[8001,8001,8002]},{\"server\":\"192.168.1.2\",\"enabled\":false,\"connection_max\":300,\"ports\":[8001]}]},\"servers\":[{\"name\":\"alpha\",\"ip\":\"10.0.0.1\",\"role\":\"frontend\"},{\"name\":\"beta\",\"ip\":\"10.0.0.2\",\"role\":\"backend\"}],\"logging\":{\"level\":\"info\",\"format\":\"json\",\"file\":{\"path\":\"/var/log/app.log\",\"max_size\":\"10MB\",\"rotate\":true},\"console\":{\"colors\":true,\"enabled\":true,\"test_booleans\":[true,false,true],\"test_floats\":[3.14,2.71,1.41],\"test_numbers\":[1,2,3,42],\"test_strings\":[\"hello\",\"world\",\"test\"]}},\"mixed_data\":{\"array_value\":[1,2,3],\"boolean_value\":true,\"date_value\":\"2024-01-01T00:00:00Z\",\"float_value\":3.14159,\"integer_value\":42,\"string_value\":\"test string\",\"nested\":{\"key1\":\"value1\",\"key2\":\"value2\",\"bin_number\":0,\"b11010110\":null,\"float_with_exponent\":5,\"e\":22,\"float_with_underscore\":224617.445991,\"hex_number\":0,\"xDEADBEEF\":null,\"int_with_underscore\":1000000,\"oct_number\":0,\"o755\":null,\"local_date\":\"1979-05-27\",\"local_datetime\":\"1979-05-27T07:32:00\",\"local_time\":\"07:32:00\",\"offset_datetime\":\"1979-05-27T07:32:00-08:00\",\"heterogeneous_array\":[1,2,\"a\",\"b\",\"c\"],\"nested_array\":[\"gamma\",\"delta\",1,2],\"inline_table\":{\"x\":1,\"y\":2},\"nested_inline\":{\"person\":{\"name\":\"John\",\"age\":30}},\"infinity\":9223372036854775807,\"negative_infinity\":-9223372036854775808,\"not_a_number\":NaN,\"empty_array\":[],\"empty_string\":\"\",\"\":null,\"russian_comment\":\"Привет мир\"}},\"products\":[{\"name\":\"Hammer\",\"sku\":738594937},{\"name\":\"Nail\",\"sku\":284758393,\"color\":\"gray\"}],\"tool\":{\"name\":\"cargo\",\"version\":\"1.70.0\",\"features\":{\"default\":[\"std\"],\"no-std\":[\"core\"],\"std\":[]},\"settings\":{\"format\":\"json\",\"compression\":true}},\"127.0.0.1\":\"localhost\",\"barke_key\":\"value\",\"character encoding\":\"UTF-8\",\"quoted_key\":\"value\",\"ʎǝʞ\":\"upside down key\"}")
| assert_eq(result, "{\"package\":{\"name\":\"test-package\",\"authors\":[\"Test Author <test@example.com>\"],\"description\":\"A test TOML configuration file\",\"edition\":\"2021\",\"license\":\"MIT\",\"version\":\"1.0.0\"},\"dependencies\":{\"clap\":\"4.0\",\"serde\":\"1.0\",\"tokio\":{\"version\":\"1.0\",\"features\":[\"full\"]}},\"features\":{\"default\":[\"json\"],\"json\":[\"serde_json\"],\"yaml\":[\"serde_yaml\"]},\"dev-dependencies\":{\"assert_cmd\":\"2.0\",\"predicates\":\"3.0\"},\"bin\":[{\"name\":\"test-cli\",\"path\":\"src/main.rs\"}],\"build-dependencies\":{\"cc\":\"1.0\"},\"profile\":{\"release\":{\"codegen-units\":1,\"lto\":true,\"opt-level\":3}},\"workspace\":{\"members\":[\"crate1\",\"crate2\",\"subdir/crate3\"]},\"metadata\":{\"docs\":{\"rs\":{\"all-features\":true,\"rustdoc-args\":[\"--cfg\",\"docsrs\"]}}},\"config\":{\"debug\":true,\"max_connections\":100,\"timeout\":30,\"database\":{\"name\":\"testdb\",\"host\":\"localhost\",\"password\":\"secret\",\"port\":5432,\"user\":\"admin\"},\"server\":{\"host\":\"0.0.0.0\",\"port\":8080,\"workers\":4}},\"database\":{\"connection\":[{\"server\":\"192.168.1.1\",\"enabled\":true,\"connection_max\":5000,\"ports\":[8001,8001,8002]},{\"server\":\"192.168.1.2\",\"enabled\":false,\"connection_max\":300,\"ports\":[8001]}]},\"servers\":[{\"name\":\"alpha\",\"ip\":\"10.0.0.1\",\"role\":\"frontend\"},{\"name\":\"beta\",\"ip\":\"10.0.0.2\",\"role\":\"backend\"}],\"logging\":{\"level\":\"info\",\"format\":\"json\",\"file\":{\"path\":\"/var/log/app.log\",\"max_size\":\"10MB\",\"rotate\":true},\"console\":{\"colors\":true,\"enabled\":true,\"test_booleans\":[true,false,true],\"test_floats\":[3.14,2.71,1.41],\"test_numbers\":[1,2,3,42],\"test_strings\":[\"hello\",\"world\",\"test\"]}},\"mixed_data\":{\"array_value\":[1,2,3],\"boolean_value\":true,\"date_value\":\"2024-01-01T00:00:00Z\",\"float_value\":3.14159,\"integer_value\":42,\"string_value\":\"test string\",\"nested\":{\"key1\":\"value1\",\"key2\":\"value2\",\"bin_number\":0,\"b11010110\":null,\"float_with_exponent\":5,\"e\":22,\"float_with_underscore\":224617.445991,\"hex_number\":0,\"xDEADBEEF\":null,\"int_with_underscore\":1000000,\"oct_number\":0,\"o755\":null,\"local_date\":\"1979-05-27\",\"local_datetime\":\"1979-05-27T07:32:00\",\"local_time\":\"07:32:00\",\"offset_datetime\":\"1979-05-27T07:32:00-08:00\",\"heterogeneous_array\":[1,2,\"a\",\"b\",\"c\"],\"nested_array\":[\"gamma\",\"delta\",1,2],\"inline_table\":{\"x\":1,\"y\":2},\"nested_inline\":{\"person\":{\"name\":\"John\",\"age\":30}},\"infinity\":9223372036854775807,\"negative_infinity\":-9223372036854775808,\"not_a_number\":NaN,\"empty_array\":[],\"empty_string\":\"\",\"\":null,\"russian_comment\":\"Привет мир\"}},\"products\":[{\"name\":\"Hammer\",\"sku\":738594937},{\"name\":\"Nail\",\"sku\":284758393,\"color\":\"gray\"}],\"tool\":{\"name\":\"cargo\",\"version\":\"1.70.0\",\"features\":{\"default\":[\"std\"],\"no-std\":[\"core\"],\"std\":[]},\"settings\":{\"format\":\"json\",\"compression\":true}},\"127.0.0.1\":\"localhost\",\"barke_key\":\"value\",\"character encoding\":\"UTF-8\",\"quoted_key\":\"value\",\"ʎǝʞ\":\"upside down key\"}")
end

| let yaml_input = "---\nstring: hello\nnumber: 42\nfloat: 3.14\nbool_true: true\nbool_false: false\nnull_value: null\narray:\n - item1\n - item2\n - item3\nobject:\n key1: value1\n key2: value2\nnested:\n arr:\n - a\n - b\n obj:\n subkey: subval\nmultiline: |\n This is a\n multiline string\nquoted: \"quoted string\"\nsingle_quoted: 'single quoted string'\ndate: 2024-06-01\ntimestamp: 2024-06-01T12:34:56Z\nempty_array: []\nempty_object: {}\nanchors:\n &anchor_val anchored value\nref: *anchor_val\ncomplex:\n - foo: bar\n baz:\n - qux\n - quux\n - corge: grault\nspecial_chars: \"!@#$%^&*()_+-=[]{}|;:',.<>/?\"\nunicode: \"こんにちは世界\"\nbool_list:\n - true\n - false\nnull_list:\n - null\n - ~"
Expand Down
Loading