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}