Skip to main content

daml_parser/
layout.rs

1//! Layout resolution: insert virtual braces/semicolons per the Haskell
2//! offside rule (adapted for DAML, where `with` also opens a layout block).
3//!
4//! Input is the raw token stream from the lexer; output is the same stream
5//! with `VLBrace`/`VRBrace`/`VSemi` inserted, so the parser never has to
6//! look at columns.
7
8use crate::lexer::{Pos, Token, TokenKind};
9
10/// Keywords that open a layout block. DAML adds `with` (template fields,
11/// choice parameters, record construction) and `catch` (exception handler
12/// alternatives) to Haskell's set.
13fn layout_keyword(tok: &TokenKind) -> Option<LayoutKeyword> {
14    match tok.keyword() {
15        Some("where") => Some(LayoutKeyword::Where),
16        Some("do") => Some(LayoutKeyword::Do),
17        Some("of") => Some(LayoutKeyword::Of),
18        Some("let") => Some(LayoutKeyword::Let),
19        Some("with") => Some(LayoutKeyword::With),
20        Some("catch") => Some(LayoutKeyword::Catch),
21        _ => None,
22    }
23}
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq)]
26enum LayoutKeyword {
27    Module,
28    Where,
29    Do,
30    Of,
31    Let,
32    With,
33    Catch,
34}
35
36#[derive(Debug)]
37struct Context {
38    /// Column of the block; 0 for an explicit `{ }` context (no offside).
39    col: usize,
40    /// Line the block was opened on (same-line `where` closure rule).
41    line: usize,
42    /// Keyword that opened it (`let` matters for the `in` rule).
43    opened_by: LayoutKeyword,
44    /// Bracket nesting depth at open time, so `)` can close blocks that
45    /// were opened inside the parentheses: `(do stmts)`.
46    bracket_depth: usize,
47}
48
49/// Resolve indentation-sensitive layout: insert virtual `VLBrace`/`VRBrace`/
50/// `VSemi` tokens per the offside rule so the parser never inspects columns.
51///
52/// Input is the raw lexer token stream; the output preserves every input token
53/// in order and interleaves the virtual layout tokens. This is a total function
54/// over any token slice — it never panics, even on unbalanced or truncated
55/// input (stray brackets and unclosed blocks are tolerated, not rejected).
56///
57/// ```
58/// use daml_parser::lexer::lex;
59/// use daml_parser::layout::resolve_layout;
60///
61/// let lexed = lex("module M where\nfoo = 1\n");
62/// let laid_out = resolve_layout(lexed.tokens);
63/// assert!(laid_out.len() >= 1);
64/// ```
65#[must_use]
66pub fn resolve_layout(tokens: Vec<Token>) -> Vec<Token> {
67    let eof_pos = tokens
68        .last()
69        .map(|t| t.pos)
70        .unwrap_or(Pos { line: 1, column: 1 });
71    let mut out: Vec<Token> = Vec::with_capacity(tokens.len() + tokens.len() / 4);
72    let mut stack: Vec<Context> = Vec::new();
73    let mut bracket_depth = 0usize;
74    // Set after a layout keyword: the next token starts a block.
75    let mut expecting_open: Option<LayoutKeyword> = None;
76    let mut last_line = 0usize;
77
78    // A file that doesn't start with `module` opens an implicit top context
79    // at its first token's column (Haskell rule for missing module headers).
80    if let Some(first) = tokens.first() {
81        if !first.kind.is_keyword("module") {
82            stack.push(Context {
83                col: first.pos.column,
84                line: first.pos.line,
85                opened_by: LayoutKeyword::Module,
86                bracket_depth: 0,
87            });
88            out.push(virtual_tok(TokenKind::VLBrace, first.pos));
89            last_line = first.pos.line;
90        }
91    }
92
93    let close = |out: &mut Vec<Token>, pos: Pos| {
94        out.push(virtual_tok(TokenKind::VRBrace, pos));
95    };
96
97    for token in tokens {
98        let pos = token.pos;
99        let col = pos.column;
100
101        if let Some(kw) = expecting_open.take() {
102            if matches!(token.kind, TokenKind::LBrace) {
103                // Explicit block: push a no-offside context.
104                stack.push(Context {
105                    col: 0,
106                    line: pos.line,
107                    opened_by: kw,
108                    bracket_depth,
109                });
110                out.push(token);
111                last_line = pos.line;
112                continue;
113            }
114            let enclosing = stack.iter().rev().find(|c| c.col > 0).map_or(0, |c| c.col);
115            if col > enclosing {
116                stack.push(Context {
117                    col,
118                    line: pos.line,
119                    opened_by: kw,
120                    bracket_depth,
121                });
122                out.push(virtual_tok(TokenKind::VLBrace, pos));
123                last_line = pos.line;
124                // fall through to emit the token itself
125            } else {
126                // Token not indented past the enclosing block: empty block,
127                // then let the normal offside logic below handle the token.
128                out.push(virtual_tok(TokenKind::VLBrace, pos));
129                out.push(virtual_tok(TokenKind::VRBrace, pos));
130            }
131        }
132
133        // Offside check at the first token of each new line.
134        if pos.line != last_line {
135            while let Some(top) = stack.last() {
136                if top.col > 0 && col < top.col {
137                    if token.kind.is_keyword("in") && top.opened_by == LayoutKeyword::Let {
138                        close(&mut out, pos);
139                        stack.pop();
140                        break;
141                    }
142                    close(&mut out, pos);
143                    stack.pop();
144                } else {
145                    break;
146                }
147            }
148            // `where` never starts a new block item — the rule below closes
149            // the block instead, so a VSemi here would be orphaned.
150            if let Some(top) = stack.last() {
151                if top.col > 0 && col == top.col && !token.kind.is_keyword("where") {
152                    out.push(virtual_tok(TokenKind::VSemi, pos));
153                }
154            }
155            last_line = pos.line;
156        }
157
158        // `where` closes any block at or right of it (`do ... where` at the
159        // same indentation must end the do-block; GHC handles this via the
160        // parse-error rule).
161        if token.kind.is_keyword("where") {
162            while let Some(top) = stack.last() {
163                // Close blocks at/right of the `where`, and with-blocks
164                // opened on the same line (`template S with p : Party
165                // where ...` — the inline with-block ends at the where).
166                if top.col > 0
167                    && (top.col >= col
168                        || (top.line == pos.line && top.opened_by == LayoutKeyword::With))
169                    && top.opened_by != LayoutKeyword::Module
170                {
171                    close(&mut out, pos);
172                    stack.pop();
173                } else {
174                    break;
175                }
176            }
177        }
178
179        // `in` closes the matching `let` block when still open. Offside may
180        // already have closed an inner `let` on this line; in that case the
181        // current top context still decides whether this `in` belongs to an
182        // enclosing `let`.
183        if token.kind.is_keyword("in") {
184            if let Some(top) = stack.last() {
185                if top.col > 0
186                    && top.opened_by == LayoutKeyword::Let
187                    && (top.line == pos.line || col <= top.col)
188                {
189                    close(&mut out, pos);
190                    stack.pop();
191                }
192            }
193        }
194
195        match token.kind {
196            TokenKind::LParen | TokenKind::LBracket => bracket_depth += 1,
197            TokenKind::RParen | TokenKind::RBracket | TokenKind::Comma => {
198                // Close implicit blocks opened inside this bracket pair
199                // before the bracket closes over them: `(do stmts)`,
200                // `[f x, do y]`.
201                let target = if matches!(token.kind, TokenKind::Comma) {
202                    bracket_depth
203                } else {
204                    bracket_depth.saturating_sub(1)
205                };
206                while let Some(top) = stack.last() {
207                    if top.col > 0 && top.bracket_depth > target {
208                        close(&mut out, pos);
209                        stack.pop();
210                    } else {
211                        break;
212                    }
213                }
214                if !matches!(token.kind, TokenKind::Comma) {
215                    bracket_depth = bracket_depth.saturating_sub(1);
216                }
217            }
218            TokenKind::RBrace
219                // Close an explicit context if one is on top.
220                if stack.last().is_some_and(|c| c.col == 0) => {
221                    stack.pop();
222                }
223            _ => {}
224        }
225
226        let was_backslash = out
227            .last()
228            .is_some_and(|t| matches!(&t.kind, TokenKind::Op(o) if *o == "\\"));
229        let opens_layout = layout_keyword(&token.kind);
230        let case_after_backslash = token.kind.is_keyword("case") && was_backslash;
231        out.push(token);
232
233        if let Some(keyword) = opens_layout {
234            expecting_open = Some(keyword);
235        } else if case_after_backslash {
236            // `\case` alternatives form a layout block like `of`.
237            expecting_open = Some(LayoutKeyword::Of);
238        }
239    }
240
241    // EOF closes everything implicit.
242    if expecting_open.is_some() {
243        out.push(virtual_tok(TokenKind::VLBrace, eof_pos));
244        out.push(virtual_tok(TokenKind::VRBrace, eof_pos));
245    }
246    for ctx in stack.iter().rev() {
247        if ctx.col > 0 {
248            out.push(virtual_tok(TokenKind::VRBrace, eof_pos));
249        }
250    }
251
252    out
253}
254
255const fn virtual_tok(tok: TokenKind, pos: Pos) -> Token {
256    // Layout tokens have no source bytes; zero-width span keeps the
257    // lossless render (which skips them anyway) honest.
258    Token {
259        kind: tok,
260        pos,
261        start: 0,
262        end: 0,
263    }
264}
265
266// Virtual-brace layout invariants for the layout resolver phase.
267#[cfg(test)]
268mod tests {
269    use super::*;
270    use crate::lexer::lex;
271
272    /// Render the laid-out stream compactly: `{` `}` `;` for virtual tokens,
273    /// token text otherwise.
274    fn layout_str(src: &str) -> String {
275        let (tokens, errors) = lex(src).into_parts();
276        assert!(errors.is_empty(), "lex errors: {errors:?}");
277        resolve_layout(tokens)
278            .iter()
279            .map(|t| match &t.kind {
280                TokenKind::VLBrace => "{".to_string(),
281                TokenKind::VRBrace => "}".to_string(),
282                TokenKind::VSemi => ";".to_string(),
283                TokenKind::LowerId { qualifier, name } | TokenKind::UpperId { qualifier, name } => {
284                    qualifier
285                        .as_ref()
286                        .map_or_else(|| name.to_string(), |q| format!("{q}.{name}"))
287                }
288                TokenKind::Op(o) => o.to_string(),
289                TokenKind::IntLit(n) | TokenKind::DecimalLit(n) => n.clone(),
290                TokenKind::StringLit(s) => format!("{s:?}"),
291                TokenKind::CharLit(c) => format!("'{c}'"),
292                TokenKind::LParen => "(".into(),
293                TokenKind::RParen => ")".into(),
294                TokenKind::LBracket => "[".into(),
295                TokenKind::RBracket => "]".into(),
296                TokenKind::LBrace => "{{".into(),
297                TokenKind::RBrace => "}}".into(),
298                TokenKind::Comma => ",".into(),
299                TokenKind::Semi => ";;".into(),
300                TokenKind::Backtick => "`".into(),
301            })
302            .collect::<Vec<_>>()
303            .join(" ")
304    }
305
306    #[test]
307    fn module_where_opens_top_block() {
308        assert_eq!(
309            layout_str("module M where\n\nf = 1\ng = 2\n"),
310            "module M where { f = 1 ; g = 2 }"
311        );
312    }
313
314    #[test]
315    fn template_with_where_blocks() {
316        let src = "module M where\n\ntemplate Foo\n  with\n    x : Int\n    y : Party\n  where\n    signatory y\n";
317        assert_eq!(
318            layout_str(src),
319            "module M where { template Foo with { x : Int ; y : Party } where { signatory y } }"
320        );
321    }
322
323    #[test]
324    fn do_block_and_dedent() {
325        let src = "module M where\nf = do\n  a\n  b\ng = 1\n";
326        assert_eq!(
327            layout_str(src),
328            "module M where { f = do { a ; b } ; g = 1 }"
329        );
330    }
331
332    #[test]
333    fn same_line_record_with() {
334        let src = "module M where\nf = do\n  cid <- create this with owner = p\n  pure cid\n";
335        assert_eq!(
336            layout_str(src),
337            "module M where { f = do { cid <- create this with { owner = p } ; pure cid } }"
338        );
339    }
340
341    #[test]
342    fn let_in_same_line() {
343        assert_eq!(
344            layout_str("module M where\nf = let x = 1 in x\n"),
345            "module M where { f = let { x = 1 } in x }"
346        );
347    }
348
349    #[test]
350    fn let_in_multiline() {
351        let src = "module M where\nf =\n  let x = 1\n      y = 2\n  in x\n";
352        assert_eq!(
353            layout_str(src),
354            "module M where { f = let { x = 1 ; y = 2 } in x }"
355        );
356    }
357
358    #[test]
359    fn paren_closes_do_block() {
360        assert_eq!(
361            layout_str("module M where\nf = g (do\n  a) b\n"),
362            "module M where { f = g ( do { a } ) b }"
363        );
364    }
365
366    #[test]
367    fn where_at_do_indent_closes_do() {
368        let src = "module M where\nf = do\n  a\n  where\n    g = 1\n";
369        assert_eq!(
370            layout_str(src),
371            "module M where { f = do { a } where { g = 1 } }"
372        );
373    }
374
375    #[test]
376    fn in_closes_enclosing_let_after_inner_let_closes_offside() {
377        let src = "module M where\nf = let\n  let x = 1\n      y = 2\nin x\n";
378        assert_eq!(
379            layout_str(src),
380            "module M where { f = let { let { x = 1 ; y = 2 } } in x }"
381        );
382    }
383
384    #[test]
385    fn file_without_module_header() {
386        assert_eq!(layout_str("f = 1\ng = 2\n"), "{ f = 1 ; g = 2 }");
387    }
388
389    /// Phase gate: lexer + layout must survive the whole daml-finance
390    /// corpus (no panic, no hang, balanced virtual braces). The corpus is
391    /// vendored once at the workspace root, shared with daml-lint.
392    #[test]
393    fn corpus_lex_and_layout_survives() {
394        let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
395            .join("../../corpus/daml-finance/daml");
396        if !root.exists() {
397            assert!(
398                std::env::var_os("CI").is_none(),
399                "vendored corpus missing under CI (was it committed?): {}",
400                root.display()
401            );
402            eprintln!("corpus absent (published crate?), skipping");
403            return;
404        }
405        let mut files = Vec::new();
406        collect_daml(&root, &mut files).expect("collect corpus files");
407        assert!(
408            files.len() > 600,
409            "corpus incomplete: {} files",
410            files.len()
411        );
412        let mut lex_errors = 0usize;
413        for f in &files {
414            let src = std::fs::read_to_string(f)
415                .unwrap_or_else(|e| panic!("failed to read corpus file {}: {e}", f.display()));
416            let (tokens, errors) = lex(&src).into_parts();
417            lex_errors += errors.len();
418            let laid = resolve_layout(tokens);
419            let opens = laid.iter().filter(|t| t.kind == TokenKind::VLBrace).count();
420            let closes = laid.iter().filter(|t| t.kind == TokenKind::VRBrace).count();
421            assert_eq!(opens, closes, "unbalanced virtual braces in {f:?}");
422        }
423        assert_eq!(lex_errors, 0, "lex errors across corpus");
424    }
425
426    fn collect_daml(
427        dir: &std::path::Path,
428        out: &mut Vec<std::path::PathBuf>,
429    ) -> std::io::Result<()> {
430        for entry in std::fs::read_dir(dir)? {
431            let entry = entry?;
432            let p = entry.path();
433            if p.is_dir() {
434                collect_daml(&p, out)?;
435            } else if p.extension().is_some_and(|e| e == "daml") {
436                out.push(p);
437            }
438        }
439        Ok(())
440    }
441
442    #[test]
443    fn case_of_alternatives() {
444        let src = "module M where\nf x = case x of\n  1 -> a\n  _ -> b\n";
445        assert_eq!(
446            layout_str(src),
447            "module M where { f x = case x of { 1 -> a ; _ -> b } }"
448        );
449    }
450}