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