Skip to content

Commit 63393e6

Browse files
committed
Fix multiline lambdas in ternaries having too much indentation and getting a parse error
Close #112
1 parent cbd3eb5 commit 63393e6

File tree

8 files changed

+238
-12
lines changed

8 files changed

+238
-12
lines changed

src/formatter.rs

Lines changed: 211 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@ impl Formatter {
162162
self.add_newlines_after_extends_statement()
163163
.fix_dangling_semicolons()
164164
.fix_dangling_commas()
165+
.fix_nested_parenthesized_lambda_indentation()
165166
.fix_trailing_spaces()
166167
.remove_trailing_commas_from_preload()
167168
.postprocess_tree_sitter()
@@ -186,12 +187,9 @@ impl Formatter {
186187

187188
#[inline(always)]
188189
fn ensure_safe_reorder(&mut self) -> Result<(), Box<dyn std::error::Error>> {
189-
let original_source = self
190-
.original_source
191-
.as_deref()
192-
.ok_or_else(|| {
193-
"Safe mode requires the original source to verify reordered code".to_string()
194-
})?;
190+
let original_source = self.original_source.as_deref().ok_or_else(|| {
191+
"Safe mode requires the original source to verify reordered code".to_string()
192+
})?;
195193

196194
self.tree = self.parser.parse(&self.content, None).unwrap();
197195
ensure_top_level_tokens_match(original_source, &self.tree, &self.content)?;
@@ -263,9 +261,10 @@ impl Formatter {
263261
// separate arguments or elements in arrays and use inline comments to
264262
// describe the elements
265263
// This is done in the Godot Nakama repository for example.
266-
let comment_re = RegexBuilder::new(r"(?m)(?P<before>[^\n\r]*?)(?P<comment>#[^\n\r]*)\n\s+,")
267-
.build()
268-
.expect("dangling comma with comment regex should compile");
264+
let comment_re =
265+
RegexBuilder::new(r"(?m)(?P<before>[^\n\r]*?)(?P<comment>#[^\n\r]*)\n\s+,")
266+
.build()
267+
.expect("dangling comma with comment regex should compile");
269268

270269
self.regex_replace_all_outside_strings(comment_re, |caps: &regex::Captures| {
271270
let before = caps.name("before").unwrap().as_str();
@@ -298,6 +297,162 @@ impl Formatter {
298297
self
299298
}
300299

300+
/// This function removes duplicate indentation caused by lambdas wrapped in nested
301+
/// parenthesized expressions (e.g. a multiline lambda inside a ternary expression).
302+
/// Topiary applies an indent for each parenthesis level and another indent for the
303+
/// lambda body. That causes the lambda to have too much indentation and
304+
/// causes a GDScript parse error.
305+
#[inline(always)]
306+
fn fix_nested_parenthesized_lambda_indentation(&mut self) -> &mut Self {
307+
let mut captures = Vec::new();
308+
let mut stack = vec![self.tree.root_node()];
309+
310+
while let Some(node) = stack.pop() {
311+
if node.kind() == "lambda" {
312+
if let Some(body) = node.child_by_field_name("body") {
313+
if body.end_position().row > node.start_position().row {
314+
captures.push((node, body));
315+
}
316+
}
317+
}
318+
319+
let mut cursor = node.walk();
320+
for child in node.children(&mut cursor) {
321+
stack.push(child);
322+
}
323+
}
324+
325+
if captures.is_empty() {
326+
return self;
327+
}
328+
329+
captures.sort_by_key(|(lambda, _)| lambda.start_position().row);
330+
331+
let mut lines: Vec<String> = self
332+
.content
333+
.split('\n')
334+
.map(|line| line.to_string())
335+
.collect();
336+
let indent_size = self.config.indent_size.max(1);
337+
// We might need to pull the closing parenthesis up to the lambda's last line
338+
// to fix the indentation.
339+
// GDScript doesn't parse multi-line lambdas when there is a newline between the body
340+
// and the closing parenthesis (parenthesized expression, ternary, ...).
341+
let mut closing_merges: Vec<(usize, usize)> = Vec::new();
342+
343+
for (lambda, body) in captures {
344+
let header_row = lambda.start_position().row as usize;
345+
let mut first_row = body.start_position().row as usize;
346+
let last_row = body.end_position().row as usize;
347+
348+
if first_row == header_row {
349+
first_row = first_row.saturating_add(1);
350+
}
351+
352+
if header_row >= lines.len()
353+
|| first_row >= lines.len()
354+
|| last_row >= lines.len()
355+
|| first_row > last_row
356+
{
357+
continue;
358+
}
359+
360+
// Skip empty lines at the top of the lambda body. They shouldn't count when
361+
// we inspect indentation or decide whether the lambda is multi-line.
362+
while first_row <= last_row
363+
&& first_row < lines.len()
364+
&& lines[first_row].trim().is_empty()
365+
{
366+
first_row += 1;
367+
}
368+
if first_row > last_row || first_row >= lines.len() {
369+
continue;
370+
}
371+
372+
let header_indent = calculate_indent_info(&lines[header_row], indent_size);
373+
let first_indent = calculate_indent_info(&lines[first_row], indent_size);
374+
let target_spaces = header_indent.spaces + indent_size;
375+
if first_indent.spaces <= target_spaces {
376+
continue;
377+
}
378+
let delta_spaces = first_indent.spaces - target_spaces;
379+
380+
let suffix = lines[first_row][first_indent.column..].to_string();
381+
lines[first_row] = format!(
382+
"{}{suffix}",
383+
render_indent(
384+
&IndentInfo {
385+
spaces: target_spaces,
386+
column: first_indent.column,
387+
},
388+
indent_size,
389+
self.config.use_spaces
390+
)
391+
);
392+
393+
for row in (first_row + 1)..=last_row {
394+
if row >= lines.len() || lines[row].trim().is_empty() {
395+
continue;
396+
}
397+
let current_indent = calculate_indent_info(&lines[row], indent_size);
398+
if current_indent.spaces <= delta_spaces {
399+
continue;
400+
}
401+
let new_spaces = current_indent.spaces - delta_spaces;
402+
let suffix = lines[row][current_indent.column..].to_string();
403+
lines[row] = format!(
404+
"{}{}",
405+
render_indent(
406+
&IndentInfo {
407+
spaces: new_spaces,
408+
column: current_indent.column,
409+
},
410+
indent_size,
411+
self.config.use_spaces
412+
),
413+
suffix
414+
);
415+
}
416+
if let Some(parent) = lambda.parent() {
417+
// When a lambda sits inside an expression wrapped with
418+
// parentheses, the GDScript parser needs the closing ")" to
419+
// immediately follow the lambda body (no blank line, no line
420+
// return at the end of the lambda body).
421+
// We look for the next non-empty line and, if it's just a
422+
// closing parenthesis, we merge it back onto the lambda body.
423+
if parent.kind() == "parenthesized_expression"
424+
&& !lines[last_row].trim_end().ends_with(')')
425+
{
426+
let mut closing_row = last_row + 1;
427+
while closing_row < lines.len() && lines[closing_row].trim().is_empty() {
428+
closing_row += 1;
429+
}
430+
if closing_row < lines.len() && lines[closing_row].trim() == ")" {
431+
closing_merges.push((closing_row, last_row));
432+
}
433+
}
434+
}
435+
}
436+
437+
closing_merges.sort_by(|a, b| b.0.cmp(&a.0));
438+
for (closing_row, body_row) in closing_merges {
439+
if closing_row >= lines.len() || body_row >= lines.len() {
440+
continue;
441+
}
442+
if lines[closing_row].trim() != ")" {
443+
continue;
444+
}
445+
let trimmed_body = lines[body_row].trim_end().to_string();
446+
lines[body_row] = format!("{trimmed_body})");
447+
lines.remove(closing_row);
448+
}
449+
450+
self.content = lines.join("\n");
451+
self.tree = self.parser.parse(&self.content, Some(&self.tree)).unwrap();
452+
453+
self
454+
}
455+
301456
/// This function removes trailing spaces at the end of lines.
302457
#[inline(always)]
303458
fn fix_trailing_spaces(&mut self) -> &mut Self {
@@ -941,6 +1096,53 @@ struct GdTreeNode {
9411096
children: Vec<usize>,
9421097
}
9431098

1099+
/// Represents a line's indentation using two metrics:
1100+
/// - `spaces`: the total indentation width measured in space units;
1101+
/// - `column`: the byte index of the first non-whitespace character in the line.
1102+
struct IndentInfo {
1103+
spaces: usize,
1104+
column: usize,
1105+
}
1106+
1107+
/// Calculates indentation information for `line`, interpreting tabs using
1108+
/// `indent_size`.
1109+
fn calculate_indent_info(line: &str, indent_size: usize) -> IndentInfo {
1110+
let mut spaces = 0usize;
1111+
1112+
for (idx, ch) in line.char_indices() {
1113+
match ch {
1114+
'\t' => spaces += indent_size,
1115+
' ' => spaces += 1,
1116+
_ => {
1117+
return IndentInfo {
1118+
spaces,
1119+
column: idx,
1120+
};
1121+
}
1122+
}
1123+
}
1124+
1125+
IndentInfo {
1126+
spaces,
1127+
column: line.len(),
1128+
}
1129+
}
1130+
1131+
/// Renders an indentation string with width `indent.spaces` according to the
1132+
/// formatter's configuration.
1133+
fn render_indent(indent: &IndentInfo, indent_size: usize, use_spaces: bool) -> String {
1134+
if use_spaces {
1135+
return " ".repeat(indent.spaces);
1136+
}
1137+
1138+
let tabs = indent.spaces / indent_size;
1139+
let remainder = indent.spaces % indent_size;
1140+
let mut result = String::with_capacity(tabs + remainder);
1141+
result.push_str(&"\t".repeat(tabs));
1142+
result.push_str(&" ".repeat(remainder));
1143+
result
1144+
}
1145+
9441146
/// Calculates end position of the `slice` counting from `start`
9451147
fn calculate_end_position(mut start: Point, slice: &str) -> Point {
9461148
for b in slice.as_bytes() {

src/reorder.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ fn extract_tokens_to_reorder(
358358
// Here we look for inline comments after declarations, and if
359359
// so, we attach them as inline to the declaration. For
360360
// example:
361-
//
361+
//
362362
// var test = 1 # inline comment
363363
//
364364
// Without this code, the comment would wrap to the next line.

tests/expected/lambda.gd

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,11 @@ var f2 = func():
55
var f3 = (func(): print(123))
66
var f4 = (func(): pass)
77
var f5 = (func():
8-
print(123)
9-
print(123)
8+
print(123)
9+
print(123)
10+
)
11+
var f6 = (func():
12+
print(123)
13+
# comment in lambda
14+
print(456)
1015
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
var my_callable: Callable = (
2+
(func():
3+
my_var = 1)
4+
if some_condition else some_function )

tests/input/func_args_inline_comment.gd

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,6 @@ func t(
44
, c
55
)
66

7+
8+
9+

tests/input/lambda.gd

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,8 @@ var f5 = ( func () :
88
print(123)
99
print(123)
1010
)
11+
var f6 = ( func () :
12+
print(123)
13+
# comment in lambda
14+
print(456)
15+
)
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
var my_callable: Callable = (
2+
(func():
3+
my_var = 1)
4+
if some_condition else some_function )

tests/reorder_code/input/reorder_inline_comment.gd

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@ extends Node
55

66
var test = 1 # Inline comment
77

8+
9+
10+

0 commit comments

Comments
 (0)