use std::path::Path;
use pest_derive::Parser;
#[derive(Parser)]
#[grammar = "src/parser/preprocessor.pest"]
#[allow(dead_code)]
struct PreprocessorParser;
type PreprocessError = Box<pest::error::Error<Rule>>;
pub fn preprocess(input: &str, working_dir: &Path) -> Result<String, PreprocessError> {
let mut lines: Vec<String> = vec![];
let mut pending_assertion_label: Option<String> = None;
let mut in_body_block = false;
for raw_line in input.lines() {
let trimmed = raw_line.trim();
if let Some(label) = pending_assertion_label.take() {
if is_assertion_line(trimmed) {
lines.push(format!("^: {label}"));
}
}
if trimmed.is_empty() {
continue;
}
if trimmed.starts_with("~~~") {
in_body_block = !in_body_block;
lines.push(trimmed.to_string());
continue;
}
if in_body_block {
if trimmed.starts_with('#') {
continue;
}
lines.push(trimmed.to_string());
continue;
}
if let Some(label) = trimmed.strip_prefix('#') {
let label = label.trim();
if !label.is_empty() {
pending_assertion_label = Some(label.to_string());
}
continue;
}
if let Some(arg) = parse_import_target(trimmed) {
lines.push(resolve_import(arg.as_str(), working_dir)?);
continue;
}
lines.push(trimmed.to_string());
}
Ok(lines.join("\n"))
}
fn is_assertion_line(line: &str) -> bool {
if line.starts_with('^') {
return true;
}
let Some(remainder) = line.strip_prefix('[') else {
return false;
};
let Some((_, remainder)) = remainder.split_once(']') else {
return false;
};
remainder.trim_start().starts_with('^')
}
fn parse_import_target(line: &str) -> Option<String> {
if let Some(remainder) = line.strip_prefix("<<") {
let target = remainder.trim();
return (!target.is_empty()).then(|| target.to_string());
}
let remainder = line.strip_prefix('[')?;
let (guard, remainder) = remainder.split_once(']')?;
if guard.trim().is_empty() {
return None;
}
let remainder = remainder.trim_start().strip_prefix("<<")?;
let target = remainder.trim();
(!target.is_empty()).then(|| target.to_string())
}
fn resolve_import(path: &str, working_dir: &Path) -> Result<String, PreprocessError> {
let import_path = working_dir.join(path);
let file_content = std::fs::read_to_string(&import_path).map_err(|err| {
custom_error(
path,
format!("Failed to read import '{}': {}", import_path.display(), err),
)
})?;
let import_working_dir = import_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| working_dir.to_path_buf());
preprocess(&file_content, &import_working_dir)
}
fn custom_error(source: &str, message: impl Into<String>) -> PreprocessError {
Box::new(pest::error::Error::new_from_pos(
pest::error::ErrorVariant::CustomError {
message: message.into(),
},
pest::Position::from_start(source),
))
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[test]
fn nested_imports_resolve_relative_to_imported_file() {
let dir = tempdir().expect("tempdir should exist");
let root = dir.path();
let nested_dir = root.join("nested");
std::fs::create_dir_all(&nested_dir).expect("nested dir should exist");
std::fs::write(root.join("first.hen"), "<< nested/second.hen\n").expect("first import");
std::fs::write(nested_dir.join("second.hen"), "<< third.hen\n").expect("second import");
std::fs::write(nested_dir.join("third.hen"), "GET https://example.com\n")
.expect("third import");
let output = preprocess("<< first.hen\n", root).expect("preprocess should succeed");
assert_eq!(output, "GET https://example.com");
}
#[test]
fn missing_import_returns_readable_error() {
let dir = tempdir().expect("tempdir should exist");
let err = preprocess("<< missing.hen\n", dir.path()).expect_err("preprocess should fail");
assert!(err.to_string().contains("Failed to read import"));
assert!(err.to_string().contains("missing.hen"));
}
#[test]
fn adjacent_comments_become_assertion_labels() {
let output = preprocess(
"GET https://example.com\n# The page loads\n^ & status == 200\n",
Path::new("."),
)
.expect("preprocess should succeed");
assert_eq!(output, "GET https://example.com\n^: The page loads\n^ & status == 200");
}
#[test]
fn comments_only_label_the_immediately_following_assertion_line() {
let output = preprocess(
"# Ignored\n\n^ & status == 200\n# Still ignored\nGET https://example.com\n# Applied\n[ status == 200 ] ^ & body.ok == true\n",
Path::new("."),
)
.expect("preprocess should succeed");
assert_eq!(
output,
"^ & status == 200\nGET https://example.com\n^: Applied\n[ status == 200 ] ^ & body.ok == true"
);
}
}