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