Skip to main content

gdscript_syntax/
prepass.rs

1//! WS2 — the indentation pre-pass (the highest-risk module).
2//!
3//! GDScript has Python-like significant indentation. This pass consumes the flat
4//! [`RawToken`] stream from the lexer and injects the synthetic, **zero-width**
5//! `Newline`/`Indent`/`Dedent` markers the parser needs to recover block structure,
6//! while leaving every original byte-carrying token (real tokens **and** trivia)
7//! exactly where it was — so the round-trip stays byte-exact.
8//!
9//! Modeled on Godot's own `gdscript_tokenizer.cpp`
10//! (`plans/PHASE-1-IMPLEMENTATION-PLAYBOOK.md` §WS2), **not** tree-sitter's
11//! `scanner.c`. Key engine-faithful choices:
12//! - **Tab width is a flat `tab_size` (default 4)**, `+1` per space — Godot adds a
13//!   flat `tab_size` per tab, not 8-column tab stops.
14//! - **Bracket suppression:** inside `()`/`[]`/`{}` newlines/indentation are not
15//!   significant (a depth counter pauses marker emission).
16//! - **`\` line continuations** are already merged into single `LineContinuation`
17//!   tokens by the lexer, so splitting logical lines on physical newlines joins them
18//!   for free.
19//! - **Blank / comment-only lines keep indentation state** (no spurious `Dedent`),
20//!   so a column-0 comment inside a body never closes the scope.
21//! - **Two distinct diagnostics** (same-line tab+space mix; cross-line deviation from
22//!   the file's first indent character) — both recover, never abort.
23//!
24//! - **Lambda bodies inside brackets** re-enable indentation. Inside `()[]{}`
25//!   indentation is normally suppressed, but a *multiline lambda* body that lives
26//!   inside an open bracket (e.g. `arr.sort_custom(func(a, b):\n\treturn a < b\n)`)
27//!   must still be a block. We mirror Godot's stack-of-stacks: a line that ends with
28//!   `:` while inside brackets opens a fresh indentation context for the lambda body,
29//!   which closes (restoring the bracket-suppressed context) once a later line dedents
30//!   back to the header's column.
31
32use text_size::{TextRange, TextSize};
33
34use crate::SyntaxKind;
35use crate::lexer::RawToken;
36
37/// Godot's default indentation width for a tab character.
38const TAB_SIZE: u32 = 4;
39
40/// A saved indentation context for a lambda body opened inside brackets. When the
41/// lambda's `:` is reached we stash the surrounding indent stack and start a fresh one
42/// based at the header line's column; the body closes once indentation returns to
43/// `base`, restoring `saved_indent_stack`.
44#[derive(Debug, Clone)]
45struct LambdaCtx {
46    saved_indent_stack: Vec<u32>,
47    base: u32,
48    /// The `bracket_depth` the lambda body lives at (the depth inside its enclosing
49    /// bracket). When a closing bracket drops below this, the body ends — even mid-line,
50    /// when the closer trails the last body statement (`call(func(): … last())`).
51    open_bracket_depth: u32,
52}
53
54/// An indentation diagnostic produced while injecting block-structure markers.
55/// Byte-ranged; mapped into a `gdscript-base` `Diagnostic` by the IDE layer.
56#[derive(Debug, Clone, PartialEq, Eq)]
57pub struct IndentDiagnostic {
58    /// The offending leading-whitespace range.
59    pub range: TextRange,
60    /// A human-readable message (mirrors Godot's wording).
61    pub message: String,
62}
63
64/// Which character a line used for its leading indentation.
65#[derive(Debug, Clone, Copy, PartialEq, Eq)]
66enum IndentChar {
67    Tab,
68    Space,
69}
70
71/// Inject `Newline`/`Indent`/`Dedent` markers into the lexer token stream.
72///
73/// Returns the augmented token stream plus any indentation diagnostics. The output is
74/// still lossless: the injected markers are zero-width, and every input token is
75/// preserved in order.
76#[must_use]
77pub fn run(tokens: &[RawToken], src: &str) -> (Vec<RawToken>, Vec<IndentDiagnostic>) {
78    let mut p = PrePass {
79        src,
80        out: Vec::with_capacity(tokens.len() + 16),
81        diags: Vec::new(),
82        indent_stack: vec![0],
83        bracket_depth: 0,
84        indent_char: None,
85        lambda_stack: Vec::new(),
86    };
87    p.run_lines(tokens);
88    (p.out, p.diags)
89}
90
91struct PrePass<'s> {
92    src: &'s str,
93    out: Vec<RawToken>,
94    diags: Vec<IndentDiagnostic>,
95    indent_stack: Vec<u32>,
96    bracket_depth: u32,
97    indent_char: Option<IndentChar>,
98    /// Active lambda-body indentation contexts (innermost last). Non-empty means
99    /// indentation is significant *despite* being inside brackets.
100    lambda_stack: Vec<LambdaCtx>,
101}
102
103impl PrePass<'_> {
104    fn run_lines(&mut self, tokens: &[RawToken]) {
105        // Split the stream into physical lines (each ends at a `NewlinePhys`, or at
106        // EOF). `\`-continuations are already absorbed into `LineContinuation` tokens,
107        // so a continued logical line is naturally one slice here.
108        let mut start = 0usize;
109        let mut i = 0usize;
110        while i < tokens.len() {
111            if tokens[i].kind == SyntaxKind::NewlinePhys {
112                self.line(&tokens[start..=i]);
113                start = i + 1;
114            }
115            i += 1;
116        }
117        if start < tokens.len() {
118            self.line(&tokens[start..]); // trailing line without a final newline
119        }
120        self.finish(src_end(self.src));
121    }
122
123    /// Process one physical line (the slice may end with a `NewlinePhys`).
124    ///
125    /// Indentation is significant when we are outside all brackets **or** inside a
126    /// lambda body opened within brackets. The logical `Newline` is emitted at the
127    /// terminator when we are at bracket depth 0, inside a lambda body, or the line is
128    /// itself a lambda header (its `:` opens a body block).
129    fn line(&mut self, line: &[RawToken]) {
130        // Blank / comment-only lines keep indentation state — copy verbatim, no
131        // markers (this is what stops a column-0 comment from closing a scope). A line
132        // whose only non-trivia content is the newline is blank too, since
133        // `NewlinePhys` is trivia.
134        let Some(first) = line.iter().find(|t| !t.kind.is_trivia()) else {
135            self.copy_verbatim(line);
136            return;
137        };
138        let col = self.column(line);
139        let at = first.range.start();
140
141        // A line whose first meaningful token is a closing bracket that closes a lambda's enclosing
142        // bracket is a *bracket continuation* — the `)` of `call(func(): … )` on its own dedented
143        // line, which real code often indents BETWEEN the lambda header and its body. Close that body
144        // now by BRACKET DEPTH (not column) so the line is treated as indentation-suppressed and no
145        // spurious INDENT is emitted for where the closer happens to sit. (The column-based
146        // `close_lambdas` below only fires when the line dedents to at-or-below the header column.)
147        if matches!(
148            first.kind,
149            SyntaxKind::RParen | SyntaxKind::RBrace | SyntaxKind::RBrack
150        ) && self
151            .lambda_stack
152            .last()
153            .is_some_and(|ctx| ctx.open_bracket_depth >= self.bracket_depth)
154        {
155            self.close_lambdas_on_bracket(self.bracket_depth.saturating_sub(1), at);
156        }
157
158        // Close any lambda bodies this line has dedented back out of.
159        self.close_lambdas(col, at);
160
161        let in_lambda = !self.lambda_stack.is_empty();
162        let suppressed = !in_lambda && self.bracket_depth > 0;
163
164        // Indentation markers only where indentation is significant.
165        if !suppressed {
166            self.diagnose_indent(line);
167            self.emit_indent_dedent(col, at);
168        }
169
170        // Copy the line's tokens, tracking brackets and the last meaningful token, and
171        // emit a logical Newline at the terminator where appropriate.
172        let mut has_terminator = false;
173        let mut last_meaningful: Option<SyntaxKind> = None;
174        for tok in line {
175            if tok.kind == SyntaxKind::NewlinePhys {
176                has_terminator = true;
177                let opens_lambda =
178                    self.bracket_depth > 0 && last_meaningful == Some(SyntaxKind::Colon);
179                if self.bracket_depth == 0 || in_lambda || opens_lambda {
180                    self.push_marker(SyntaxKind::Newline, tok.range.start());
181                }
182                self.out.push(*tok);
183            } else {
184                // A closing bracket that drops below a lambda body's enclosing depth
185                // ends that body here, even mid-line — emit its `Dedent`s before the
186                // bracket so the parser closes the block at the right place.
187                if matches!(
188                    tok.kind,
189                    SyntaxKind::RParen | SyntaxKind::RBrace | SyntaxKind::RBrack
190                ) && !self.lambda_stack.is_empty()
191                {
192                    let new_depth = self.bracket_depth.saturating_sub(1);
193                    self.close_lambdas_on_bracket(new_depth, tok.range.start());
194                }
195                // A `,` at a lambda body's OWN enclosing bracket depth is the enclosing call's
196                // argument separator (`call(func(): body, next_arg)` — a bare comma can't be valid
197                // lambda-body syntax at that depth), so it ends the body mid-line too. Close by depth,
198                // not column, before the comma.
199                else if tok.kind == SyntaxKind::Comma
200                    && self
201                        .lambda_stack
202                        .last()
203                        .is_some_and(|ctx| ctx.open_bracket_depth == self.bracket_depth)
204                {
205                    self.close_lambdas_on_bracket(
206                        self.bracket_depth.saturating_sub(1),
207                        tok.range.start(),
208                    );
209                }
210                self.out.push(*tok);
211                self.track_bracket(tok.kind);
212                if !tok.kind.is_trivia() {
213                    last_meaningful = Some(tok.kind);
214                }
215            }
216        }
217        // A final line with content but no trailing newline still terminates a statement.
218        if !has_terminator && (self.bracket_depth == 0 || in_lambda) {
219            self.push_marker(SyntaxKind::Newline, src_end(self.src));
220        }
221
222        // A line that ends with `:` while inside brackets is a lambda header: open a
223        // fresh indentation context for its body, based at this line's column.
224        if self.bracket_depth > 0 && last_meaningful == Some(SyntaxKind::Colon) {
225            let saved = std::mem::replace(&mut self.indent_stack, vec![col]);
226            self.lambda_stack.push(LambdaCtx {
227                saved_indent_stack: saved,
228                base: col,
229                open_bracket_depth: self.bracket_depth,
230            });
231        }
232    }
233
234    /// Close every lambda body whose base column is `>= col` (i.e. that this line has
235    /// dedented out of), emitting the `Dedent`s for its body and restoring the
236    /// surrounding indentation context.
237    fn close_lambdas(&mut self, col: u32, at: TextSize) {
238        while self.lambda_stack.last().is_some_and(|ctx| col <= ctx.base) {
239            let base = self.lambda_stack.last().expect("checked").base;
240            while *self.indent_stack.last().expect("lambda base present") > base {
241                self.indent_stack.pop();
242                self.push_marker(SyntaxKind::Dedent, at);
243            }
244            let ctx = self.lambda_stack.pop().expect("checked");
245            self.indent_stack = ctx.saved_indent_stack;
246        }
247    }
248
249    /// Close lambda bodies whose enclosing bracket has just closed — a `)`/`]`/`}` that
250    /// drops `bracket_depth` to `new_depth` *mid-line*. Mirrors [`Self::close_lambdas`]
251    /// but is keyed on bracket depth instead of column, for the case where the closer
252    /// trails the last body statement on one line (`call(func(): … last())`). The
253    /// column-based path already handles a closer that sits on its own dedented line; a
254    /// lambda is only ever popped once, so the two paths never double-close.
255    fn close_lambdas_on_bracket(&mut self, new_depth: u32, at: TextSize) {
256        while self
257            .lambda_stack
258            .last()
259            .is_some_and(|ctx| ctx.open_bracket_depth > new_depth)
260        {
261            let base = self.lambda_stack.last().expect("checked").base;
262            while *self.indent_stack.last().expect("lambda base present") > base {
263                self.indent_stack.pop();
264                self.push_marker(SyntaxKind::Dedent, at);
265            }
266            let ctx = self.lambda_stack.pop().expect("checked");
267            self.indent_stack = ctx.saved_indent_stack;
268        }
269    }
270
271    /// Copy a blank / comment-only line's tokens unchanged (no structural markers),
272    /// only updating bracket depth so an open multiline literal stays open across it.
273    fn copy_verbatim(&mut self, line: &[RawToken]) {
274        for tok in line {
275            self.out.push(*tok);
276            if tok.kind != SyntaxKind::NewlinePhys {
277                self.track_bracket(tok.kind);
278            }
279        }
280    }
281
282    /// Compare `col` to the indent stack and push `Indent` / `Dedent` markers.
283    fn emit_indent_dedent(&mut self, col: u32, at: TextSize) {
284        let top = *self.indent_stack.last().expect("indent stack has a base 0");
285        if col > top {
286            self.indent_stack.push(col);
287            self.push_marker(SyntaxKind::Indent, at);
288        } else if col < top {
289            while *self.indent_stack.last().expect("base 0 guards the loop") > col {
290                self.indent_stack.pop();
291                self.push_marker(SyntaxKind::Dedent, at);
292            }
293            if *self.indent_stack.last().expect("non-empty") != col {
294                self.diags.push(IndentDiagnostic {
295                    range: TextRange::empty(at),
296                    message: "Unindent does not match any outer indentation level.".to_owned(),
297                });
298                self.indent_stack.push(col); // resync and keep going
299            }
300        }
301    }
302
303    /// The leading-whitespace column of a line (Godot's flat `tab_size` per tab, `+1`
304    /// per space). Pure — used for the lambda-context bookkeeping before deciding
305    /// whether to diagnose.
306    fn column(&self, line: &[RawToken]) -> u32 {
307        let Some(ws) = line.first().filter(|t| t.kind == SyntaxKind::Whitespace) else {
308            return 0;
309        };
310        self.src[ws.range]
311            .bytes()
312            .fold(0u32, |col, b| col + if b == b'\t' { TAB_SIZE } else { 1 })
313    }
314
315    /// Record any tab/space indentation diagnostics for a line (same-line mix;
316    /// cross-line inconsistency with the file's first indent character).
317    fn diagnose_indent(&mut self, line: &[RawToken]) {
318        let Some(ws) = line.first().filter(|t| t.kind == SyntaxKind::Whitespace) else {
319            return;
320        };
321        let text = &self.src[ws.range];
322        let mut saw_tab = false;
323        let mut saw_space = false;
324        for b in text.bytes() {
325            saw_tab |= b == b'\t';
326            saw_space |= b == b' ';
327        }
328        if saw_tab && saw_space {
329            self.diags.push(IndentDiagnostic {
330                range: ws.range,
331                message: "Mixed use of tabs and spaces for indentation.".to_owned(),
332            });
333        } else if let Some(first) = text.bytes().next() {
334            let this = if first == b'\t' {
335                IndentChar::Tab
336            } else {
337                IndentChar::Space
338            };
339            match self.indent_char {
340                None => self.indent_char = Some(this),
341                Some(file) if file != this => {
342                    let (used, before) = match this {
343                        IndentChar::Tab => ("tab", "space"),
344                        IndentChar::Space => ("space", "tab"),
345                    };
346                    self.diags.push(IndentDiagnostic {
347                        range: ws.range,
348                        message: format!(
349                            "Used {used} character for indentation instead of {before} as used before in the file."
350                        ),
351                    });
352                }
353                Some(_) => {}
354            }
355        }
356    }
357
358    /// At end of input, close any still-open lambda bodies, then terminate any open
359    /// block by popping the indent stack to 0.
360    fn finish(&mut self, at: TextSize) {
361        self.close_lambdas(0, at); // col 0 <= every base, so all lambdas close
362        while *self.indent_stack.last().expect("base 0") > 0 {
363            self.indent_stack.pop();
364            self.push_marker(SyntaxKind::Dedent, at);
365        }
366    }
367
368    fn track_bracket(&mut self, kind: SyntaxKind) {
369        match kind {
370            SyntaxKind::LParen | SyntaxKind::LBrack | SyntaxKind::LBrace => {
371                self.bracket_depth += 1;
372            }
373            SyntaxKind::RParen | SyntaxKind::RBrack | SyntaxKind::RBrace => {
374                self.bracket_depth = self.bracket_depth.saturating_sub(1);
375            }
376            _ => {}
377        }
378    }
379
380    fn push_marker(&mut self, kind: SyntaxKind, at: TextSize) {
381        self.out.push(RawToken {
382            kind,
383            range: TextRange::empty(at),
384        });
385    }
386}
387
388/// The end-of-source offset as a `TextSize`.
389fn src_end(src: &str) -> TextSize {
390    TextSize::of(src)
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::tokenize;
397
398    fn prepass(src: &str) -> Vec<RawToken> {
399        run(&tokenize(src), src).0
400    }
401
402    /// Non-trivia kind sequence — shows the synthetic markers + real tokens, hiding
403    /// whitespace/comment noise.
404    fn structure(src: &str) -> Vec<SyntaxKind> {
405        prepass(src)
406            .into_iter()
407            .filter(|t| !t.kind.is_trivia())
408            .map(|t| t.kind)
409            .collect()
410    }
411
412    fn diagnostics(src: &str) -> Vec<IndentDiagnostic> {
413        run(&tokenize(src), src).1
414    }
415
416    /// The pre-pass must remain byte-exact: zero-width markers contribute nothing and
417    /// every original token is preserved.
418    fn assert_lossless(src: &str) {
419        let rebuilt: String = prepass(src).iter().map(|t| &src[t.range]).collect();
420        assert_eq!(rebuilt, src, "prepass not lossless for {src:?}");
421    }
422
423    fn count(src: &str, kind: SyntaxKind) -> usize {
424        structure(src).into_iter().filter(|&k| k == kind).count()
425    }
426
427    #[test]
428    fn nested_func_if_drives_indent_dedent() {
429        use SyntaxKind as S;
430        let src = "func f():\n\tif x:\n\t\treturn\n";
431        assert_lossless(src);
432        assert_eq!(
433            structure(src),
434            vec![
435                S::FuncKw,
436                S::Ident,
437                S::LParen,
438                S::RParen,
439                S::Colon,
440                S::Newline,
441                S::Indent,
442                S::IfKw,
443                S::Ident,
444                S::Colon,
445                S::Newline,
446                S::Indent,
447                S::ReturnKw,
448                S::Newline,
449                S::Dedent,
450                S::Dedent,
451            ]
452        );
453    }
454
455    #[test]
456    fn line_continuation_does_not_indent() {
457        // Case 1: a `\`-continued line never produces Newline/Indent mid-expression.
458        let src = "a = 1 + \\\n  2\n";
459        assert_lossless(src);
460        assert_eq!(count(src, SyntaxKind::Indent), 0);
461        assert_eq!(count(src, SyntaxKind::Newline), 1); // exactly one logical line
462    }
463
464    #[test]
465    fn multiline_brackets_suppress_indentation() {
466        // Case 2: newlines inside [] are not significant.
467        let src = "var a = [\n\t1,\n\t2,\n]\n";
468        assert_lossless(src);
469        assert_eq!(count(src, SyntaxKind::Indent), 0);
470        assert_eq!(count(src, SyntaxKind::Dedent), 0);
471        assert_eq!(count(src, SyntaxKind::Newline), 1); // one logical statement
472    }
473
474    #[test]
475    fn top_level_lambda_body_indents() {
476        // Case 4: a multiline lambda body at statement level indents normally.
477        use SyntaxKind as S;
478        let src = "var f = func():\n\tprint()\nx = 1\n";
479        assert_lossless(src);
480        assert_eq!(count(src, S::Indent), 1);
481        assert_eq!(count(src, S::Dedent), 1);
482    }
483
484    #[test]
485    fn blank_and_comment_only_lines_keep_state() {
486        // Cases 7 & 8: blank lines and a column-0 comment must not close the block.
487        let src = "func f():\n\tx = 1\n\n# top-level comment\n\ty = 2\n";
488        assert_lossless(src);
489        // Only one Indent (into the body) and one Dedent (at EOF) — the blank and the
490        // column-0 comment do not emit a Dedent.
491        assert_eq!(count(src, SyntaxKind::Indent), 1);
492        assert_eq!(count(src, SyntaxKind::Dedent), 1);
493    }
494
495    #[test]
496    fn inline_block_has_no_indent() {
497        // Case 9: `func f(): return 1` on one line never produces an Indent.
498        let src = "func f(): return 1\n";
499        assert_lossless(src);
500        assert_eq!(count(src, SyntaxKind::Indent), 0);
501        assert_eq!(count(src, SyntaxKind::Newline), 1);
502    }
503
504    #[test]
505    fn dedent_to_eof_without_trailing_newline() {
506        // Case 11: file ends mid-nest with no trailing newline.
507        use SyntaxKind as S;
508        let src = "func f():\n\tpass";
509        assert_lossless(src);
510        let s = structure(src);
511        assert_eq!(s.last(), Some(&S::Dedent));
512        assert_eq!(count(src, S::Indent), 1);
513        assert_eq!(count(src, S::Dedent), 1);
514        // The final unterminated line still gets a logical Newline.
515        assert!(s.contains(&S::Newline));
516    }
517
518    #[test]
519    fn empty_and_comment_only_files() {
520        // Case 12.
521        assert_lossless("");
522        assert_eq!(structure(""), Vec::<SyntaxKind>::new());
523        assert_lossless("# just a comment\n");
524        assert_eq!(count("# just a comment\n", SyntaxKind::Indent), 0);
525    }
526
527    #[test]
528    fn mixed_tabs_and_spaces_diagnoses_but_recovers() {
529        // Case 6: a tab+space mix on one indentation run is flagged, not fatal.
530        let src = "func f():\n \tpass\n";
531        assert_lossless(src);
532        let diags = diagnostics(src);
533        assert!(
534            diags
535                .iter()
536                .any(|d| d.message.contains("Mixed use of tabs and spaces")),
537            "expected a mixed-indent diagnostic, got {diags:?}"
538        );
539    }
540
541    #[test]
542    fn inconsistent_indent_char_across_lines_is_flagged() {
543        // First indented line uses a tab; a later one uses spaces → file-consistency
544        // diagnostic (first char wins).
545        let src = "func f():\n\ta = 1\nfunc g():\n    b = 2\n";
546        let diags = diagnostics(src);
547        assert!(
548            diags.iter().any(|d| d.message.contains("instead of")),
549            "expected an inconsistent-indent diagnostic, got {diags:?}"
550        );
551    }
552
553    #[test]
554    fn match_block_nests() {
555        use SyntaxKind as S;
556        let src = "match x:\n\t1:\n\t\tpass\n";
557        assert_lossless(src);
558        assert_eq!(count(src, S::Indent), 2);
559        assert_eq!(count(src, S::Dedent), 2);
560        assert_eq!(structure(src)[0], S::MatchKw);
561    }
562
563    #[test]
564    fn multiline_lambda_inside_brackets_indents() {
565        // A multiline lambda body inside an open `(` re-enables indentation.
566        use SyntaxKind as S;
567        let src = "arr.sort_custom(func(a, b):\n\treturn a < b\n)\n";
568        assert_lossless(src);
569        assert_eq!(count(src, S::Indent), 1, "lambda body should Indent once");
570        assert_eq!(count(src, S::Dedent), 1, "lambda body should Dedent once");
571        // One logical statement (the call), terminated after the closing `)`.
572        let s = structure(src);
573        // The Indent comes right after the lambda's `:` + Newline.
574        let colon = s.iter().position(|&k| k == S::Colon).unwrap();
575        assert_eq!(s[colon + 1], S::Newline);
576        assert_eq!(s[colon + 2], S::Indent);
577        // The Dedent comes before the closing `)`.
578        let rparen = s.iter().rposition(|&k| k == S::RParen).unwrap();
579        assert_eq!(s[rparen - 1], S::Dedent);
580    }
581
582    #[test]
583    fn lambda_inside_multiline_array() {
584        // A lambda living inside a multiline `[ ]` literal.
585        use SyntaxKind as S;
586        let src = "var a = [\n\tfunc():\n\t\tprint()\n]\n";
587        assert_lossless(src);
588        assert_eq!(count(src, S::Indent), 1);
589        assert_eq!(count(src, S::Dedent), 1);
590    }
591
592    #[test]
593    fn nested_lambdas_inside_brackets() {
594        use SyntaxKind as S;
595        let src = "outer(func():\n\tinner(func():\n\t\tbody\n\t)\n)\n";
596        assert_lossless(src);
597        assert_eq!(count(src, S::Indent), 2, "two nested lambda bodies");
598        assert_eq!(count(src, S::Dedent), 2);
599    }
600
601    #[test]
602    fn single_line_lambda_inside_brackets_has_no_indent() {
603        // The body is on the header line → no Indent/Dedent, one statement.
604        use SyntaxKind as S;
605        let src = "arr.map(func(x): x * 2)\n";
606        assert_lossless(src);
607        assert_eq!(count(src, S::Indent), 0);
608        assert_eq!(count(src, S::Dedent), 0);
609        assert_eq!(count(src, S::Newline), 1);
610    }
611}