use cucumber_expressions::Expression;
use ferridriver::FerriError;
use ferridriver::error::Result;
use regex::Regex;
use crate::param_type::ParameterTypeRegistry;
use crate::step::StepParam;
fn invalid_expr(reason: impl Into<String>) -> FerriError {
FerriError::invalid_argument("cucumber-expression", reason)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ParamType {
String,
Int,
Float,
Word,
Anonymous,
Custom(std::string::String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParamInfo {
pub ty: ParamType,
pub id: usize,
}
pub struct CompiledExpression {
pub regex: Regex,
pub param_types: Vec<ParamType>,
pub param_infos: Vec<ParamInfo>,
}
pub fn compile(expression: &str) -> Result<CompiledExpression> {
static EMPTY_REGISTRY: std::sync::LazyLock<ParameterTypeRegistry> =
std::sync::LazyLock::new(ParameterTypeRegistry::new);
compile_with_custom(expression, &EMPTY_REGISTRY)
}
pub fn compile_with_custom(expression: &str, custom_types: &ParameterTypeRegistry) -> Result<CompiledExpression> {
let parsed = Expression::parse(expression)
.map_err(|e| invalid_expr(format!("invalid cucumber expression \"{expression}\": {e}")))?;
let mut param_infos = Vec::new();
extract_param_types(&parsed, custom_types, &mut param_infos);
let param_types: Vec<ParamType> = param_infos.iter().map(|p| p.ty.clone()).collect();
let has_custom = param_types.iter().any(|t| matches!(t, ParamType::Custom(_)));
let regex = if has_custom {
let mut processed = expression.to_string();
for info in ¶m_infos {
if let ParamType::Custom(ref name) = info.ty {
if let Some(custom) = custom_types.find(name) {
let placeholder = format!("{{{name}}}");
let replacement = format!("({})", custom.regex);
processed = processed.replacen(&placeholder, &replacement, 1);
}
}
}
Expression::regex(&processed)
.map_err(|e| {
tracing::debug!("cucumber-expressions failed on processed expression, building regex directly: {e}");
e
})
.or_else(|_| {
Regex::new(&format!("^{processed}$"))
.map_err(|e| invalid_expr(format!("failed to compile processed expression \"{processed}\": {e}")))
})?
} else {
Expression::regex(expression)
.map_err(|e| invalid_expr(format!("failed to compile expression \"{expression}\": {e}")))?
};
Ok(CompiledExpression {
regex,
param_types,
param_infos,
})
}
fn extract_param_types(
expr: &Expression<cucumber_expressions::Spanned<'_>>,
custom_types: &ParameterTypeRegistry,
params: &mut Vec<ParamInfo>,
) {
for single in expr.iter() {
if let cucumber_expressions::SingleExpression::Parameter(p) = single {
let name: &str = *p.input;
let ty = match name {
"string" => ParamType::String,
"int" => ParamType::Int,
"float" => ParamType::Float,
"word" => ParamType::Word,
"" => ParamType::Anonymous,
_ => {
if custom_types.find(name).is_some() {
ParamType::Custom(name.to_string())
} else {
ParamType::Anonymous
}
},
};
params.push(ParamInfo { ty, id: p.id });
}
}
}
pub fn extract_params(
captures: ®ex::Captures<'_>,
types: &[ParamType],
infos: &[ParamInfo],
) -> Result<Vec<StepParam>> {
extract_params_with_custom(captures, types, infos, None)
}
pub fn extract_params_with_custom(
captures: ®ex::Captures<'_>,
types: &[ParamType],
infos: &[ParamInfo],
custom_types: Option<&ParameterTypeRegistry>,
) -> Result<Vec<StepParam>> {
let mut params = Vec::with_capacity(types.len());
let mut positional_index = 1_usize;
for info in infos {
let param = match &info.ty {
ParamType::String => {
let name0 = format!("__{}_0", info.id);
let name1 = format!("__{}_1", info.id);
let cap = captures
.name(&name0)
.or_else(|| captures.name(&name1))
.map(|m| m.as_str())
.unwrap_or("");
positional_index += 2;
StepParam::String(cap.to_string())
},
ParamType::Int => {
let cap = captures.get(positional_index).map(|m| m.as_str()).unwrap_or("");
positional_index += 1;
let val = cap
.parse::<i64>()
.map_err(|e| invalid_expr(format!("failed to parse int param \"{cap}\": {e}")))?;
StepParam::Int(val)
},
ParamType::Float => {
let cap = captures.get(positional_index).map(|m| m.as_str()).unwrap_or("");
positional_index += 1;
let val = cap
.parse::<f64>()
.map_err(|e| invalid_expr(format!("failed to parse float param \"{cap}\": {e}")))?;
StepParam::Float(val)
},
ParamType::Word | ParamType::Anonymous => {
let cap = captures.get(positional_index).map(|m| m.as_str()).unwrap_or("");
positional_index += 1;
StepParam::Word(cap.to_string())
},
ParamType::Custom(name) => {
let cap = captures.get(positional_index).map(|m| m.as_str()).unwrap_or("");
positional_index += 1;
if let Some(registry) = custom_types {
if let Some(custom) = registry.find(name) {
if let Some(ref transformer) = custom.transformer {
transformer(cap)
} else {
StepParam::Custom {
type_name: name.clone(),
value: cap.to_string(),
}
}
} else {
StepParam::Custom {
type_name: name.clone(),
value: cap.to_string(),
}
}
} else {
StepParam::Custom {
type_name: name.clone(),
value: cap.to_string(),
}
}
},
};
params.push(param);
}
Ok(params)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn compile_simple_string() {
let expr = compile("I navigate to {string}").unwrap();
assert!(expr.regex.is_match("I navigate to \"https://example.com\""));
assert_eq!(expr.param_types, vec![ParamType::String]);
}
#[test]
fn compile_int() {
let expr = compile("I wait {int} seconds").unwrap();
assert!(expr.regex.is_match("I wait 5 seconds"));
assert_eq!(expr.param_types, vec![ParamType::Int]);
}
#[test]
fn compile_optional() {
let expr = compile("I have {int} item(s)").unwrap();
assert!(expr.regex.is_match("I have 1 item"));
assert!(expr.regex.is_match("I have 5 items"));
assert_eq!(expr.param_types, vec![ParamType::Int]);
}
#[test]
fn compile_multiple_params() {
let expr = compile("I fill {string} with {string}").unwrap();
assert!(expr.regex.is_match("I fill \"#input\" with \"hello\""));
assert_eq!(expr.param_types, vec![ParamType::String, ParamType::String]);
}
#[test]
fn extract_string_param() {
let expr = compile("I navigate to {string}").unwrap();
let caps = expr.regex.captures("I navigate to \"https://example.com\"").unwrap();
let params = extract_params(&caps, &expr.param_types, &expr.param_infos).unwrap();
assert_eq!(params.len(), 1);
assert_eq!(params[0], StepParam::String("https://example.com".to_string()));
}
#[test]
fn extract_single_quoted_string_param() {
let expr = compile("I navigate to {string}").unwrap();
let caps = expr.regex.captures("I navigate to 'https://example.com'").unwrap();
let params = extract_params(&caps, &expr.param_types, &expr.param_infos).unwrap();
assert_eq!(params.len(), 1);
assert_eq!(params[0], StepParam::String("https://example.com".to_string()));
}
#[test]
fn extract_multiple_string_params() {
let expr = compile("I fill {string} with {string}").unwrap();
let caps = expr.regex.captures("I fill \"#input\" with \"hello\"").unwrap();
let params = extract_params(&caps, &expr.param_types, &expr.param_infos).unwrap();
assert_eq!(params.len(), 2);
assert_eq!(params[0], StepParam::String("#input".to_string()));
assert_eq!(params[1], StepParam::String("hello".to_string()));
}
#[test]
fn extract_int_param() {
let expr = compile("I wait {int} seconds").unwrap();
let caps = expr.regex.captures("I wait 5 seconds").unwrap();
let params = extract_params(&caps, &expr.param_types, &expr.param_infos).unwrap();
assert_eq!(params.len(), 1);
assert_eq!(params[0], StepParam::Int(5));
}
#[test]
fn extract_mixed_params() {
let expr = compile("I fill {string} with {int} items").unwrap();
let caps = expr.regex.captures("I fill \"cart\" with 3 items").unwrap();
let params = extract_params(&caps, &expr.param_types, &expr.param_infos).unwrap();
assert_eq!(params.len(), 2);
assert_eq!(params[0], StepParam::String("cart".to_string()));
assert_eq!(params[1], StepParam::Int(3));
}
}