pub fn platform_block(line: &str) -> Option<String> {
let rest = line.strip_prefix("platform ")?;
let name = rest.trim().strip_prefix(':')?;
let name = name.split_whitespace().next()?;
line.ends_with(" do").then(|| name.to_string())
}
pub fn description(line: &str) -> Option<String> {
let rest = line.strip_prefix("desc ")?.trim();
Some(unquote(rest))
}
pub fn lane_start(line: &str) -> Option<(String, bool)> {
let (rest, private) = match line.strip_prefix("private_lane ") {
Some(rest) => (rest, true),
None => (line.strip_prefix("lane ")?, false),
};
let rest = rest.trim().strip_prefix(':')?;
let name: String = rest
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
(!name.is_empty() && line.contains(" do")).then_some((name, private))
}
pub fn opens_block(line: &str) -> bool {
if line.starts_with("lane ")
|| line.starts_with("private_lane ")
|| line.starts_with("platform ")
{
return false;
}
line.ends_with(" do")
|| line.ends_with("do |")
|| line.contains(" do |")
|| line.starts_with("if ")
|| line.starts_with("unless ")
|| line.starts_with("case ")
|| line.starts_with("begin")
|| line.starts_with("def ")
|| line.starts_with("before_all")
|| line.starts_with("after_all")
|| line.starts_with("error do")
}
pub fn sh_command(line: &str) -> Option<String> {
let rest = line
.strip_prefix("sh(")
.map(|rest| rest.trim_end().trim_end_matches(')'))
.or_else(|| line.strip_prefix("sh "))?;
Some(unquote(rest.trim()))
}
pub fn action_call(line: &str) -> Option<(String, Vec<(String, String)>)> {
let name: String = line
.chars()
.take_while(|c| c.is_alphanumeric() || *c == '_')
.collect();
if name.is_empty() || !name.starts_with(|c: char| c.is_ascii_lowercase()) {
return None;
}
let rest = line[name.len()..].trim();
if rest.is_empty() {
return Some((name, Vec::new()));
}
let inside = match rest.strip_prefix('(') {
Some(inner) => inner.strip_suffix(')')?,
None => rest,
};
Some((name, arguments(inside)))
}
pub fn arguments(text: &str) -> Vec<(String, String)> {
let mut args = Vec::new();
for part in split_top_level(text) {
let Some((key, value)) = part.split_once(':') else {
continue;
};
let key = key.trim();
if key.is_empty() || !key.chars().all(|c| c.is_alphanumeric() || c == '_') {
continue;
}
args.push((key.to_string(), unquote(value.trim())));
}
args
}
fn split_top_level(text: &str) -> Vec<String> {
let mut parts = Vec::new();
let mut current = String::new();
let mut quote: Option<char> = None;
let mut depth = 0usize;
for ch in text.chars() {
match ch {
'"' | '\'' => {
match quote {
Some(open) if open == ch => quote = None,
None => quote = Some(ch),
_ => {}
}
current.push(ch);
}
'(' | '[' | '{' if quote.is_none() => {
depth += 1;
current.push(ch);
}
')' | ']' | '}' if quote.is_none() => {
depth = depth.saturating_sub(1);
current.push(ch);
}
',' if quote.is_none() && depth == 0 => {
parts.push(current.trim().to_string());
current.clear();
}
_ => current.push(ch),
}
}
if !current.trim().is_empty() {
parts.push(current.trim().to_string());
}
parts
}
pub fn unquote(value: &str) -> String {
let value = value.trim();
for quote in ['"', '\''] {
if value.len() >= 2 && value.starts_with(quote) && value.ends_with(quote) {
return value[1..value.len() - 1].to_string();
}
}
value.to_string()
}
pub fn interpolate(value: &str) -> String {
let mut out = value.to_string();
out = replace_all(&out, "#{", "}", |inner| {
let inner = inner.trim();
match inner
.strip_prefix("options[:")
.and_then(|rest| rest.strip_suffix(']'))
{
Some(name) => format!("${{{name}}}"),
None => match inner
.strip_prefix("ENV[\"")
.and_then(|rest| rest.strip_suffix("\"]"))
{
Some(name) => format!("${{{name}}}"),
None => format!("${{{inner}}}"),
},
}
});
out = replace_all(&out, "ENV[\"", "\"]", |name| format!("${{{name}}}"));
out = replace_all(&out, "ENV['", "']", |name| format!("${{{name}}}"));
out = replace_all(&out, "options[:", "]", |name| format!("${{{name}}}"));
out
}
fn replace_all(text: &str, open: &str, close: &str, build: impl Fn(&str) -> String) -> String {
let mut out = String::with_capacity(text.len());
let mut rest = text;
while let Some(start) = rest.find(open) {
out.push_str(&rest[..start]);
let after = &rest[start + open.len()..];
let Some(end) = after.find(close) else {
out.push_str(&rest[start..]);
return out;
};
out.push_str(&build(&after[..end]));
rest = &after[end + close.len()..];
}
out.push_str(rest);
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn recognises_a_platform_block() {
assert_eq!(platform_block("platform :ios do").as_deref(), Some("ios"));
assert_eq!(
platform_block("platform :android do").as_deref(),
Some("android")
);
assert!(platform_block("platform :ios").is_none());
assert!(platform_block("lane :beta do").is_none());
}
#[test]
fn recognises_lanes() {
assert_eq!(
lane_start("lane :beta do"),
Some(("beta".to_string(), false))
);
assert_eq!(
lane_start("lane :beta do |options|"),
Some(("beta".to_string(), false))
);
assert_eq!(
lane_start("private_lane :setup do"),
Some(("setup".to_string(), true))
);
assert!(lane_start("laneish :x do").is_none());
}
#[test]
fn recognises_sh() {
assert_eq!(
sh_command("sh \"bundle install\"").as_deref(),
Some("bundle install")
);
assert_eq!(
sh_command("sh(\"pod install\")").as_deref(),
Some("pod install")
);
assert!(sh_command("shell \"x\"").is_none());
}
#[test]
fn reads_an_action_call() {
let (name, args) = action_call("gym(scheme: \"MyApp\", clean: true)").expect("an action");
assert_eq!(name, "gym");
assert_eq!(args.len(), 2);
assert_eq!(args[0], ("scheme".to_string(), "MyApp".to_string()));
assert_eq!(args[1], ("clean".to_string(), "true".to_string()));
}
#[test]
fn reads_an_action_without_brackets() {
let (name, args) = action_call("slack message: \"hi\"").expect("an action");
assert_eq!(name, "slack");
assert_eq!(args[0].1, "hi");
}
#[test]
fn reads_a_bare_action() {
let (name, args) = action_call("ensure_git_status_clean").expect("an action");
assert_eq!(name, "ensure_git_status_clean");
assert!(args.is_empty());
}
#[test]
fn a_comma_inside_quotes_does_not_split_arguments() {
let args = arguments("message: \"one, two\", channel: \"#releases\"");
assert_eq!(args.len(), 2, "{args:?}");
assert_eq!(args[0].1, "one, two");
assert_eq!(args[1].1, "#releases");
}
#[test]
fn a_bracketed_value_stays_whole() {
let args = arguments("devices: [\"iPhone 15\", \"iPad\"], clean: true");
assert_eq!(args.len(), 2, "{args:?}");
assert_eq!(args[0].1, "[\"iPhone 15\", \"iPad\"]");
}
#[test]
fn translates_env_and_options() {
assert_eq!(interpolate("ENV[\"SLACK_URL\"]"), "${SLACK_URL}");
assert_eq!(interpolate("ENV['TOKEN']"), "${TOKEN}");
assert_eq!(interpolate("options[:version]"), "${version}");
}
#[test]
fn translates_string_interpolation() {
assert_eq!(
interpolate("Shipped #{options[:version]} now"),
"Shipped ${version} now"
);
assert_eq!(interpolate("to #{ENV[\"STAGE\"]}"), "to ${STAGE}");
}
#[test]
fn leaves_plain_text_alone() {
assert_eq!(interpolate("just a message"), "just a message");
}
#[test]
fn an_unbalanced_reference_is_not_lost() {
assert_eq!(interpolate("ENV[\"OOPS"), "ENV[\"OOPS");
}
#[test]
fn knows_which_lines_open_a_block() {
assert!(opens_block("if ENV[\"CI\"]"));
assert!(opens_block("[1,2].each do |n|"));
assert!(opens_block("before_all do"));
assert!(!opens_block("lane :beta do"));
assert!(!opens_block("gym(scheme: \"X\")"));
}
}