Skip to main content

pray_core/
statement_surface.rs

1//! Normalize Gemfile-like Ruby surface sugar into the canonical Prayfile statement form.
2//!
3//! Supported (static, non-executable):
4//! - top-level `;` statement separators
5//! - `{ … }` blocks as `do` / `end`
6//! - optional call parentheses after keywords (`compose("x") do`)
7//! - optional call parentheses on symbol assignments (`support_email("x")`)
8//!
9//! Not supported (intentional): interpolation, constants, variables, method chaining.
10
11use 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    // Only `keyword{…}`, `keyword(…){…}`, or `keyword "…"{…}` — not `spec.exports = {…}`.
42    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    // Body is scanned after the opening `{`, so find the matching `}` at depth 0
183    // relative to that body (depth starts at 1 for the already-consumed opener).
184    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}