gdck-format 0.5.2

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
//! A Wadler-style document IR and its renderer.
//!
//! Lowering produces a [`Doc`] describing *where* a line could break rather
//! than where it does. The renderer then decides, outermost group first,
//! whether each group fits on the remaining width. This is the standard
//! arrangement (Wadler's "A prettier printer", by way of Prettier), and it is
//! what keeps the layout rules in one place instead of scattered through the
//! lowering of every construct.
//!
//! The one GDScript-specific wrinkle is indentation width. The style guide
//! mandates tabs, so the rendered text contains tabs, but line length has to be
//! measured in display columns. [`TAB_WIDTH`] resolves that, and matches the
//! value the lexer uses when comparing indentation depth.

use gdck_config::IndentStyle;

/// Display width of a tab when measuring line length.
///
/// Matches the lexer's value, so what the parser considers one indent level and
/// what the formatter counts as four columns cannot drift apart.
pub(crate) const TAB_WIDTH: usize = 4;

/// A document: text with the possible line breaks marked.
#[derive(Debug, Clone)]
pub(crate) struct Doc {
    kind: DocKind,
    /// Whether this document contains a break that cannot be flattened.
    ///
    /// Cached at construction rather than computed on demand, which keeps
    /// building a document linear in its size instead of quadratic.
    hard: bool,
}

#[derive(Debug, Clone)]
enum DocKind {
    Nil,
    /// Literal text. Must not contain a newline.
    Text(String),
    Concat(Vec<Doc>),
    /// A space when flat, a newline when broken.
    Line,
    /// Nothing when flat, a newline when broken.
    SoftLine,
    /// Always a newline, and forces every enclosing group to break.
    HardLine,
    /// A break candidate: rendered flat if it fits, broken otherwise.
    Group(Box<Doc>),
    /// Adds `levels` indent levels to everything inside.
    Indent(u8, Box<Doc>),
    /// Chooses between two documents based on the enclosing group's mode.
    IfBreak(Box<Doc>, Box<Doc>),
    /// Renders its contents on one line whatever the width.
    Flat(Box<Doc>),
    /// Text that may itself span lines, written out exactly as given.
    Verbatim(String),
}

impl Doc {
    pub(crate) fn nil() -> Self {
        Self {
            kind: DocKind::Nil,
            hard: false,
        }
    }

    /// # Panics
    ///
    /// Panics in debug builds if `text` contains a newline. Line breaks must be
    /// expressed as [`Doc::hard_line`] and friends, or the renderer's column
    /// tracking silently goes wrong.
    pub(crate) fn text(text: impl Into<String>) -> Self {
        let text = text.into();
        debug_assert!(
            !text.contains('\n'),
            "Doc::text must not contain a newline: {text:?}"
        );
        Self {
            kind: DocKind::Text(text),
            hard: false,
        }
    }

    /// Text that may contain newlines of its own, such as a triple-quoted
    /// string.
    ///
    /// The renderer writes it unchanged and resumes counting columns from its
    /// last line, since the earlier ones are no longer the current line.
    pub(crate) fn verbatim(text: impl Into<String>) -> Self {
        Self {
            kind: DocKind::Verbatim(text.into()),
            hard: false,
        }
    }

    /// A literal's text, whichever form it takes.
    pub(crate) fn literal(text: impl Into<String>) -> Self {
        let text = text.into();
        if text.contains('\n') {
            Self::verbatim(text)
        } else {
            Self::text(text)
        }
    }

    pub(crate) fn concat(parts: Vec<Doc>) -> Self {
        let hard = parts.iter().any(|part| part.hard);
        Self {
            kind: DocKind::Concat(parts),
            hard,
        }
    }

    pub(crate) fn line() -> Self {
        Self {
            kind: DocKind::Line,
            hard: false,
        }
    }

    pub(crate) fn soft_line() -> Self {
        Self {
            kind: DocKind::SoftLine,
            hard: false,
        }
    }

    pub(crate) fn hard_line() -> Self {
        Self {
            kind: DocKind::HardLine,
            hard: true,
        }
    }

    /// Emits nothing, but forces every group containing it to break.
    ///
    /// Used where the author's own line breaks are being honoured: the style
    /// guide shows both `var array = [1, 2, 3]` and the same array spread over
    /// four lines as good, so which one a file gets is the author's call and
    /// not something the column limit should overrule.
    pub(crate) fn break_parent() -> Self {
        Self {
            kind: DocKind::Nil,
            hard: true,
        }
    }

    pub(crate) fn group(inner: Doc) -> Self {
        let hard = inner.hard;
        Self {
            kind: DocKind::Group(Box::new(inner)),
            hard,
        }
    }

    pub(crate) fn indent(inner: Doc) -> Self {
        Self::indent_by(1, inner)
    }

    /// Indent by several levels at once.
    ///
    /// The style guide asks for two levels on continuation lines, to tell them
    /// apart from the block that follows, so this is not just `indent` twice
    /// for convenience — it is a rule with its own call sites.
    pub(crate) fn indent_by(levels: u8, inner: Doc) -> Self {
        let hard = inner.hard;
        Self {
            kind: DocKind::Indent(levels, Box::new(inner)),
            hard,
        }
    }

    /// Render `inner` on one line, ignoring the width.
    ///
    /// For the places GDScript gives no legal way to break: a `match` pattern
    /// is not inside brackets, so a line break in one is a syntax error rather
    /// than a long line.
    pub(crate) fn flat(inner: Doc) -> Self {
        Self {
            kind: DocKind::Flat(Box::new(inner)),
            hard: false,
        }
    }

    /// Whether this is the single space used to separate adjacent pieces.
    ///
    /// Lets a caller take a separator back when it turns out a line break is
    /// needed there instead.
    pub(crate) fn is_space(&self) -> bool {
        matches!(&self.kind, DocKind::Text(text) if text == " ")
    }

    pub(crate) fn if_break(broken: Doc, flat: Doc) -> Self {
        // Deliberately not inheriting `hard` from either branch: a document
        // that only appears when its group is already broken cannot itself be
        // the reason for breaking.
        Self {
            kind: DocKind::IfBreak(Box::new(broken), Box::new(flat)),
            hard: false,
        }
    }
}

/// Join `parts` with `separator` between each pair.
pub(crate) fn join(parts: Vec<Doc>, separator: &Doc) -> Doc {
    let mut out = Vec::with_capacity(parts.len().saturating_mul(2));
    for (index, part) in parts.into_iter().enumerate() {
        if index > 0 {
            out.push(separator.clone());
        }
        out.push(part);
    }
    Doc::concat(out)
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Mode {
    Flat,
    Break,
}

/// One unit of pending work: what to render, at what indent, in which mode.
type Cmd<'a> = (u8, Mode, &'a Doc);

/// Render a document to text.
// One dispatch over the document kinds. Splitting it would mean passing the
// output buffer, column and pending indent between helpers, which reads worse
// than the loop does.
#[allow(clippy::too_many_lines)]
pub(crate) fn render(doc: &Doc, width: usize, style: IndentStyle) -> String {
    let unit = match style {
        IndentStyle::Tabs => "\t".to_string(),
        IndentStyle::Spaces(n) => " ".repeat(n as usize),
    };
    let unit_width = match style {
        IndentStyle::Tabs => TAB_WIDTH,
        IndentStyle::Spaces(n) => n as usize,
    };

    let mut out = String::new();
    let mut column = 0usize;
    // Indentation is written just before the next piece of text rather than
    // straight after the newline, so a line with nothing on it stays empty
    // instead of collecting trailing whitespace.
    let mut pending_indent: Option<u8> = None;
    let mut stack: Vec<Cmd<'_>> = vec![(0, Mode::Break, doc)];

    while let Some((indent, mode, doc)) = stack.pop() {
        match &doc.kind {
            DocKind::Nil => {}
            DocKind::Text(text) => {
                if let Some(level) = pending_indent.take() {
                    for _ in 0..level {
                        out.push_str(&unit);
                    }
                }
                out.push_str(text);
                column += display_width(text);
            }
            DocKind::Concat(parts) => {
                for part in parts.iter().rev() {
                    stack.push((indent, mode, part));
                }
            }
            DocKind::Indent(levels, inner) => {
                stack.push((indent.saturating_add(*levels), mode, inner));
            }
            DocKind::Group(inner) => {
                let inner_mode = if mode == Mode::Flat {
                    // Already inside something rendering on one line, so this
                    // group has no say. That is what makes `Doc::flat` a
                    // guarantee rather than a preference.
                    Mode::Flat
                } else if doc.hard {
                    Mode::Break
                } else if fits(
                    width.saturating_sub(column),
                    (indent, Mode::Flat, inner),
                    &stack,
                ) {
                    Mode::Flat
                } else {
                    Mode::Break
                };
                stack.push((indent, inner_mode, inner));
            }
            DocKind::Line => match mode {
                Mode::Flat => {
                    if let Some(level) = pending_indent.take() {
                        for _ in 0..level {
                            out.push_str(&unit);
                        }
                    }
                    out.push(' ');
                    column += 1;
                }
                Mode::Break => {
                    new_line(
                        &mut out,
                        &mut column,
                        &mut pending_indent,
                        indent,
                        unit_width,
                    );
                }
            },
            DocKind::SoftLine => {
                if mode == Mode::Break {
                    new_line(
                        &mut out,
                        &mut column,
                        &mut pending_indent,
                        indent,
                        unit_width,
                    );
                }
            }
            DocKind::HardLine => {
                new_line(
                    &mut out,
                    &mut column,
                    &mut pending_indent,
                    indent,
                    unit_width,
                );
            }
            DocKind::IfBreak(broken, flat) => {
                let chosen = if mode == Mode::Break { broken } else { flat };
                stack.push((indent, mode, chosen));
            }
            DocKind::Flat(inner) => stack.push((indent, Mode::Flat, inner)),
            DocKind::Verbatim(text) => {
                if let Some(level) = pending_indent.take() {
                    for _ in 0..level {
                        out.push_str(&unit);
                    }
                }
                out.push_str(text);
                // Columns resume from whatever follows the last newline; the
                // lines before it are behind us.
                column = match text.rsplit_once('\n') {
                    Some((_, last)) => display_width(last),
                    None => column + display_width(text),
                };
            }
        }
    }

    // A pending indent at the end means the document finished with a line
    // break, which is exactly what we want: no trailing whitespace.
    out
}

fn new_line(
    out: &mut String,
    column: &mut usize,
    pending_indent: &mut Option<u8>,
    indent: u8,
    unit_width: usize,
) {
    out.push('\n');
    *pending_indent = Some(indent);
    *column = indent as usize * unit_width;
}

/// Whether `first`, followed by whatever is already queued, reaches a line
/// break within `remaining` columns.
///
/// Looking past the group being measured matters: `foo(bar)` only fits if the
/// closing parenthesis and everything trailing it fit too.
#[allow(clippy::cast_possible_wrap)]
fn fits(remaining: usize, first: Cmd<'_>, rest: &[Cmd<'_>]) -> bool {
    // The caller passes a saturating subtraction of two column counts, so this
    // is far from the wrapping range; it goes signed so overflow is detectable.
    let mut remaining = remaining as isize;
    let mut queue: Vec<Cmd<'_>> = vec![first];
    // `rest` is a stack, so its last element is the next one to be processed.
    let mut rest_index = rest.len();

    loop {
        if remaining < 0 {
            return false;
        }

        let (indent, mode, doc) = if let Some(cmd) = queue.pop() {
            cmd
        } else {
            // Nothing left of the group being measured, so carry on through
            // what was already queued. `rest` is a stack, so it is read from
            // the end.
            if rest_index == 0 {
                return true;
            }
            rest_index -= 1;
            rest[rest_index]
        };

        match &doc.kind {
            DocKind::Nil => {}
            DocKind::Text(text) => remaining -= display_width(text) as isize,
            DocKind::Concat(parts) => {
                for part in parts.iter().rev() {
                    queue.push((indent, mode, part));
                }
            }
            DocKind::Indent(levels, inner) => {
                queue.push((indent.saturating_add(*levels), mode, inner));
            }
            DocKind::Group(inner) => {
                // A group that must break ends the line, so measuring stops
                // there rather than pretending its contents are flat.
                let inner_mode = if doc.hard { Mode::Break } else { Mode::Flat };
                queue.push((indent, inner_mode, inner));
            }
            DocKind::Line => match mode {
                Mode::Flat => remaining -= 1,
                // Reaching a break means the rest goes on another line.
                Mode::Break => return true,
            },
            DocKind::SoftLine => {
                if mode == Mode::Break {
                    return true;
                }
            }
            DocKind::HardLine => return true,
            DocKind::IfBreak(broken, flat) => {
                let chosen = if mode == Mode::Break { broken } else { flat };
                queue.push((indent, mode, chosen));
            }
            DocKind::Flat(inner) => queue.push((indent, Mode::Flat, inner)),
            DocKind::Verbatim(text) => {
                // A literal that breaks its own line ends the measurement, the
                // same as reaching any other break.
                if text.contains('\n') {
                    return true;
                }
                remaining -= display_width(text) as isize;
            }
        }
    }
}

/// Width of a string in display columns.
///
/// Counts characters rather than grapheme clusters, so a line whose overflow
/// depends on combining marks or East Asian width may be measured slightly
/// short. Getting that exactly right needs a Unicode table, and the cost of
/// being wrong here is one line a couple of columns over the limit.
fn display_width(text: &str) -> usize {
    text.chars()
        .map(|c| if c == '\t' { TAB_WIDTH } else { 1 })
        .sum()
}

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

    fn render_tabs(doc: &Doc, width: usize) -> String {
        render(doc, width, IndentStyle::Tabs)
    }

    #[test]
    fn a_group_that_fits_stays_flat() {
        let doc = Doc::group(Doc::concat(vec![
            Doc::text("f("),
            Doc::soft_line(),
            Doc::text("a"),
            Doc::soft_line(),
            Doc::text(")"),
        ]));
        assert_eq!(render_tabs(&doc, 100), "f(a)");
    }

    #[test]
    fn a_group_that_does_not_fit_breaks() {
        let doc = Doc::group(Doc::concat(vec![
            Doc::text("f("),
            Doc::indent(Doc::concat(vec![Doc::soft_line(), Doc::text("argument")])),
            Doc::soft_line(),
            Doc::text(")"),
        ]));
        assert_eq!(render_tabs(&doc, 5), "f(\n\targument\n)");
    }

    #[test]
    fn a_hard_line_forces_every_enclosing_group() {
        let doc = Doc::group(Doc::concat(vec![
            Doc::text("a"),
            Doc::line(),
            Doc::hard_line(),
            Doc::text("b"),
        ]));
        // Plenty of room, but the hard line still breaks the group, so the
        // soft `line` before it breaks too.
        assert_eq!(render_tabs(&doc, 100), "a\n\nb");
    }

    #[test]
    fn blank_lines_carry_no_trailing_whitespace() {
        // Two breaks in a row leave the middle line genuinely empty, because
        // indentation is written just before text rather than after a newline.
        let doc = Doc::indent(Doc::concat(vec![
            Doc::hard_line(),
            Doc::text("a"),
            Doc::hard_line(),
            Doc::hard_line(),
            Doc::text("b"),
        ]));
        assert_eq!(render_tabs(&doc, 100), "\n\ta\n\n\tb");
    }

    #[test]
    fn if_break_picks_the_branch_matching_the_group() {
        let trailing_comma = Doc::if_break(Doc::text(","), Doc::nil());
        let build = |width| {
            let doc = Doc::group(Doc::concat(vec![
                Doc::text("["),
                Doc::indent(Doc::concat(vec![
                    Doc::soft_line(),
                    Doc::text("1"),
                    trailing_comma.clone(),
                ])),
                Doc::soft_line(),
                Doc::text("]"),
            ]));
            render_tabs(&doc, width)
        };
        assert_eq!(build(100), "[1]");
        assert_eq!(build(2), "[\n\t1,\n]");
    }

    #[test]
    fn fits_accounts_for_text_queued_after_the_group() {
        // The group alone is 5 columns and would fit in 8, but the trailing
        // text pushes the line over, so it has to break.
        let doc = Doc::concat(vec![
            Doc::group(Doc::concat(vec![
                Doc::text("("),
                Doc::soft_line(),
                Doc::text("ab"),
                Doc::soft_line(),
                Doc::text(")"),
            ])),
            Doc::text(" trailing"),
        ]);
        assert_eq!(render_tabs(&doc, 8), "(\nab\n) trailing");
    }

    #[test]
    fn indentation_follows_the_configured_style() {
        let doc = Doc::indent(Doc::concat(vec![Doc::hard_line(), Doc::text("x")]));
        assert_eq!(render(&doc, 100, IndentStyle::Tabs), "\n\tx");
        assert_eq!(render(&doc, 100, IndentStyle::Spaces(4)), "\n    x");
    }

    #[test]
    fn two_indent_levels_are_distinct_from_one() {
        let doc = Doc::indent_by(2, Doc::concat(vec![Doc::hard_line(), Doc::text("x")]));
        assert_eq!(render_tabs(&doc, 100), "\n\t\tx");
    }

    #[test]
    fn a_tab_counts_as_four_columns_when_measuring() {
        // One indent level plus "abcdef" is 4 + 6 = 10 columns, so a width of
        // 9 must break but 10 must not.
        let build = |width| {
            let doc = Doc::indent(Doc::concat(vec![
                Doc::hard_line(),
                Doc::group(Doc::concat(vec![
                    Doc::text("abcdef"),
                    Doc::soft_line(),
                    Doc::text("g"),
                ])),
            ]));
            render_tabs(&doc, width)
        };
        assert_eq!(build(11), "\n\tabcdefg");
        assert_eq!(build(10), "\n\tabcdef\n\tg");
    }
}