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 suppressed = self.indentation_suppressed();
162        // Whether this physical line, when it ends with `:` inside brackets, is a *lambda header*
163        // (`… func(params) [-> Type]:`) rather than a dict entry whose value sits on the next line
164        // (`"key":\n value`). Both end with `:` inside brackets, but only the lambda opens a body
165        // block; a dict-entry colon must keep its newline suppressed so the value continues the entry.
166        let is_lambda_header = line_is_lambda_header(line);
167
168        // Indentation markers only where indentation is significant.
169        if !suppressed {
170            self.diagnose_indent(line);
171            self.emit_indent_dedent(col, at);
172        }
173
174        // Copy the line's tokens, tracking brackets, and emit a logical Newline at the terminator
175        // where appropriate.
176        let mut has_terminator = false;
177        for tok in line {
178            if tok.kind == SyntaxKind::NewlinePhys {
179                has_terminator = true;
180                let opens_lambda = self.bracket_depth > 0 && is_lambda_header;
181                // Emit a logical `Newline` at a significant statement boundary — re-evaluating
182                // suppression with the *current* bracket/lambda state (not the line-start value): a
183                // lambda body that closed mid-line (`func(): … return X,`) is no longer significant,
184                // and a line that sits inside a bracket nested *within* the lambda body
185                // (`return new(\n …\n)`) stays suppressed too.
186                if !self.indentation_suppressed() || opens_lambda {
187                    self.push_marker(SyntaxKind::Newline, tok.range.start());
188                }
189                self.out.push(*tok);
190            } else {
191                // A closing bracket that drops below a lambda body's enclosing depth
192                // ends that body here, even mid-line — emit its `Dedent`s before the
193                // bracket so the parser closes the block at the right place.
194                if matches!(
195                    tok.kind,
196                    SyntaxKind::RParen | SyntaxKind::RBrace | SyntaxKind::RBrack
197                ) && !self.lambda_stack.is_empty()
198                {
199                    let new_depth = self.bracket_depth.saturating_sub(1);
200                    self.close_lambdas_on_bracket(new_depth, tok.range.start());
201                }
202                // A `,` at a lambda body's OWN enclosing bracket depth is the enclosing call's
203                // argument separator (`call(func(): body, next_arg)` — a bare comma can't be valid
204                // lambda-body syntax at that depth), so it ends the body mid-line too. Close by depth,
205                // not column, before the comma.
206                else if tok.kind == SyntaxKind::Comma
207                    && self
208                        .lambda_stack
209                        .last()
210                        .is_some_and(|ctx| ctx.open_bracket_depth == self.bracket_depth)
211                {
212                    self.close_lambdas_on_bracket(
213                        self.bracket_depth.saturating_sub(1),
214                        tok.range.start(),
215                    );
216                }
217                self.out.push(*tok);
218                self.track_bracket(tok.kind);
219            }
220        }
221        // A final line with content but no trailing newline still terminates a statement.
222        if !has_terminator && !self.indentation_suppressed() {
223            self.push_marker(SyntaxKind::Newline, src_end(self.src));
224        }
225
226        // A lambda header inside brackets opens a fresh indentation context for its body, based at
227        // this line's column. (A dict-entry colon with the value on the next line is *not* a header.)
228        if self.bracket_depth > 0 && is_lambda_header {
229            let saved = std::mem::replace(&mut self.indent_stack, vec![col]);
230            self.lambda_stack.push(LambdaCtx {
231                saved_indent_stack: saved,
232                base: col,
233                open_bracket_depth: self.bracket_depth,
234            });
235        }
236    }
237
238    /// Whether indentation / logical newlines are currently *not* significant. Outside all lambdas
239    /// that is any open bracket; inside a lambda body it is only a bracket nested *deeper* than the
240    /// lambda's own level (the body itself, at the lambda's bracket depth, stays significant).
241    fn indentation_suppressed(&self) -> bool {
242        match self.lambda_stack.last() {
243            Some(ctx) => self.bracket_depth > ctx.open_bracket_depth,
244            None => self.bracket_depth > 0,
245        }
246    }
247
248    /// Close every lambda body whose base column is `>= col` (i.e. that this line has
249    /// dedented out of), emitting the `Dedent`s for its body and restoring the
250    /// surrounding indentation context.
251    fn close_lambdas(&mut self, col: u32, at: TextSize) {
252        while self.lambda_stack.last().is_some_and(|ctx| col <= ctx.base) {
253            let base = self.lambda_stack.last().expect("checked").base;
254            while *self.indent_stack.last().expect("lambda base present") > base {
255                self.indent_stack.pop();
256                self.push_marker(SyntaxKind::Dedent, at);
257            }
258            let ctx = self.lambda_stack.pop().expect("checked");
259            self.indent_stack = ctx.saved_indent_stack;
260        }
261    }
262
263    /// Close lambda bodies whose enclosing bracket has just closed — a `)`/`]`/`}` that
264    /// drops `bracket_depth` to `new_depth` *mid-line*. Mirrors [`Self::close_lambdas`]
265    /// but is keyed on bracket depth instead of column, for the case where the closer
266    /// trails the last body statement on one line (`call(func(): … last())`). The
267    /// column-based path already handles a closer that sits on its own dedented line; a
268    /// lambda is only ever popped once, so the two paths never double-close.
269    fn close_lambdas_on_bracket(&mut self, new_depth: u32, at: TextSize) {
270        while self
271            .lambda_stack
272            .last()
273            .is_some_and(|ctx| ctx.open_bracket_depth > new_depth)
274        {
275            let base = self.lambda_stack.last().expect("checked").base;
276            while *self.indent_stack.last().expect("lambda base present") > base {
277                self.indent_stack.pop();
278                self.push_marker(SyntaxKind::Dedent, at);
279            }
280            let ctx = self.lambda_stack.pop().expect("checked");
281            self.indent_stack = ctx.saved_indent_stack;
282        }
283    }
284
285    /// Copy a blank / comment-only line's tokens unchanged (no structural markers),
286    /// only updating bracket depth so an open multiline literal stays open across it.
287    fn copy_verbatim(&mut self, line: &[RawToken]) {
288        for tok in line {
289            self.out.push(*tok);
290            if tok.kind != SyntaxKind::NewlinePhys {
291                self.track_bracket(tok.kind);
292            }
293        }
294    }
295
296    /// Compare `col` to the indent stack and push `Indent` / `Dedent` markers.
297    fn emit_indent_dedent(&mut self, col: u32, at: TextSize) {
298        let top = *self.indent_stack.last().expect("indent stack has a base 0");
299        if col > top {
300            self.indent_stack.push(col);
301            self.push_marker(SyntaxKind::Indent, at);
302        } else if col < top {
303            while *self.indent_stack.last().expect("base 0 guards the loop") > col {
304                self.indent_stack.pop();
305                self.push_marker(SyntaxKind::Dedent, at);
306            }
307            if *self.indent_stack.last().expect("non-empty") != col {
308                self.diags.push(IndentDiagnostic {
309                    range: TextRange::empty(at),
310                    message: "Unindent does not match any outer indentation level.".to_owned(),
311                });
312                self.indent_stack.push(col); // resync and keep going
313            }
314        }
315    }
316
317    /// The leading-whitespace column of a line (Godot's flat `tab_size` per tab, `+1`
318    /// per space). Pure — used for the lambda-context bookkeeping before deciding
319    /// whether to diagnose.
320    fn column(&self, line: &[RawToken]) -> u32 {
321        let Some(ws) = line.first().filter(|t| t.kind == SyntaxKind::Whitespace) else {
322            return 0;
323        };
324        self.src[ws.range]
325            .bytes()
326            .fold(0u32, |col, b| col + if b == b'\t' { TAB_SIZE } else { 1 })
327    }
328
329    /// Record any tab/space indentation diagnostics for a line (same-line mix;
330    /// cross-line inconsistency with the file's first indent character).
331    fn diagnose_indent(&mut self, line: &[RawToken]) {
332        let Some(ws) = line.first().filter(|t| t.kind == SyntaxKind::Whitespace) else {
333            return;
334        };
335        let text = &self.src[ws.range];
336        let mut saw_tab = false;
337        let mut saw_space = false;
338        for b in text.bytes() {
339            saw_tab |= b == b'\t';
340            saw_space |= b == b' ';
341        }
342        if saw_tab && saw_space {
343            self.diags.push(IndentDiagnostic {
344                range: ws.range,
345                message: "Mixed use of tabs and spaces for indentation.".to_owned(),
346            });
347        } else if let Some(first) = text.bytes().next() {
348            let this = if first == b'\t' {
349                IndentChar::Tab
350            } else {
351                IndentChar::Space
352            };
353            match self.indent_char {
354                None => self.indent_char = Some(this),
355                Some(file) if file != this => {
356                    let (used, before) = match this {
357                        IndentChar::Tab => ("tab", "space"),
358                        IndentChar::Space => ("space", "tab"),
359                    };
360                    self.diags.push(IndentDiagnostic {
361                        range: ws.range,
362                        message: format!(
363                            "Used {used} character for indentation instead of {before} as used before in the file."
364                        ),
365                    });
366                }
367                Some(_) => {}
368            }
369        }
370    }
371
372    /// At end of input, close any still-open lambda bodies, then terminate any open
373    /// block by popping the indent stack to 0.
374    fn finish(&mut self, at: TextSize) {
375        self.close_lambdas(0, at); // col 0 <= every base, so all lambdas close
376        while *self.indent_stack.last().expect("base 0") > 0 {
377            self.indent_stack.pop();
378            self.push_marker(SyntaxKind::Dedent, at);
379        }
380    }
381
382    fn track_bracket(&mut self, kind: SyntaxKind) {
383        match kind {
384            SyntaxKind::LParen | SyntaxKind::LBrack | SyntaxKind::LBrace => {
385                self.bracket_depth += 1;
386            }
387            SyntaxKind::RParen | SyntaxKind::RBrack | SyntaxKind::RBrace => {
388                self.bracket_depth = self.bracket_depth.saturating_sub(1);
389            }
390            _ => {}
391        }
392    }
393
394    fn push_marker(&mut self, kind: SyntaxKind, at: TextSize) {
395        self.out.push(RawToken {
396            kind,
397            range: TextRange::empty(at),
398        });
399    }
400}
401
402/// The end-of-source offset as a `TextSize`.
403fn src_end(src: &str) -> TextSize {
404    TextSize::of(src)
405}
406
407/// Whether a physical line is a **lambda header** — `… func(params) [-> Type]:` ending in `:`.
408///
409/// Used to distinguish a lambda whose body follows on the next line (its `:` opens an indented
410/// block) from a dict entry whose value sits on the next line (`"key":\n value`), since both end
411/// with `:` inside brackets. We find the last `func` keyword and require that what follows is a
412/// balanced parameter list, then only an optional `-> Type` return annotation, then the line's
413/// terminal `:` — i.e. no further `:` (which would mean an inline lambda body or a dict colon owns
414/// the terminal one).
415fn line_is_lambda_header(line: &[RawToken]) -> bool {
416    use SyntaxKind as S;
417    let toks: Vec<S> = line
418        .iter()
419        .map(|t| t.kind)
420        .filter(|k| !k.is_trivia())
421        .collect();
422    if toks.last() != Some(&S::Colon) {
423        return false;
424    }
425    let Some(func_pos) = toks.iter().rposition(|&k| k == S::FuncKw) else {
426        return false;
427    };
428    // `func` is followed by an optional name (named lambda) then the parameter list `(...)`.
429    let mut i = func_pos + 1;
430    if toks.get(i) == Some(&S::Ident) {
431        i += 1;
432    }
433    if toks.get(i) != Some(&S::LParen) {
434        return false;
435    }
436    let mut depth = 0u32;
437    while i < toks.len() {
438        match toks[i] {
439            S::LParen => depth += 1,
440            S::RParen => {
441                depth -= 1;
442                if depth == 0 {
443                    i += 1;
444                    break;
445                }
446            }
447            _ => {}
448        }
449        i += 1;
450    }
451    if depth != 0 {
452        return false;
453    }
454    // Between the params' `)` and the terminal `:` only a `-> Type` may appear — no other colon.
455    let last = toks.len() - 1;
456    !toks[i..last].contains(&S::Colon)
457}
458
459#[cfg(test)]
460mod tests {
461    use super::*;
462    use crate::tokenize;
463
464    fn prepass(src: &str) -> Vec<RawToken> {
465        run(&tokenize(src), src).0
466    }
467
468    /// Non-trivia kind sequence — shows the synthetic markers + real tokens, hiding
469    /// whitespace/comment noise.
470    fn structure(src: &str) -> Vec<SyntaxKind> {
471        prepass(src)
472            .into_iter()
473            .filter(|t| !t.kind.is_trivia())
474            .map(|t| t.kind)
475            .collect()
476    }
477
478    fn diagnostics(src: &str) -> Vec<IndentDiagnostic> {
479        run(&tokenize(src), src).1
480    }
481
482    /// The pre-pass must remain byte-exact: zero-width markers contribute nothing and
483    /// every original token is preserved.
484    fn assert_lossless(src: &str) {
485        let rebuilt: String = prepass(src).iter().map(|t| &src[t.range]).collect();
486        assert_eq!(rebuilt, src, "prepass not lossless for {src:?}");
487    }
488
489    fn count(src: &str, kind: SyntaxKind) -> usize {
490        structure(src).into_iter().filter(|&k| k == kind).count()
491    }
492
493    #[test]
494    fn nested_func_if_drives_indent_dedent() {
495        use SyntaxKind as S;
496        let src = "func f():\n\tif x:\n\t\treturn\n";
497        assert_lossless(src);
498        assert_eq!(
499            structure(src),
500            vec![
501                S::FuncKw,
502                S::Ident,
503                S::LParen,
504                S::RParen,
505                S::Colon,
506                S::Newline,
507                S::Indent,
508                S::IfKw,
509                S::Ident,
510                S::Colon,
511                S::Newline,
512                S::Indent,
513                S::ReturnKw,
514                S::Newline,
515                S::Dedent,
516                S::Dedent,
517            ]
518        );
519    }
520
521    #[test]
522    fn line_continuation_does_not_indent() {
523        // Case 1: a `\`-continued line never produces Newline/Indent mid-expression.
524        let src = "a = 1 + \\\n  2\n";
525        assert_lossless(src);
526        assert_eq!(count(src, SyntaxKind::Indent), 0);
527        assert_eq!(count(src, SyntaxKind::Newline), 1); // exactly one logical line
528    }
529
530    #[test]
531    fn multiline_brackets_suppress_indentation() {
532        // Case 2: newlines inside [] are not significant.
533        let src = "var a = [\n\t1,\n\t2,\n]\n";
534        assert_lossless(src);
535        assert_eq!(count(src, SyntaxKind::Indent), 0);
536        assert_eq!(count(src, SyntaxKind::Dedent), 0);
537        assert_eq!(count(src, SyntaxKind::Newline), 1); // one logical statement
538    }
539
540    #[test]
541    fn top_level_lambda_body_indents() {
542        // Case 4: a multiline lambda body at statement level indents normally.
543        use SyntaxKind as S;
544        let src = "var f = func():\n\tprint()\nx = 1\n";
545        assert_lossless(src);
546        assert_eq!(count(src, S::Indent), 1);
547        assert_eq!(count(src, S::Dedent), 1);
548    }
549
550    #[test]
551    fn blank_and_comment_only_lines_keep_state() {
552        // Cases 7 & 8: blank lines and a column-0 comment must not close the block.
553        let src = "func f():\n\tx = 1\n\n# top-level comment\n\ty = 2\n";
554        assert_lossless(src);
555        // Only one Indent (into the body) and one Dedent (at EOF) — the blank and the
556        // column-0 comment do not emit a Dedent.
557        assert_eq!(count(src, SyntaxKind::Indent), 1);
558        assert_eq!(count(src, SyntaxKind::Dedent), 1);
559    }
560
561    #[test]
562    fn inline_block_has_no_indent() {
563        // Case 9: `func f(): return 1` on one line never produces an Indent.
564        let src = "func f(): return 1\n";
565        assert_lossless(src);
566        assert_eq!(count(src, SyntaxKind::Indent), 0);
567        assert_eq!(count(src, SyntaxKind::Newline), 1);
568    }
569
570    #[test]
571    fn dedent_to_eof_without_trailing_newline() {
572        // Case 11: file ends mid-nest with no trailing newline.
573        use SyntaxKind as S;
574        let src = "func f():\n\tpass";
575        assert_lossless(src);
576        let s = structure(src);
577        assert_eq!(s.last(), Some(&S::Dedent));
578        assert_eq!(count(src, S::Indent), 1);
579        assert_eq!(count(src, S::Dedent), 1);
580        // The final unterminated line still gets a logical Newline.
581        assert!(s.contains(&S::Newline));
582    }
583
584    #[test]
585    fn empty_and_comment_only_files() {
586        // Case 12.
587        assert_lossless("");
588        assert_eq!(structure(""), Vec::<SyntaxKind>::new());
589        assert_lossless("# just a comment\n");
590        assert_eq!(count("# just a comment\n", SyntaxKind::Indent), 0);
591    }
592
593    #[test]
594    fn mixed_tabs_and_spaces_diagnoses_but_recovers() {
595        // Case 6: a tab+space mix on one indentation run is flagged, not fatal.
596        let src = "func f():\n \tpass\n";
597        assert_lossless(src);
598        let diags = diagnostics(src);
599        assert!(
600            diags
601                .iter()
602                .any(|d| d.message.contains("Mixed use of tabs and spaces")),
603            "expected a mixed-indent diagnostic, got {diags:?}"
604        );
605    }
606
607    #[test]
608    fn inconsistent_indent_char_across_lines_is_flagged() {
609        // First indented line uses a tab; a later one uses spaces → file-consistency
610        // diagnostic (first char wins).
611        let src = "func f():\n\ta = 1\nfunc g():\n    b = 2\n";
612        let diags = diagnostics(src);
613        assert!(
614            diags.iter().any(|d| d.message.contains("instead of")),
615            "expected an inconsistent-indent diagnostic, got {diags:?}"
616        );
617    }
618
619    #[test]
620    fn match_block_nests() {
621        use SyntaxKind as S;
622        let src = "match x:\n\t1:\n\t\tpass\n";
623        assert_lossless(src);
624        assert_eq!(count(src, S::Indent), 2);
625        assert_eq!(count(src, S::Dedent), 2);
626        assert_eq!(structure(src)[0], S::MatchKw);
627    }
628
629    #[test]
630    fn multiline_lambda_inside_brackets_indents() {
631        // A multiline lambda body inside an open `(` re-enables indentation.
632        use SyntaxKind as S;
633        let src = "arr.sort_custom(func(a, b):\n\treturn a < b\n)\n";
634        assert_lossless(src);
635        assert_eq!(count(src, S::Indent), 1, "lambda body should Indent once");
636        assert_eq!(count(src, S::Dedent), 1, "lambda body should Dedent once");
637        // One logical statement (the call), terminated after the closing `)`.
638        let s = structure(src);
639        // The Indent comes right after the lambda's `:` + Newline.
640        let colon = s.iter().position(|&k| k == S::Colon).unwrap();
641        assert_eq!(s[colon + 1], S::Newline);
642        assert_eq!(s[colon + 2], S::Indent);
643        // The Dedent comes before the closing `)`.
644        let rparen = s.iter().rposition(|&k| k == S::RParen).unwrap();
645        assert_eq!(s[rparen - 1], S::Dedent);
646    }
647
648    #[test]
649    fn lambda_inside_multiline_array() {
650        // A lambda living inside a multiline `[ ]` literal.
651        use SyntaxKind as S;
652        let src = "var a = [\n\tfunc():\n\t\tprint()\n]\n";
653        assert_lossless(src);
654        assert_eq!(count(src, S::Indent), 1);
655        assert_eq!(count(src, S::Dedent), 1);
656    }
657
658    #[test]
659    fn nested_lambdas_inside_brackets() {
660        use SyntaxKind as S;
661        let src = "outer(func():\n\tinner(func():\n\t\tbody\n\t)\n)\n";
662        assert_lossless(src);
663        assert_eq!(count(src, S::Indent), 2, "two nested lambda bodies");
664        assert_eq!(count(src, S::Dedent), 2);
665    }
666
667    #[test]
668    fn single_line_lambda_inside_brackets_has_no_indent() {
669        // The body is on the header line → no Indent/Dedent, one statement.
670        use SyntaxKind as S;
671        let src = "arr.map(func(x): x * 2)\n";
672        assert_lossless(src);
673        assert_eq!(count(src, S::Indent), 0);
674        assert_eq!(count(src, S::Dedent), 0);
675        assert_eq!(count(src, S::Newline), 1);
676    }
677}