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