gdck-format 0.6.0

GDScript formatter, the engine behind `gdck format` (internal)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
//! GDScript formatter.
//!
//! Formatting runs in two stages. Lowering turns the concrete syntax tree
//! into a document describing where lines *may* break, and the renderer decides
//! where they *do*, given the configured width. Keeping those apart means the
//! style-guide rules live in one place instead of being spread across string
//! concatenation.
//!
//! # What the guide asks for
//!
//! Most of it falls out of the document IR: the 100-column wrap, one space
//! around operators and after commas, two blank lines around top-level
//! definitions and one inside a class, trailing commas on collections that
//! break, and two indent levels on continuation lines against one inside
//! arrays, dictionaries and enums.
//!
//! The rest is explicit: quote style chosen to minimise escapes, lowercase
//! hexadecimal, a digit either side of a float's point, single-line inner
//! class declarations, and redundant parentheses dropped. Those live in
//! the `literal` and `lower` modules.
//!
//! # Safety checks
//!
//! Before returning, the formatter re-parses its own output and checks that it
//! still parses, that the tree still means the same thing, that no comment was
//! dropped, and that a second pass is a no-op. A formatter that silently eats code is far
//! worse than one that refuses to run, so these are on by default;
//! [`FormatConfig::safety_checks`] turns them off.

mod doc;
pub mod literal;
mod lower;
mod trivia;

use std::fmt;

use gdck_config::FormatConfig;
use gdck_syntax::{Element, SyntaxKind, SyntaxNode, SyntaxTree};

use crate::lower::Lowerer;
use crate::trivia::Trivia;

/// Why formatting could not be completed.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FormatError {
    /// The input could not be parsed, so there is nothing safe to rewrite.
    Unparseable,
    /// Formatting changed the meaning of the code. Always a bug in `gdck`.
    SafetyCheckFailed(&'static str),
}

impl fmt::Display for FormatError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Unparseable => f.write_str("cannot format a file with syntax errors"),
            Self::SafetyCheckFailed(what) => {
                write!(f, "formatting was rejected by a safety check: {what}")
            }
        }
    }
}

impl std::error::Error for FormatError {}

/// Format a parsed GDScript file.
///
/// # Errors
///
/// Returns [`FormatError::Unparseable`] if the tree holds syntax errors, or
/// [`FormatError::SafetyCheckFailed`] if the output does not survive the
/// checks described on this module.
pub fn format(tree: &SyntaxTree, config: &FormatConfig) -> Result<String, FormatError> {
    if tree.has_errors() {
        return Err(FormatError::Unparseable);
    }

    let output = render(tree, config);

    if !config.safety_checks {
        return Ok(output);
    }

    let reparsed = gdck_syntax::parse(&output);
    if reparsed.has_errors() {
        return Err(FormatError::SafetyCheckFailed(
            "the formatted output does not parse",
        ));
    }
    if canonical(tree) != canonical(&reparsed) {
        return Err(FormatError::SafetyCheckFailed(
            "formatting changed the code",
        ));
    }
    let before = Trivia::collect(tree);
    let after = Trivia::collect(&reparsed);
    if before.all_comments() != after.all_comments() {
        return Err(FormatError::SafetyCheckFailed("a comment was lost"));
    }
    let second = render(&reparsed, config);
    if second != output {
        return Err(FormatError::SafetyCheckFailed(
            "formatting is not idempotent",
        ));
    }

    Ok(output)
}

/// Format source text directly, parsing it first.
///
/// # Errors
///
/// As [`format()`].
pub fn format_source(source: &str, config: &FormatConfig) -> Result<String, FormatError> {
    format(&gdck_syntax::parse(source), config)
}

fn render(tree: &SyntaxTree, config: &FormatConfig) -> String {
    let trivia = Trivia::collect(tree);
    let lowerer = Lowerer::new(tree, &trivia, config.class_declaration);
    let document = lowerer.source_file(tree.root());
    let mut output = doc::render(&document, config.line_length as usize, config.indent);

    // A document always ends with the file's final break; collapse whatever
    // that produced to exactly one line feed.
    while output.ends_with('\n') {
        output.pop();
    }
    if !output.is_empty() {
        output.push('\n');
    }
    output
}

/// One step of a tree's canonical form. See [`canonical`].
#[derive(Debug, Clone, PartialEq, Eq)]
enum Step {
    Enter(SyntaxKind),
    Token(SyntaxKind, String),
    /// The operator of an initializer, spelled the same however it was written.
    Operator(&'static str),
}

/// A canonical form capturing what the program *means*.
///
/// Comparing flat token streams would be simpler, but it would reject the
/// rewrites the style guide asks for: hoisting an inner class's `extends` onto
/// the declaration line moves tokens, and dropping a redundant parenthesis
/// removes them. Comparing tree shape instead is both weaker in the right
/// places and stronger in the important one — grouping is encoded by the
/// nesting, so a parenthesis that actually mattered shows up as a differently
/// shaped expression rather than as two missing tokens.
///
/// Elided deliberately:
///
/// * `ParenExpr`, which only ever expressed grouping the tree already records.
/// * Commas and semicolons, which separate siblings the tree already orders.
/// * The position of an inner class's `extends`, canonicalised to the header.
/// * Literal spelling, since quote style and hexadecimal case may change.
fn canonical(tree: &SyntaxTree) -> Vec<Step> {
    let mut steps = Vec::new();
    walk(tree.root(), tree.text(), &mut steps);
    steps
}

fn walk(node: SyntaxNode<'_>, source: &str, steps: &mut Vec<Step>) {
    match node.kind() {
        // Transparent: its only contribution was grouping, which is now the
        // shape of the tree around it.
        SyntaxKind::ParenExpr => {
            for child in node.child_nodes() {
                walk(child, source, steps);
            }
            return;
        }
        SyntaxKind::ClassDecl => {
            walk_class_decl(node, source, steps);
            return;
        }
        SyntaxKind::Initializer => {
            steps.push(Step::Enter(SyntaxKind::Initializer));
            // `:=`, `: =` and `=` all reduce to which of the two forms it is.
            let inferred = node
                .child_tokens()
                .any(|token| matches!(token.kind, SyntaxKind::ColonEq | SyntaxKind::Colon));
            steps.push(Step::Operator(if inferred { ":=" } else { "=" }));
            for child in node.child_nodes() {
                walk(child, source, steps);
            }
            return;
        }
        _ => {}
    }

    steps.push(Step::Enter(node.kind()));
    for element in node.children() {
        match element {
            Element::Node(id) => walk(node.tree().node(id), source, steps),
            Element::Token(token) => push_token(token, source, steps),
        }
    }
}

/// Emit a class declaration with its `extends` in the header position.
///
/// GDScript allows the parent either there or as the body's first statement,
/// and the formatter moves it, so the comparison has to see both spellings as
/// the same program.
fn walk_class_decl(node: SyntaxNode<'_>, source: &str, steps: &mut Vec<Step>) {
    steps.push(Step::Enter(SyntaxKind::ClassDecl));

    let block = node.child_node_of(SyntaxKind::Block);
    let mut members: Vec<SyntaxNode<'_>> = block
        .map(|block| block.child_nodes().collect())
        .unwrap_or_default();

    let mut extends = node.child_node_of(SyntaxKind::ExtendsDecl);
    if extends.is_none() {
        let body_level = members
            .iter()
            .position(|member| member.kind() == SyntaxKind::ExtendsDecl);
        if let Some(index) = body_level {
            extends = Some(members.remove(index));
        }
    }

    for token in node.child_tokens() {
        push_token(token, source, steps);
    }
    if let Some(extends) = extends {
        walk(extends, source, steps);
    }
    if block.is_some() {
        steps.push(Step::Enter(SyntaxKind::Block));
        for member in members {
            walk(member, source, steps);
        }
    }
}

fn push_token(token: gdck_syntax::Token, source: &str, steps: &mut Vec<Step>) {
    if token.kind.is_trivia()
        || matches!(
            token.kind,
            SyntaxKind::Indent
                | SyntaxKind::Dedent
                | SyntaxKind::Eof
                // Separators the sibling order already records.
                | SyntaxKind::Comma
                | SyntaxKind::Semicolon
        )
    {
        return;
    }
    steps.push(Step::Token(
        token.kind,
        normalize_for_comparison(token, source),
    ));
}

/// Compare literals by value rather than spelling, since the formatter is
/// allowed to change quote style and hexadecimal case.
fn normalize_for_comparison(token: gdck_syntax::Token, source: &str) -> String {
    let text = token.text(source);
    match token.kind {
        SyntaxKind::Int | SyntaxKind::Float => literal::normalize_number(text),
        SyntaxKind::Str
        | SyntaxKind::StringName
        | SyntaxKind::NodePath
        | SyntaxKind::GetNode
        | SyntaxKind::UniqueNode => literal::normalize_string(text),
        _ => text.to_string(),
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn check(source: &str, expected: &str) {
        let formatted = format_source(source, &FormatConfig::default())
            .unwrap_or_else(|error| panic!("failed to format {source:?}: {error}"));
        assert_eq!(formatted, expected, "\ninput was:\n{source}");
    }

    /// Formatting an already-formatted file must change nothing.
    fn check_stable(source: &str) {
        check(source, source);
    }

    #[test]
    fn refuses_to_format_unparseable_input() {
        let tree = gdck_syntax::parse("func f(:\n");
        assert_eq!(
            format(&tree, &FormatConfig::default()),
            Err(FormatError::Unparseable)
        );
    }

    #[test]
    fn an_empty_file_stays_empty() {
        check("", "");
    }

    #[test]
    fn carriage_returns_are_normalised_away() {
        // "Use line feed (LF) characters to break lines, not CRLF or CR."
        check("var x = 1\r\nvar y = 2\r\n", "var x = 1\nvar y = 2\n");
    }

    #[test]
    fn a_file_ends_with_exactly_one_newline() {
        check("var x = 1", "var x = 1\n");
        check("var x = 1\n\n\n", "var x = 1\n");
    }

    #[test]
    fn operators_get_one_space_and_commas_one_after() {
        check(
            "func f():\n\tposition.x=5\n\tmy_array = [4,5,6]\n\tdict [\"key\"] = 5\n\tprint ( \"foo\" )\n",
            "func f():\n\tposition.x = 5\n\tmy_array = [4, 5, 6]\n\tdict[\"key\"] = 5\n\tprint(\"foo\")\n",
        );
    }

    #[test]
    fn an_inner_class_declares_its_parent_on_one_line() {
        // The guide: "For inner classes, use single-line declarations".
        check_stable("class Child extends Parent:\n\tpass\n");
        check(
            "class Child:\n\textends Parent\n\tpass\n",
            "class Child extends Parent:\n\tpass\n",
        );
    }

    #[test]
    fn a_script_description_stays_with_the_header_it_documents() {
        // Godot's rule: a `##` block "must immediately precede a script member,
        // or for script descriptions, be placed at the top of the script". The
        // blank line under it is what says this one is the script's rather than
        // the function's, so the guide's two blank lines go *after* it.
        //
        // It used to be carried along as the function's leading comment, which
        // put two blank lines above it and left the author's one below it —
        // immediately preceding nothing, and no longer at the top either.
        check(
            "extends Node\n## Doc.\n\nfunc f() -> void:\n\tpass\n",
            "extends Node\n## Doc.\n\n\nfunc f() -> void:\n\tpass\n",
        );
        check_stable("extends Node\n## Doc.\n\n\nfunc f() -> void:\n\tpass\n");
    }

    #[test]
    fn a_comment_touching_its_declaration_still_belongs_to_it() {
        // The other half of the same rule. With no blank line the comment does
        // immediately precede the function, so it documents the function and
        // the spacing belongs above the pair.
        check(
            "extends Node\n## Doc.\nfunc f() -> void:\n\tpass\n",
            "extends Node\n\n\n## Doc.\nfunc f() -> void:\n\tpass\n",
        );
    }

    #[test]
    fn a_detached_comment_between_two_functions_keeps_its_distance() {
        // Nothing about this is specific to the top of the file: a note written
        // between two definitions is neither one's, and stays where it was put.
        check_stable(
            "extends Node\n\n\nfunc a() -> void:\n\tpass\n\n\n## A note.\n\n\nfunc b() -> void:\n\tpass\n",
        );
    }

    #[test]
    fn a_file_level_class_declares_its_parent_on_the_next_line() {
        // The counterpart rule: at file level the two are separate lines.
        check(
            "class_name Player extends Node\n",
            "class_name Player\nextends Node\n",
        );
        check_stable("class_name Player\nextends Node\n");
    }

    #[test]
    fn a_project_can_keep_the_joined_class_declaration() {
        // `gdformat` enforces neither form, so a project can be uniformly on
        // this one without having chosen it. Both inputs still converge, or
        // the setting would only be preserving whatever it was given.
        let mut joined = FormatConfig::default();
        joined.class_declaration = gdck_config::ClassDeclaration::SingleLine;
        for source in [
            "class_name Player extends Node\n",
            "class_name Player\nextends Node\n",
        ] {
            assert_eq!(
                format_source(source, &joined).expect("formats"),
                "class_name Player extends Node\n",
                "\ninput was:\n{source}"
            );
        }
        // Nothing to join: `extends` alone is one line under either setting.
        assert_eq!(
            format_source("extends Node\n", &joined).expect("formats"),
            "extends Node\n"
        );
    }

    #[test]
    fn a_comment_between_class_name_and_extends_survives() {
        // It has nowhere to go on a joined line, so it keeps the two apart
        // whatever the setting says. Before this was handled the comment was
        // dropped, and only the safety check noticed.
        let source = "class_name Player\n# why we extend\nextends Node\n";
        check_stable(source);
        let mut joined = FormatConfig::default();
        joined.class_declaration = gdck_config::ClassDeclaration::SingleLine;
        assert_eq!(format_source(source, &joined).expect("formats"), source);
    }

    #[test]
    fn abstract_stays_on_the_inner_class_line() {
        check_stable("@abstract class MyNode extends Node:\n\tpass\n");
    }

    #[test]
    fn a_functions_annotations_take_a_line_each() {
        // How the Godot documentation writes them: `@rpc(...)` above the func,
        // `@export_range(...)` beside the var.
        check_stable("@rpc(\"any_peer\")\nfunc ping() -> void:\n\tpass\n");
        check(
            "@rpc(\"any_peer\") func ping() -> void:\n\tpass\n",
            "@rpc(\"any_peer\")\nfunc ping() -> void:\n\tpass\n",
        );
        check_stable("@export_range(0, 10) var lives = 3\n");
        // `@abstract` is a modifier, and the language reference writes it
        // inline: `@abstract func draw()`.
        check_stable("@abstract\nclass_name Shape\n\n\n@abstract func area() -> float\n");
    }

    #[test]
    fn one_statement_per_line() {
        check(
            "func f():\n\tif flag: print(\"flagged\")\n",
            "func f():\n\tif flag:\n\t\tprint(\"flagged\")\n",
        );
        check("var a = 1; var b = 2\n", "var a = 1\nvar b = 2\n");
    }

    #[test]
    fn the_ternary_operator_is_the_exception_to_that() {
        check_stable("func f():\n\tnext_state = \"idle\" if is_on_floor() else \"fall\"\n");
    }

    #[test]
    fn definitions_get_two_blank_lines_at_file_level() {
        check(
            "func a():\n\tpass\nfunc b():\n\tpass\n",
            "func a():\n\tpass\n\n\nfunc b():\n\tpass\n",
        );
    }

    #[test]
    fn definitions_get_one_blank_line_inside_a_class() {
        // The guide's own example ends with exactly this shape.
        check_stable("class State:\n\tvar foo = 0\n\n\tfunc _init():\n\t\tprint(\"Hello!\")\n");
    }

    #[test]
    fn blank_line_runs_collapse_to_one() {
        check("var a = 1\n\n\n\nvar b = 2\n", "var a = 1\n\nvar b = 2\n");
    }

    #[test]
    fn redundant_parentheses_are_dropped() {
        check(
            "func f():\n\tif (is_colliding()):\n\t\tqueue_free()\n",
            "func f():\n\tif is_colliding():\n\t\tqueue_free()\n",
        );
    }

    #[test]
    fn parentheses_that_carry_meaning_are_kept() {
        check_stable("var x = (a + b) * c\n");
        check_stable("func f():\n\tif (foo and bar) or not baz:\n\t\tprint(\"yes\")\n");
    }

    #[test]
    fn a_single_line_dictionary_gets_spaces_inside_its_braces() {
        check(
            "var my_dictionary = {key = \"value\"}\n",
            "var my_dictionary = { key = \"value\" }\n",
        );
        check_stable("var empty = {}\n");
    }

    #[test]
    fn collections_take_one_indent_level_and_a_trailing_comma() {
        let long = "var party = [\"Godot\", \"Godette\", \"Steve\", \"a name quite long indeed\", \"and one more that certainly pushes it over\"]\n";
        check(
            long,
            "var party = [\n\t\"Godot\",\n\t\"Godette\",\n\t\"Steve\",\n\t\"a name quite long indeed\",\n\t\"and one more that certainly pushes it over\",\n]\n",
        );
    }

    #[test]
    fn a_short_collection_stays_on_one_line_without_a_trailing_comma() {
        check("var array = [1, 2, 3,]\n", "var array = [1, 2, 3]\n");
        // An array the author spread over several lines stays that way, and
        // gains the trailing comma the guide asks for.
        check(
            "var array = [\n\t1,\n\t2\n]\n",
            "var array = [\n\t1,\n\t2,\n]\n",
        );
    }

    #[test]
    fn comments_stay_with_what_they_document() {
        check_stable("# Sets things up.\nfunc _ready():\n\tpass\n");
        check_stable("var x = 1 # why\n");
        // A comment above a definition belongs to it, so the two blank lines
        // go before the comment rather than between it and the function.
        check(
            "var a = 1\n# Documents f.\nfunc f():\n\tpass\n",
            "var a = 1\n\n\n# Documents f.\nfunc f():\n\tpass\n",
        );
    }

    #[test]
    fn a_trailing_comment_keeps_one_space_before_it() {
        check("var x = 1    # why\n", "var x = 1 # why\n");
    }

    #[test]
    fn comments_at_the_end_of_a_file_survive() {
        check_stable("var x = 1\n\n# the end\n");
    }

    #[test]
    fn a_lambda_written_inline_stays_inline() {
        check_stable("var double = func(x): return x * 2\n");
    }

    #[test]
    fn wrapped_expressions_take_two_indent_levels() {
        // The guide: continuation lines use 2 indent levels so they cannot be
        // mistaken for the block that follows.
        check_stable(
            "var position = Vector2(250, 350)\n\n\nfunc f():\n\tif (\n\t\t\tposition.x > 200\n\t\t\tand position.x < 400\n\t\t\tand position.y > 300\n\t\t\tand position.y < 400\n\t):\n\t\tpass\n",
        );
    }

    #[test]
    fn a_multi_line_lambda_keeps_its_block() {
        check_stable(
            "func f():\n\tbutton.pressed.connect(\n\t\t\tfunc() -> void:\n\t\t\t\tdo_something(),\n\t)\n",
        );
    }

    /// The trailing comma above is not cosmetic.
    ///
    /// A lambda body is the one place inside brackets where Godot still tracks
    /// indentation, and it stops again at whatever ends the lambda. Without the
    /// comma the closing bracket's line is the first line after the body, and
    /// Godot then demands it sit at the enclosing statement's indent. One level
    /// of nesting can satisfy that by accident; two cannot, and the file stops
    /// compiling with "Unindent doesn't match the previous indentation level".
    ///
    /// So the comma goes in whenever the list breaks and ends in a lambda
    /// block, rather than only in the arrangement that would otherwise break.
    /// The alternative is output whose validity depends on how deeply the call
    /// happens to be nested.
    #[test]
    fn a_lambda_closing_a_nested_call_ends_with_a_comma() {
        check_stable(
            "func f():\n\
             \tbox.add_child(\n\
             \t\t\tmake_button(\n\
             \t\t\t\t\t\"a long label here to force the formatter to wrap this\",\n\
             \t\t\t\t\tfunc() -> void:\n\
             \t\t\t\t\t\tdo_something(),\n\
             \t\t\t)\n\
             \t)\n",
        );
    }

    #[test]
    fn a_single_line_lambda_gains_no_comma() {
        // It never opens a block, so nothing has to close one.
        check_stable("func f():\n\tbutton.pressed.connect(func(): do_something())\n");
    }

    /// Parentheses around a lambda block close on the body's last line, for the
    /// same reason the comma above exists: what ends the lambda has to sit
    /// there, because Godot is still tracking indentation until it does and a
    /// closing paren on its own line would dedent to a continuation's level
    /// rather than the statement's.
    #[test]
    fn parens_around_a_lambda_block_close_on_its_last_line() {
        // A lambda block opens the call out however short it is, so the paren
        // is always the last thing on the body's line rather than the first on
        // the next one.
        check(
            "func f():\n\tassert((func() -> bool:\n\t\treturn check_something_here()).call())\n",
            "func f():\n\
             \tassert(\n\
             \t\t\t(func() -> bool:\n\
             \t\t\t\treturn check_something_here()).call()\n\
             \t)\n",
        );
        check(
            "func f():\n\
             \tassert((func() -> bool:\n\
             \t\tvar ok: bool = probe_the_thing_for_a_while(argument_one, argument_two)\n\
             \t\treturn ok).call())\n",
            "func f():\n\
             \tassert(\n\
             \t\t\t(func() -> bool:\n\
             \t\t\t\tvar ok: bool = probe_the_thing_for_a_while(argument_one, argument_two)\n\
             \t\t\t\treturn ok).call()\n\
             \t)\n",
        );
    }

    /// A standalone annotation opens or closes a region rather than saying
    /// something about the declaration under it, so there is nothing for it to
    /// sit beside. Godot rejects the attempt with "Expected newline after a
    /// standalone annotation", which is what moving these up beside a `var`
    /// used to produce.
    #[test]
    fn a_standalone_annotation_keeps_its_own_line() {
        check_stable(
            "func f():\n\
             \t@warning_ignore_start(\"integer_division\")\n\
             \tvar halved := total / 2\n\
             \t@warning_ignore_restore(\"integer_division\")\n\
             \treturn halved\n",
        );
        check_stable(
            "@export_category(\"Stats\")\n\
             @export_group(\"Health\", \"health_\")\n\
             var health_max := 10\n\
             @export_subgroup(\"Regen\")\n\
             var health_regen := 1.0\n",
        );
    }

    /// The ones that do describe the declaration below them still move up onto
    /// its line, which is how the Godot documentation writes them.
    #[test]
    fn an_annotation_about_a_variable_stays_beside_it() {
        check(
            "@export_range(0, 10)\nvar lives := 3\n",
            "@export_range(0, 10) var lives := 3\n",
        );
    }

    #[test]
    fn accessors_keep_the_form_they_were_written_in() {
        check_stable("var health = max_health:\n\tset(new_health):\n\t\thealth = new_health\n");
        check_stable("var is_active = true:\n\tset = set_is_active\n");
    }

    /// Godot decides which property form it is reading from the first accessor
    /// and separates the two differently, so the comma is not a matter of
    /// taste. In `set = f, get = g` it is what carries the parser on to the
    /// second accessor: drop it and the property is over, and the `get` line
    /// is rejected with "Expected end of indented block for property".
    #[test]
    fn a_setget_property_keeps_the_comma_between_its_accessors() {
        check_stable("var p:\n\tset = __set,\n\tget = __get\n");
        check_stable("var p:\n\tget = __get,\n\tset = __set\n");
    }

    /// The other form takes no comma at all — Godot never looks for one there,
    /// so emitting it would be a syntax error rather than a redundancy.
    #[test]
    fn block_bodied_accessors_are_not_comma_separated() {
        check_stable("var p:\n\tset(x):\n\t\t_p = x\n\tget:\n\t\treturn _p\n");
    }

    #[test]
    fn the_safety_check_catches_a_lost_comment() {
        // Nothing should trip this; the test exists so the wiring is exercised
        // rather than merely present.
        let tree = gdck_syntax::parse("# a\nvar x = 1  # b\n## c\nfunc f():\n\tpass\n");
        assert!(format(&tree, &FormatConfig::default()).is_ok());
    }

    #[test]
    fn a_comment_moved_onto_its_own_line_keeps_no_inline_space() {
        // A comment between `=` and its value cannot stay there: anything
        // after it on the line would be commented out, including the comma.
        check(
            "var x = {\n\tname = # why\n\t1\n}\n",
            "var x = {\n\t# why\n\tname = 1,\n}\n",
        );
    }

    #[test]
    fn formatting_is_idempotent_on_awkward_input() {
        let source = "class_name A extends B\nvar x={'k':1,}\nfunc f(a,b=2):\n\tif (a): return\n";
        let first = format_source(source, &FormatConfig::default()).expect("formats");
        let second = format_source(&first, &FormatConfig::default()).expect("formats");
        assert_eq!(first, second);
    }
}