use std::{collections::HashMap, path::PathBuf};
use http::Method;
use pest::Parser;
use pest_derive::Parser;
pub mod context;
mod preprocessor;
use crate::{
collection::Collection,
request::{
expand_templates, Assertion, RequestTemplate, ResponseCapture, TemplateError, VariableStore,
},
};
#[derive(Parser)]
#[grammar = "src/parser/grammar.pest"]
struct CollectionParser;
pub fn parse_collection(
input: &str,
working_dir: PathBuf,
) -> Result<Collection, pest::error::Error<Rule>> {
let preprocessed = preprocessor::preprocess(input, working_dir.clone()).map_err(|e| {
pest::error::Error::new_from_span(
pest::error::ErrorVariant::CustomError {
message: e.to_string(),
},
pest::Span::new(input, 0, input.len()).unwrap(),
)
})?;
log::debug!("PREPROCESSED COMPLETE:\n{}", preprocessed);
let mut pairs = CollectionParser::parse(Rule::request_collection, preprocessed.as_str())?;
let collection = pairs.next().unwrap();
let mut name = String::new();
let mut description = String::new();
let mut request_templates: Vec<RequestTemplate> = Vec::new();
let mut global_store = VariableStore::new();
let mut global_headers: HashMap<String, String> = HashMap::new();
let mut global_queries: HashMap<String, String> = HashMap::new();
let mut global_callbacks: Vec<String> = vec![];
for pair in collection.into_inner() {
match pair.as_rule() {
Rule::collection_name => {
name = pair.as_str().trim().to_string();
}
Rule::collection_description => {
description.push_str(pair.as_str().trim());
}
Rule::variable => {
let span = pair.as_span();
let mut inner_pairs = pair.into_inner();
let key = inner_pairs
.next()
.unwrap()
.as_str()
.trim()
.to_string();
let value = inner_pairs.next().unwrap().as_str().to_string();
assign_global_variable(&mut global_store, key, value.as_str(), &working_dir)
.map_err(|err| template_error_to_span(err, span.clone()))?;
}
Rule::header => {
let mut inner_pairs = pair.into_inner();
let key = inner_pairs.next().unwrap().as_str().trim().to_string();
let value = inner_pairs.next().unwrap().as_str().trim().to_string();
global_headers.insert(key, context::inject_from_prompt(&value));
}
Rule::query => {
let mut inner_pairs = pair.into_inner();
let key = inner_pairs.next().unwrap().as_str().trim().to_string();
let value = inner_pairs.next().unwrap().as_str().trim().to_string();
global_queries.insert(key, context::inject_from_prompt(&value));
}
Rule::callback => {
global_callbacks.push(pair.as_str().strip_prefix('!').unwrap().to_string());
}
Rule::requests => {
for request_pair in pair.into_inner() {
let template = parse_request(
request_pair,
&global_store,
&global_headers,
&global_queries,
&global_callbacks,
&working_dir,
)?;
request_templates.push(template);
}
}
_ => {
unreachable!("unexpected rule: {:?}", pair.as_rule());
}
}
}
let requests = expand_templates(request_templates)
.map_err(|err| template_error_to_collection_error(err, preprocessed.as_str()))?;
Ok(Collection {
name,
description,
requests,
})
}
fn parse_request(
pair: pest::iterators::Pair<Rule>,
collection_store: &VariableStore,
global_headers: &HashMap<String, String>,
global_queries: &HashMap<String, String>,
global_callbacks: &[String],
working_dir: &PathBuf,
) -> Result<RequestTemplate, pest::error::Error<Rule>> {
let request_span = pair.as_span();
let mut store = collection_store.clone();
let mut description_raw = String::new();
let mut method: Option<Method> = None;
let mut url_raw: Option<String> = None;
let mut header_raw: HashMap<String, String> = HashMap::new();
let mut query_raw: HashMap<String, String> = HashMap::new();
let mut form_text_raw: HashMap<String, String> = HashMap::new();
let mut form_file_raw: HashMap<String, String> = HashMap::new();
let mut body_raw: Option<String> = None;
let mut body_content_type_raw: Option<String> = None;
let mut callback_src_raw: Vec<String> = global_callbacks.to_vec();
let mut dependencies: Vec<String> = Vec::new();
let mut response_capture_raw: Vec<(String, pest::Span<'_>)> = Vec::new();
let mut assertion_raw: Vec<(String, pest::Span<'_>)> = Vec::new();
for inner in pair.into_inner() {
match inner.as_rule() {
Rule::description => {
description_raw.push_str(inner.as_str().trim());
}
Rule::variable => {
let span = inner.as_span();
let mut inner_pairs = inner.into_inner();
let key = inner_pairs
.next()
.unwrap()
.as_str()
.trim()
.to_string();
let raw_value = inner_pairs.next().unwrap().as_str().to_string();
assign_request_variable(&mut store, key, raw_value.as_str(), working_dir)
.map_err(|err| template_error_to_span(err, span.clone()))?;
}
Rule::http_method => {
method = Some(inner.as_str().parse().unwrap());
}
Rule::url => {
url_raw = Some(inner.as_str().trim().to_string());
}
Rule::header => {
let mut header_pairs = inner.into_inner();
let key = header_pairs.next().unwrap().as_str().trim().to_string();
let value = header_pairs.next().unwrap().as_str().trim().to_string();
header_raw.insert(key, value);
}
Rule::query => {
let mut query_pairs = inner.into_inner();
let key = query_pairs.next().unwrap().as_str().trim().to_string();
let value = query_pairs.next().unwrap().as_str().trim().to_string();
query_raw.insert(key, value);
}
Rule::form => {
let mut form_pairs = inner.into_inner();
let key = form_pairs.next().unwrap().as_str().trim().to_string();
let value = form_pairs.next().unwrap();
match value.as_rule() {
Rule::file => {
let trimmed = value.as_str().trim_start_matches('@').trim().to_string();
form_file_raw.insert(key, trimmed);
}
Rule::text => {
form_text_raw.insert(key, value.as_str().to_string());
}
_ => unreachable!("unexpected rule: {:?}", value.as_rule()),
}
}
Rule::body => {
body_raw = Some(inner.as_str().to_string());
}
Rule::body_content_type => {
body_content_type_raw = Some(inner.as_str().trim().to_string());
}
Rule::dependency => {
dependencies.push(parse_dependency(inner.as_str()));
}
Rule::callback => {
callback_src_raw.push(inner.as_str().strip_prefix('!').unwrap().to_string());
}
Rule::response_capture => {
response_capture_raw.push((inner.as_str().to_string(), inner.as_span()));
}
Rule::assertion => {
assertion_raw.push((inner.as_str().to_string(), inner.as_span()));
}
_ => unreachable!("unexpected rule: {:?}", inner.as_rule()),
}
}
let method = method.ok_or_else(|| {
pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError {
message: "request is missing an HTTP method".to_string(),
},
request_span.clone(),
)
})?;
let url_raw = url_raw.ok_or_else(|| {
pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError {
message: "request is missing a URL".to_string(),
},
request_span.clone(),
)
})?;
let scalar_map = store.clone_scalars();
let description_template = if description_raw.trim().is_empty() {
"[No Description]".to_string()
} else {
description_raw.trim().to_string()
};
let group_key = if description_raw.trim().is_empty() {
"[No Description]".to_string()
} else {
context::resolve_with_context(description_raw.trim(), &scalar_map)
};
let mut headers = HashMap::new();
for (key, value) in global_headers {
headers.insert(
key.clone(),
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
for (key, value) in header_raw {
headers.insert(
key,
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
let mut query_params = HashMap::new();
for (key, value) in global_queries {
query_params.insert(
key.clone(),
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
for (key, value) in query_raw {
query_params.insert(
key,
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
let mut form_text = HashMap::new();
for (key, value) in form_text_raw {
form_text.insert(
key,
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
let mut form_files = HashMap::new();
for (key, value) in form_file_raw {
form_files.insert(
key,
context::inject_from_variable(value.as_str(), &scalar_map),
);
}
let body = body_raw.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let body_content_type = body_content_type_raw
.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map));
let callback_src = callback_src_raw
.into_iter()
.map(|raw| context::inject_from_variable(raw.as_str(), &scalar_map))
.collect();
let mut response_captures = Vec::new();
for (raw, span) in response_capture_raw {
let capture = ResponseCapture::parse(raw.as_str(), &scalar_map).map_err(|e| {
pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError { message: e.0 },
span,
)
})?;
response_captures.push(capture);
}
let mut assertions = Vec::new();
for (raw, span) in assertion_raw {
let assertion = Assertion::parse(raw.as_str(), &scalar_map).map_err(|e| {
pest::error::Error::<Rule>::new_from_span(
pest::error::ErrorVariant::CustomError { message: e.0 },
span,
)
})?;
assertions.push(assertion);
}
Ok(RequestTemplate {
group_key,
description_template,
method,
url: url_raw,
headers,
query_params,
form_text,
form_files,
body,
body_content_type,
callback_src,
response_captures,
assertions,
declared_dependencies: dependencies,
variable_store: store,
working_dir: working_dir.clone(),
})
}
fn parse_dependency(raw: &str) -> String {
let remainder = raw
.strip_prefix('>')
.map(|s| s.trim())
.unwrap_or(raw.trim());
let remainder = remainder
.strip_prefix("requires")
.map(|s| s.trim_start())
.unwrap_or(remainder);
let remainder = remainder
.strip_prefix(':')
.map(|s| s.trim_start())
.unwrap_or(remainder);
let name = remainder.trim();
if name.starts_with('"') && name.ends_with('"') && name.len() >= 2 {
name[1..name.len() - 1].trim().to_string()
} else {
name.to_string()
}
}
pub fn eval_shell_script(
script: &str,
working_dir: &PathBuf,
env: Option<HashMap<String, String>>,
) -> String {
let env = env.unwrap_or_default();
log::debug!("evaluating shell script: {}", script);
log::debug!("using directory {:?}", working_dir);
let output = std::process::Command::new("sh")
.current_dir(working_dir)
.arg("-c")
.envs(env)
.arg(script)
.output()
.expect("failed to execute process");
String::from_utf8(output.stdout).unwrap()
}
fn assign_global_variable(
store: &mut VariableStore,
key: String,
raw_value: &str,
working_dir: &PathBuf,
) -> Result<(), TemplateError> {
let label = format!("${}", key);
let trimmed = raw_value.trim();
if trimmed.starts_with("$(") {
let script = trimmed.trim_start_matches("$(").trim_end_matches(")");
let value = eval_shell_script(script, working_dir, None).trim().to_string();
store.set_scalar(key, value);
return Ok(());
}
if is_array_literal(trimmed) {
let values = parse_array_literal(
trimmed,
&label,
&key,
|value| context::inject_from_prompt(value),
)?;
store.set_array(key, values);
return Ok(());
}
let value = context::inject_from_prompt(trimmed);
store.set_scalar(key, value);
Ok(())
}
fn assign_request_variable(
store: &mut VariableStore,
key: String,
raw_value: &str,
working_dir: &PathBuf,
) -> Result<(), TemplateError> {
let label = format!("request variable ${}", key);
let trimmed = raw_value.trim();
if trimmed.starts_with("$(") {
let script = trimmed.trim_start_matches("$(").trim_end_matches(")");
let value = eval_shell_script(script, working_dir, None).trim().to_string();
store.set_scalar(key, value);
return Ok(());
}
let current_scalars = store.clone_scalars();
if is_array_literal(trimmed) {
let values = parse_array_literal(
trimmed,
&label,
&key,
|value| context::inject_from_variable(value, ¤t_scalars),
)?;
store.set_array(key, values);
return Ok(());
}
let value = context::inject_from_variable(trimmed, ¤t_scalars);
store.set_scalar(key, value);
Ok(())
}
fn is_array_literal(raw: &str) -> bool {
let trimmed = raw.trim();
trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2
}
fn parse_array_literal<F>(
raw: &str,
label: &str,
variable: &str,
mut transform: F,
) -> Result<Vec<String>, TemplateError>
where
F: FnMut(&str) -> String,
{
if !is_array_literal(raw) {
return Err(TemplateError::InvalidArrayValue {
request: label.to_string(),
variable: variable.to_string(),
value: raw.to_string(),
});
}
let inner = &raw[1..raw.len() - 1];
if inner.trim().is_empty() {
return Err(TemplateError::EmptyArrayValues {
request: label.to_string(),
variable: variable.to_string(),
});
}
let mut segments: Vec<String> = Vec::new();
let mut current = String::new();
let mut in_single = false;
let mut in_double = false;
for ch in inner.chars() {
match ch {
'\'' => {
if !in_double {
in_single = !in_single;
}
current.push(ch);
}
'"' => {
if !in_single {
in_double = !in_double;
}
current.push(ch);
}
',' if !in_single && !in_double => {
if current.trim().is_empty() {
return Err(TemplateError::InvalidArrayValue {
request: label.to_string(),
variable: variable.to_string(),
value: raw.to_string(),
});
}
segments.push(current.trim().to_string());
current.clear();
}
_ => current.push(ch),
}
}
if in_single || in_double {
return Err(TemplateError::InvalidArrayValue {
request: label.to_string(),
variable: variable.to_string(),
value: raw.to_string(),
});
}
if current.trim().is_empty() {
return Err(TemplateError::InvalidArrayValue {
request: label.to_string(),
variable: variable.to_string(),
value: raw.to_string(),
});
}
segments.push(current.trim().to_string());
let mut values = Vec::with_capacity(segments.len());
for segment in segments {
let normalized = normalize_array_element(segment.as_str());
if normalized.trim().is_empty() {
return Err(TemplateError::InvalidArrayValue {
request: label.to_string(),
variable: variable.to_string(),
value: raw.to_string(),
});
}
values.push(transform(normalized.trim()));
}
Ok(values)
}
fn normalize_array_element(value: &str) -> String {
let trimmed = value.trim();
if (trimmed.starts_with('"') && trimmed.ends_with('"') && trimmed.len() >= 2)
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'') && trimmed.len() >= 2)
{
trimmed[1..trimmed.len() - 1].to_string()
} else {
trimmed.to_string()
}
}
fn template_error_to_span(
error: TemplateError,
span: pest::Span<'_>,
) -> pest::error::Error<Rule> {
pest::error::Error::new_from_span(
pest::error::ErrorVariant::CustomError {
message: error.to_string(),
},
span,
)
}
fn template_error_to_collection_error(
error: TemplateError,
source: &str,
) -> pest::error::Error<Rule> {
pest::error::Error::new_from_span(
pest::error::ErrorVariant::CustomError {
message: error.to_string(),
},
pest::Span::new(source, 0, source.len()).unwrap(),
)
}