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