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