telltale_language/compiler/
layout.rs1use 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
195pub 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 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}