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 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    // Body is scanned after the opening `{`, so find the matching `}` at depth 0
189    // relative to that body (depth starts at 1 for the already-consumed opener).
190    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}