Skip to main content

telltale_language/compiler/
layout.rs

1// Layout preprocessing for the new indentation-sensitive DSL.
2//
3// Converts indentation into explicit braces while preserving line count to
4// keep error reporting reasonably aligned.
5
6use std::fmt;
7
8#[derive(Debug, Clone)]
9pub struct LayoutError {
10    pub line: usize,
11    pub column: usize,
12    pub message: String,
13}
14
15impl LayoutError {
16    fn new(line: usize, column: usize, message: impl Into<String>) -> Self {
17        Self {
18            line,
19            column,
20            message: message.into(),
21        }
22    }
23}
24
25impl fmt::Display for LayoutError {
26    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
27        write!(f, "{}:{}: {}", self.line, self.column, self.message)
28    }
29}
30
31#[derive(Default, Debug, Clone)]
32struct ScanState {
33    in_block_comment: bool,
34    in_string: bool,
35    escape: bool,
36}
37
38#[derive(Debug, Clone)]
39struct LineScan {
40    has_code: bool,
41    depth_delta: i32,
42    end_state: ScanState,
43    sanitized_line: String,
44}
45
46fn update_code_and_depth(ch: char, has_code: &mut bool, depth_delta: &mut i32) {
47    if !ch.is_whitespace() {
48        *has_code = true;
49    }
50    match ch {
51        '{' | '(' | '[' => *depth_delta += 1,
52        '}' | ')' | ']' => *depth_delta -= 1,
53        _ => {}
54    }
55}
56
57fn scan_line(line: &str, state: &ScanState) -> LineScan {
58    let mut st = state.clone();
59    let mut has_code = false;
60    let mut depth_delta = 0i32;
61    let chars: Vec<char> = line.chars().collect();
62    let mut sanitized_line = String::with_capacity(line.len());
63    let mut i = 0usize;
64
65    while i < chars.len() {
66        if st.in_block_comment {
67            if chars[i] == '-' && chars.get(i + 1).copied() == Some('}') {
68                sanitized_line.push(' ');
69                sanitized_line.push(' ');
70                st.in_block_comment = false;
71                i += 2;
72                continue;
73            }
74            sanitized_line.push(' ');
75            i += 1;
76            continue;
77        }
78
79        if st.in_string {
80            let ch = chars[i];
81            sanitized_line.push(ch);
82            if st.escape {
83                st.escape = false;
84                i += 1;
85                continue;
86            }
87            if ch == '\\' {
88                st.escape = true;
89            } else if ch == '"' {
90                st.in_string = false;
91            }
92            i += 1;
93            continue;
94        }
95
96        let ch = chars[i];
97        let next = chars.get(i + 1).copied();
98        if ch == '-' && next == Some('-') {
99            sanitized_line.push_str(&" ".repeat(chars.len() - i));
100            break;
101        }
102        if ch == '{' && next == Some('-') {
103            st.in_block_comment = true;
104            sanitized_line.push_str("  ");
105            i += 2;
106            continue;
107        }
108        if ch == '"' {
109            st.in_string = true;
110            sanitized_line.push(ch);
111            i += 1;
112            continue;
113        }
114
115        update_code_and_depth(ch, &mut has_code, &mut depth_delta);
116        sanitized_line.push(ch);
117        i += 1;
118    }
119
120    LineScan {
121        has_code,
122        depth_delta,
123        end_state: st,
124        sanitized_line,
125    }
126}
127
128fn is_layout_continuation(line: &str) -> bool {
129    let trimmed = line.trim_start();
130    trimmed.starts_with("->") || trimmed.starts_with('{')
131}
132
133fn leading_indent(line: &str, line_no: usize) -> Result<usize, LayoutError> {
134    let mut indent = 0usize;
135    for (idx, ch) in line.chars().enumerate() {
136        match ch {
137            ' ' => indent += 1,
138            '\t' => {
139                return Err(LayoutError::new(
140                    line_no,
141                    idx + 1,
142                    "Tabs are not allowed for indentation",
143                ))
144            }
145            _ => break,
146        }
147    }
148    Ok(indent)
149}
150
151fn adjust_indent_stack(
152    indent_stack: &mut Vec<usize>,
153    current: usize,
154    line_no: usize,
155    column: usize,
156) -> Result<String, LayoutError> {
157    let mut prefix = String::new();
158    let last = *indent_stack.last().unwrap_or(&0);
159    if current > last {
160        indent_stack.push(current);
161        prefix.push_str("{ ");
162        return Ok(prefix);
163    }
164    if current < last {
165        while current < *indent_stack.last().unwrap_or(&0) {
166            indent_stack.pop();
167            prefix.push_str("} ");
168        }
169        if current != *indent_stack.last().unwrap_or(&0) {
170            return Err(LayoutError::new(
171                line_no,
172                column,
173                "Inconsistent indentation",
174            ));
175        }
176    }
177    Ok(prefix)
178}
179
180fn close_remaining_layout_blocks(out_lines: &mut Vec<String>, open_blocks: usize) {
181    if open_blocks == 0 {
182        return;
183    }
184    let mut tail = String::new();
185    for _ in 0..open_blocks {
186        tail.push_str("} ");
187    }
188    if let Some(last) = out_lines.last_mut() {
189        last.push_str(&tail);
190    } else {
191        out_lines.push(tail);
192    }
193}
194
195/// Convert indentation into braces for parsing.
196///
197/// Notes:
198/// - Inserts `{` before the first statement of an indented block.
199/// - Inserts `}` before statements that dedent.
200/// - Does not alter line count.
201/// - Ignores indentation while inside explicit `{}` or `()` blocks.
202pub fn preprocess_layout(input: &str) -> Result<String, LayoutError> {
203    let mut out_lines: Vec<String> = Vec::new();
204    let mut indent_stack: Vec<usize> = vec![0];
205    let mut explicit_depth: i32 = 0;
206    let mut scan_state = ScanState::default();
207
208    for (line_idx, line) in input.lines().enumerate() {
209        let line_no = line_idx + 1;
210        let indent = leading_indent(line, line_no)?;
211
212        let scan = scan_line(line, &scan_state);
213        scan_state = scan.end_state;
214
215        let layout_enabled = explicit_depth == 0;
216        let mut prefix = String::new();
217
218        if layout_enabled && scan.has_code && !is_layout_continuation(line) {
219            prefix.push_str(&adjust_indent_stack(
220                &mut indent_stack,
221                indent,
222                line_no,
223                indent + 1,
224            )?);
225        }
226
227        let mut out_line = String::new();
228        out_line.push_str(&prefix);
229        out_line.push_str(&scan.sanitized_line);
230        out_lines.push(out_line);
231
232        explicit_depth += scan.depth_delta;
233        if explicit_depth < 0 {
234            return Err(LayoutError::new(
235                line_no,
236                indent + 1,
237                "Unmatched closing delimiter",
238            ));
239        }
240    }
241
242    close_remaining_layout_blocks(&mut out_lines, indent_stack.len().saturating_sub(1));
243
244    Ok(out_lines.join("\n"))
245}
246
247#[cfg(test)]
248mod tests {
249    use super::preprocess_layout;
250
251    #[test]
252    fn layout_inserts_braces_for_simple_block() {
253        let input = "protocol PingPong =\n  roles Alice, Bob\n  Alice -> Bob : Ping\n  Bob -> Alice : Pong\n";
254        let out = preprocess_layout(input).unwrap();
255        let normalized = out.split_whitespace().collect::<Vec<_>>().join(" ");
256        assert!(normalized.contains("{ roles"));
257        assert!(normalized.contains("Pong}"));
258    }
259
260    #[test]
261    fn layout_handles_choice_and_branch_blocks() {
262        let input = "protocol Test =\n  roles A, B\n  choice A at\n    | Buy =>\n        A -> B : Msg\n    | Cancel => {}\n";
263        let out = preprocess_layout(input).unwrap();
264        let normalized = out.split_whitespace().collect::<Vec<_>>().join(" ");
265        assert!(normalized.contains("choice A at"));
266        assert!(normalized.contains("{ | Buy =>"));
267        assert!(normalized.contains("{ A -> B"));
268        assert!(normalized.contains("} | Cancel => {}"));
269    }
270
271    #[test]
272    fn layout_ignores_explicit_braces_blocks() {
273        let input =
274            "protocol Test =\n  roles A, B\n  par {\n    | A -> B : Msg\n    | B -> A : Ack\n  }\n";
275        let out = preprocess_layout(input).unwrap();
276        // Should still insert outer protocol block, but not double-open inside explicit braces.
277        let normalized = out.split_whitespace().collect::<Vec<_>>().join(" ");
278        assert!(normalized.contains("{ roles"));
279        assert!(normalized.contains("par {"));
280    }
281
282    #[test]
283    fn layout_allows_empty_blocks_only_with_braces() {
284        let input = "protocol Test =\n  roles A, B\n  choice A at\n    | Cancel => {}\n";
285        let out = preprocess_layout(input).unwrap();
286        let normalized = out.split_whitespace().collect::<Vec<_>>().join(" ");
287        assert!(normalized.contains("Cancel => {}"));
288    }
289
290    #[test]
291    fn layout_does_not_insert_braces_inside_multiline_sender_records() {
292        let input =
293            "protocol Test =\n  roles A, B\n  A {\n    priority : high,\n  }\n    -> B : Msg\n";
294        let out = preprocess_layout(input).unwrap();
295        assert!(!out.contains("{     priority : high,"));
296        assert!(out.contains("A {"));
297        assert!(out.contains("}\n    -> B : Msg"));
298    }
299
300    #[test]
301    fn layout_treats_arrow_line_as_continuation() {
302        let input = "protocol Test =\n  roles A, B\n  A { priority : high }\n    -> B : Msg\n";
303        let out = preprocess_layout(input).unwrap();
304        assert!(!out.contains("{     -> B : Msg"));
305        assert!(out.contains("-> B : Msg"));
306    }
307
308    #[test]
309    fn layout_removes_inline_comments_in_output_lines() {
310        let input = "protocol InlineComment =\n  roles A, B\n  A -> B : Message(\n    value = 1 -- inline payload comment\n    flag = true\n  )\n";
311        let out = preprocess_layout(input).unwrap();
312        assert!(!out.contains("-- inline payload comment"));
313        assert!(out.contains("value = 1"));
314        assert!(out.contains("flag = true"));
315    }
316}