pray_core/
statement_surface.rs1use crate::literal::{is_balanced, split_top_level};
12use std::collections::VecDeque;
13
14pub fn expand_statement_surface(statement: &str) -> Vec<String> {
15 let trimmed = statement.trim();
16 if trimmed.is_empty() {
17 return Vec::new();
18 }
19
20 let mut parts = Vec::new();
21 for segment in split_top_level(trimmed, ';') {
22 parts.extend(expand_one_surface(&segment));
23 }
24 parts
25}
26
27fn expand_one_surface(statement: &str) -> Vec<String> {
28 let trimmed = statement.trim();
29 if trimmed.is_empty() {
30 return Vec::new();
31 }
32 if let Some(expanded) = expand_brace_block(trimmed) {
33 return expanded;
34 }
35 vec![normalize_keyword_call(trimmed)]
36}
37
38fn expand_brace_block(statement: &str) -> Option<Vec<String>> {
39 let keyword = leading_identifier(statement)?;
40 let after_keyword = statement[keyword.len()..].trim_start();
41 let (args, after_open) = split_brace_header(after_keyword)?;
43 let close_offset = matching_close_brace(after_open)?;
44 let trailing = after_open[close_offset + 1..].trim();
45 if !trailing.is_empty() {
46 return None;
47 }
48 let body = after_open[..close_offset].trim();
49 if !is_balanced(body) {
50 return None;
51 }
52
53 let header_args = unwrap_outer_parens(&args);
54 let open = if header_args.is_empty() {
55 format!("{keyword} do")
56 } else {
57 format!("{keyword} {header_args} do")
58 };
59
60 let mut output = vec![open];
61 if !body.is_empty() {
62 output.extend(expand_statement_surface(body));
63 }
64 output.push("end".to_string());
65 Some(output)
66}
67
68fn split_brace_header(after_keyword: &str) -> Option<(String, &str)> {
69 if after_keyword.starts_with('{') {
70 return Some((String::new(), &after_keyword[1..]));
71 }
72 if after_keyword.starts_with('(') {
73 let close = matching_close_paren(after_keyword)?;
74 let trailing = after_keyword[close + 1..].trim_start();
75 if !trailing.starts_with('{') {
76 return None;
77 }
78 let args = after_keyword[1..close].trim().to_string();
79 return Some((args, &trailing[1..]));
80 }
81 let first = after_keyword.chars().next()?;
82 if first != '"' && first != '\'' && first != ':' {
83 return None;
84 }
85 let brace_offset = find_top_level_char(after_keyword, '{')?;
86 let args = after_keyword[..brace_offset].trim().to_string();
87 Some((args, &after_keyword[brace_offset + 1..]))
88}
89
90fn normalize_keyword_call(statement: &str) -> String {
91 if let Some(normalized) = normalize_spaced_block_opener(statement) {
92 return normalized;
93 }
94 let Some(keyword) = leading_identifier(statement) else {
95 return statement.to_string();
96 };
97 let after_keyword = statement[keyword.len()..].trim_start();
98 if !after_keyword.starts_with('(') {
99 return statement.to_string();
100 }
101 let Some(close) = matching_close_paren(after_keyword) else {
102 return statement.to_string();
103 };
104 let inner = after_keyword[1..close].trim();
105 let trailing = after_keyword[close + 1..].trim();
106 if trailing.is_empty() {
107 format!("{keyword} {inner}")
108 } else {
109 format!("{keyword} {inner} {trailing}")
110 }
111}
112
113fn normalize_spaced_block_opener(statement: &str) -> Option<String> {
114 let trimmed = statement.trim();
115 for keyword in ["pray", "template"] {
116 let rest = trimmed.strip_prefix(keyword)?.trim_start();
117 if rest == "do" {
118 return Some(format!("{keyword} do"));
119 }
120 }
121 None
122}
123
124pub fn split_symbol_assignment(statement: &str) -> Option<(String, String)> {
125 let trimmed = statement.trim();
126 if let Some((key, value)) = split_symbol_call(trimmed) {
127 return Some((key, value));
128 }
129 let mut parts = trimmed.splitn(2, char::is_whitespace);
130 let key = parts.next()?.trim();
131 let value = parts.next()?.trim();
132 if key.is_empty() || value.is_empty() {
133 return None;
134 }
135 Some((key.to_string(), value.to_string()))
136}
137
138fn split_symbol_call(statement: &str) -> Option<(String, String)> {
139 let key = leading_identifier(statement)?;
140 let after_key = statement[key.len()..].trim_start();
141 if !after_key.starts_with('(') || !after_key.ends_with(')') {
142 return None;
143 }
144 if matching_close_paren(after_key)? != after_key.len() - 1 {
145 return None;
146 }
147 let inner = after_key[1..after_key.len() - 1].trim();
148 if inner.is_empty() {
149 return None;
150 }
151 Some((key.to_string(), inner.to_string()))
152}
153
154fn leading_identifier(input: &str) -> Option<&str> {
155 let trimmed = input.trim_start();
156 let end = trimmed
157 .char_indices()
158 .find(|(_, character)| !character.is_ascii_alphanumeric() && *character != '_')
159 .map(|(index, _)| index)
160 .unwrap_or(trimmed.len());
161 if end == 0 {
162 return None;
163 }
164 let ident = &trimmed[..end];
165 if !ident
166 .chars()
167 .next()?
168 .is_ascii_alphabetic()
169 {
170 return None;
171 }
172 Some(ident)
173}
174
175fn unwrap_outer_parens(input: &str) -> String {
176 let trimmed = input.trim();
177 if trimmed.starts_with('(') && matching_close_paren(trimmed) == Some(trimmed.len() - 1) {
178 return trimmed[1..trimmed.len() - 1].trim().to_string();
179 }
180 trimmed.to_string()
181}
182
183fn matching_close_paren(input: &str) -> Option<usize> {
184 matching_close_delimited(input, '(', ')')
185}
186
187fn matching_close_brace(input: &str) -> Option<usize> {
188 let mut depth = 1i32;
191 let mut quote: Option<char> = None;
192 let mut escaped = false;
193 for (index, character) in input.char_indices() {
194 if let Some(quote_char) = quote {
195 if escaped {
196 escaped = false;
197 } else if character == '\\' {
198 escaped = true;
199 } else if character == quote_char {
200 quote = None;
201 }
202 continue;
203 }
204 match character {
205 '"' | '\'' => quote = Some(character),
206 '{' => depth += 1,
207 '}' => {
208 depth -= 1;
209 if depth == 0 {
210 return Some(index);
211 }
212 }
213 _ => {}
214 }
215 }
216 None
217}
218
219fn matching_close_delimited(input: &str, open: char, close: char) -> Option<usize> {
220 if !input.starts_with(open) {
221 return None;
222 }
223 let mut depth = 0i32;
224 let mut quote: Option<char> = None;
225 let mut escaped = false;
226 for (index, character) in input.char_indices() {
227 if let Some(quote_char) = quote {
228 if escaped {
229 escaped = false;
230 } else if character == '\\' {
231 escaped = true;
232 } else if character == quote_char {
233 quote = None;
234 }
235 continue;
236 }
237 if character == open {
238 depth += 1;
239 } else if character == close {
240 depth -= 1;
241 if depth == 0 {
242 return Some(index);
243 }
244 }
245 }
246 None
247}
248
249fn find_top_level_char(input: &str, needle: char) -> Option<usize> {
250 let mut depth = 0i32;
251 let mut quote: Option<char> = None;
252 let mut escaped = false;
253 for (index, character) in input.char_indices() {
254 if let Some(quote_char) = quote {
255 if escaped {
256 escaped = false;
257 } else if character == '\\' {
258 escaped = true;
259 } else if character == quote_char {
260 quote = None;
261 }
262 continue;
263 }
264 match character {
265 '"' | '\'' => quote = Some(character),
266 '(' | '[' | '{' => {
267 if depth == 0 && character == needle {
268 return Some(index);
269 }
270 depth += 1;
271 }
272 ')' | ']' | '}' => depth -= 1,
273 _ if depth == 0 && character == needle => return Some(index),
274 _ => {}
275 }
276 }
277 None
278}
279
280#[derive(Debug, Default)]
281pub struct SurfaceStatementReader {
282 pending: VecDeque<String>,
283}
284
285impl SurfaceStatementReader {
286 pub fn push_raw(&mut self, statement: String) {
287 for part in expand_statement_surface(&statement) {
288 self.pending.push_back(part);
289 }
290 }
291
292 pub fn next(&mut self) -> Option<String> {
293 self.pending.pop_front()
294 }
295
296 pub fn is_empty(&self) -> bool {
297 self.pending.is_empty()
298 }
299}
300
301#[cfg(test)]
302mod tests {
303 use super::*;
304
305 #[test]
306 fn expands_semicolon_one_liner() {
307 let parts = expand_statement_surface(
308 r#"pray do; support_email("a@example.com"); security_email("b@example.com"); end"#,
309 );
310 assert_eq!(
311 parts,
312 vec![
313 "pray do".to_string(),
314 r#"support_email "a@example.com""#.to_string(),
315 r#"security_email "b@example.com""#.to_string(),
316 "end".to_string(),
317 ]
318 );
319 }
320
321 #[test]
322 fn expands_brace_block() {
323 let parts = expand_statement_surface(
324 r#"pray{support_email("a@example.com");security_email("b@example.com")}"#,
325 );
326 assert_eq!(
327 parts,
328 vec![
329 "pray do".to_string(),
330 r#"support_email "a@example.com""#.to_string(),
331 r#"security_email "b@example.com""#.to_string(),
332 "end".to_string(),
333 ]
334 );
335 }
336
337 #[test]
338 fn unwraps_compose_call_parens() {
339 let parts = expand_statement_surface(r#"compose("AGENTS.md") do"#);
340 assert_eq!(parts, vec![r#"compose "AGENTS.md" do"#.to_string()]);
341 }
342
343 #[test]
344 fn expands_compose_brace_block() {
345 let parts = expand_statement_surface(r#"compose("AGENTS.md"){ pray "sample/base", "~> 1.0" }"#);
346 assert_eq!(
347 parts,
348 vec![
349 r#"compose "AGENTS.md" do"#.to_string(),
350 r#"pray "sample/base", "~> 1.0""#.to_string(),
351 "end".to_string(),
352 ]
353 );
354 }
355
356 #[test]
357 fn splits_symbol_call_form() {
358 let (key, value) =
359 split_symbol_assignment(r#"support_email("contact@kiskolabs.com")"#).expect("split");
360 assert_eq!(key, "support_email");
361 assert_eq!(value, r#""contact@kiskolabs.com""#);
362 }
363
364 #[test]
365 fn leaves_assignment_map_literals_alone() {
366 let statement = r#"spec.exports = { "AGENTS.md" => "templates/agents.md" }"#;
367 assert_eq!(
368 expand_statement_surface(statement),
369 vec![statement.to_string()]
370 );
371 }
372}