use crate::literal::{is_balanced, split_top_level};
use std::collections::VecDeque;
pub fn expand_statement_surface(statement: &str) -> Vec<String> {
let trimmed = statement.trim();
if trimmed.is_empty() {
return Vec::new();
}
let mut parts = Vec::new();
for segment in split_top_level(trimmed, ';') {
parts.extend(expand_one_surface(&segment));
}
parts
}
fn expand_one_surface(statement: &str) -> Vec<String> {
let trimmed = statement.trim();
if trimmed.is_empty() {
return Vec::new();
}
if let Some(expanded) = expand_brace_block(trimmed) {
return expanded;
}
vec![normalize_keyword_call(trimmed)]
}
fn expand_brace_block(statement: &str) -> Option<Vec<String>> {
let keyword = leading_identifier(statement)?;
let after_keyword = statement[keyword.len()..].trim_start();
let (args, after_open) = split_brace_header(after_keyword)?;
let close_offset = matching_close_brace(after_open)?;
let trailing = after_open[close_offset + 1..].trim();
if !trailing.is_empty() {
return None;
}
let body = after_open[..close_offset].trim();
if !is_balanced(body) {
return None;
}
let header_args = unwrap_outer_parens(&args);
let open = if header_args.is_empty() {
format!("{keyword} do")
} else {
format!("{keyword} {header_args} do")
};
let mut output = vec![open];
if !body.is_empty() {
output.extend(expand_statement_surface(body));
}
output.push("end".to_string());
Some(output)
}
fn split_brace_header(after_keyword: &str) -> Option<(String, &str)> {
if after_keyword.starts_with('{') {
return Some((String::new(), &after_keyword[1..]));
}
if after_keyword.starts_with('(') {
let close = matching_close_paren(after_keyword)?;
let trailing = after_keyword[close + 1..].trim_start();
if !trailing.starts_with('{') {
return None;
}
let args = after_keyword[1..close].trim().to_string();
return Some((args, &trailing[1..]));
}
let first = after_keyword.chars().next()?;
if first != '"' && first != '\'' && first != ':' {
return None;
}
let brace_offset = find_top_level_char(after_keyword, '{')?;
let args = after_keyword[..brace_offset].trim().to_string();
Some((args, &after_keyword[brace_offset + 1..]))
}
fn normalize_keyword_call(statement: &str) -> String {
if let Some(normalized) = normalize_spaced_block_opener(statement) {
return normalized;
}
let Some(keyword) = leading_identifier(statement) else {
return statement.to_string();
};
let after_keyword = statement[keyword.len()..].trim_start();
if !after_keyword.starts_with('(') {
return statement.to_string();
}
let Some(close) = matching_close_paren(after_keyword) else {
return statement.to_string();
};
let inner = after_keyword[1..close].trim();
let trailing = after_keyword[close + 1..].trim();
if trailing.is_empty() {
format!("{keyword} {inner}")
} else {
format!("{keyword} {inner} {trailing}")
}
}
fn normalize_spaced_block_opener(statement: &str) -> Option<String> {
let trimmed = statement.trim();
for keyword in ["pray", "template"] {
let rest = trimmed.strip_prefix(keyword)?.trim_start();
if rest == "do" {
return Some(format!("{keyword} do"));
}
}
None
}
pub fn split_symbol_assignment(statement: &str) -> Option<(String, String)> {
let trimmed = statement.trim();
if let Some((key, value)) = split_symbol_call(trimmed) {
return Some((key, value));
}
let mut parts = trimmed.splitn(2, char::is_whitespace);
let key = parts.next()?.trim();
let value = parts.next()?.trim();
if key.is_empty() || value.is_empty() {
return None;
}
Some((key.to_string(), value.to_string()))
}
fn split_symbol_call(statement: &str) -> Option<(String, String)> {
let key = leading_identifier(statement)?;
let after_key = statement[key.len()..].trim_start();
if !after_key.starts_with('(') || !after_key.ends_with(')') {
return None;
}
if matching_close_paren(after_key)? != after_key.len() - 1 {
return None;
}
let inner = after_key[1..after_key.len() - 1].trim();
if inner.is_empty() {
return None;
}
Some((key.to_string(), inner.to_string()))
}
fn leading_identifier(input: &str) -> Option<&str> {
let trimmed = input.trim_start();
let end = trimmed
.char_indices()
.find(|(_, character)| !character.is_ascii_alphanumeric() && *character != '_')
.map(|(index, _)| index)
.unwrap_or(trimmed.len());
if end == 0 {
return None;
}
let ident = &trimmed[..end];
if !ident
.chars()
.next()?
.is_ascii_alphabetic()
{
return None;
}
Some(ident)
}
fn unwrap_outer_parens(input: &str) -> String {
let trimmed = input.trim();
if trimmed.starts_with('(') && matching_close_paren(trimmed) == Some(trimmed.len() - 1) {
return trimmed[1..trimmed.len() - 1].trim().to_string();
}
trimmed.to_string()
}
fn matching_close_paren(input: &str) -> Option<usize> {
matching_close_delimited(input, '(', ')')
}
fn matching_close_brace(input: &str) -> Option<usize> {
let mut depth = 1i32;
let mut quote: Option<char> = None;
let mut escaped = false;
for (index, character) in input.char_indices() {
if let Some(quote_char) = quote {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == quote_char {
quote = None;
}
continue;
}
match character {
'"' | '\'' => quote = Some(character),
'{' => depth += 1,
'}' => {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
_ => {}
}
}
None
}
fn matching_close_delimited(input: &str, open: char, close: char) -> Option<usize> {
if !input.starts_with(open) {
return None;
}
let mut depth = 0i32;
let mut quote: Option<char> = None;
let mut escaped = false;
for (index, character) in input.char_indices() {
if let Some(quote_char) = quote {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == quote_char {
quote = None;
}
continue;
}
if character == open {
depth += 1;
} else if character == close {
depth -= 1;
if depth == 0 {
return Some(index);
}
}
}
None
}
fn find_top_level_char(input: &str, needle: char) -> Option<usize> {
let mut depth = 0i32;
let mut quote: Option<char> = None;
let mut escaped = false;
for (index, character) in input.char_indices() {
if let Some(quote_char) = quote {
if escaped {
escaped = false;
} else if character == '\\' {
escaped = true;
} else if character == quote_char {
quote = None;
}
continue;
}
match character {
'"' | '\'' => quote = Some(character),
'(' | '[' | '{' => {
if depth == 0 && character == needle {
return Some(index);
}
depth += 1;
}
')' | ']' | '}' => depth -= 1,
_ if depth == 0 && character == needle => return Some(index),
_ => {}
}
}
None
}
#[derive(Debug, Default)]
pub struct SurfaceStatementReader {
pending: VecDeque<String>,
}
impl SurfaceStatementReader {
pub fn push_raw(&mut self, statement: String) {
for part in expand_statement_surface(&statement) {
self.pending.push_back(part);
}
}
pub fn next(&mut self) -> Option<String> {
self.pending.pop_front()
}
pub fn is_empty(&self) -> bool {
self.pending.is_empty()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn expands_semicolon_one_liner() {
let parts = expand_statement_surface(
r#"pray do; support_email("a@example.com"); security_email("b@example.com"); end"#,
);
assert_eq!(
parts,
vec![
"pray do".to_string(),
r#"support_email "a@example.com""#.to_string(),
r#"security_email "b@example.com""#.to_string(),
"end".to_string(),
]
);
}
#[test]
fn expands_brace_block() {
let parts = expand_statement_surface(
r#"pray{support_email("a@example.com");security_email("b@example.com")}"#,
);
assert_eq!(
parts,
vec![
"pray do".to_string(),
r#"support_email "a@example.com""#.to_string(),
r#"security_email "b@example.com""#.to_string(),
"end".to_string(),
]
);
}
#[test]
fn unwraps_compose_call_parens() {
let parts = expand_statement_surface(r#"compose("AGENTS.md") do"#);
assert_eq!(parts, vec![r#"compose "AGENTS.md" do"#.to_string()]);
}
#[test]
fn expands_compose_brace_block() {
let parts = expand_statement_surface(r#"compose("AGENTS.md"){ pray "sample/base", "~> 1.0" }"#);
assert_eq!(
parts,
vec![
r#"compose "AGENTS.md" do"#.to_string(),
r#"pray "sample/base", "~> 1.0""#.to_string(),
"end".to_string(),
]
);
}
#[test]
fn splits_symbol_call_form() {
let (key, value) =
split_symbol_assignment(r#"support_email("contact@kiskolabs.com")"#).expect("split");
assert_eq!(key, "support_email");
assert_eq!(value, r#""contact@kiskolabs.com""#);
}
#[test]
fn leaves_assignment_map_literals_alone() {
let statement = r#"spec.exports = { "AGENTS.md" => "templates/agents.md" }"#;
assert_eq!(
expand_statement_surface(statement),
vec![statement.to_string()]
);
}
}