use std::{fmt, path::{Path, PathBuf}};
use pest_derive::Parser;
use serde_json::json;
use crate::error::{
HenDiagnostic, HenDiagnosticLocation, HenDiagnosticPhase,
HenDiagnosticPosition, HenDiagnosticRange,
HenDiagnosticRelatedInformation, HenDiagnosticSeverity,
HenDiagnosticSymbol,
};
#[derive(Parser)]
#[grammar = "src/parser/preprocessor.pest"]
#[allow(dead_code)]
struct PreprocessorParser;
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PreprocessLocation {
pub path: Option<PathBuf>,
pub line: usize,
pub start_character: usize,
pub end_character: usize,
}
impl PreprocessLocation {
fn new(
path: Option<PathBuf>,
line: usize,
start_character: usize,
end_character: usize,
) -> Self {
Self {
path,
line,
start_character,
end_character: end_character.max(start_character.saturating_add(1)),
}
}
fn to_hen_location(&self) -> HenDiagnosticLocation {
HenDiagnosticLocation {
path: self.path.as_ref().map(|value| value.display().to_string()),
range: HenDiagnosticRange {
start: HenDiagnosticPosition {
line: self.line,
character: self.start_character,
},
end: HenDiagnosticPosition {
line: self.line,
character: self.end_character,
},
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PreprocessRelatedInformation {
pub message: String,
pub location: PreprocessLocation,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(super) struct PreprocessError {
code: &'static str,
message: String,
location: PreprocessLocation,
related_information: Vec<PreprocessRelatedInformation>,
symbol: Option<HenDiagnosticSymbol>,
data: Option<serde_json::Value>,
}
impl PreprocessError {
fn new(
code: &'static str,
message: impl Into<String>,
location: PreprocessLocation,
) -> Self {
Self {
code,
message: message.into(),
location,
related_information: Vec::new(),
symbol: None,
data: None,
}
}
pub(super) fn message(&self) -> &str {
&self.message
}
fn with_import_context(mut self, location: PreprocessLocation, imported_path: &Path) -> Self {
let mut related_information = vec![PreprocessRelatedInformation {
message: format!("Imported file '{}' failed here.", imported_path.display()),
location: self.location.clone(),
}];
related_information.append(&mut self.related_information);
self.location = location;
self.related_information = related_information;
self
}
fn with_symbol(mut self, symbol: HenDiagnosticSymbol) -> Self {
self.symbol = Some(symbol);
self
}
fn with_data(mut self, data: serde_json::Value) -> Self {
self.data = Some(data);
self
}
fn to_hen_diagnostic(&self) -> HenDiagnostic {
HenDiagnostic {
code: self.code.to_string(),
severity: HenDiagnosticSeverity::Error,
phase: HenDiagnosticPhase::Preprocess,
message: self.message.clone(),
source: "hen.preprocess",
location: self.location.to_hen_location(),
related_information: self
.related_information
.iter()
.map(|related| HenDiagnosticRelatedInformation {
message: related.message.clone(),
location: related.location.to_hen_location(),
})
.collect(),
symbol: self.symbol.clone(),
suggestions: Vec::new(),
data: self.data.clone(),
}
}
}
impl fmt::Display for PreprocessError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.message)
}
}
impl std::error::Error for PreprocessError {}
#[derive(Debug, Clone, PartialEq, Eq)]
struct ImportTarget {
path: String,
start_character: usize,
end_character: usize,
}
pub fn preprocess(input: &str, working_dir: &Path) -> Result<String, PreprocessError> {
let mut import_stack = Vec::new();
preprocess_with_source_path(input, working_dir, None, &mut import_stack)
}
pub(super) fn structured_diagnostic_for_message(
input: &str,
working_dir: &Path,
message: &str,
root_path: Option<&Path>,
) -> Option<HenDiagnostic> {
if !is_preprocess_message(message) {
return None;
}
let mut import_stack = Vec::new();
let error = preprocess_with_source_path(input, working_dir, root_path, &mut import_stack)
.err()?;
if error.message() != message {
return None;
}
Some(error.to_hen_diagnostic())
}
fn preprocess_with_source_path(
input: &str,
working_dir: &Path,
source_path: Option<&Path>,
import_stack: &mut Vec<PathBuf>,
) -> Result<String, PreprocessError> {
let normalized_source_path = source_path.map(|path| normalize_path_for_cycle(path, working_dir));
if let Some(source_path) = normalized_source_path.as_ref() {
import_stack.push(source_path.clone());
}
let result = (|| {
let mut lines: Vec<String> = vec![];
let mut pending_assertion_label: Option<String> = None;
let mut in_body_block = false;
for (line_index, raw_line) in input.lines().enumerate() {
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(import_target) = parse_import_target(raw_line) {
lines.push(resolve_import(
&import_target,
working_dir,
source_path,
line_index,
import_stack,
)?);
continue;
}
lines.push(trimmed.to_string());
}
Ok(lines.join("\n"))
})();
if normalized_source_path.is_some() {
import_stack.pop();
}
result
}
fn is_preprocess_message(message: &str) -> bool {
message.starts_with("Failed to read import '")
|| message.starts_with("Fragment import cycle detected:")
}
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<ImportTarget> {
let leading_whitespace = line.len().saturating_sub(line.trim_start().len());
let trimmed = line.trim_start();
if trimmed.starts_with("<<") {
return parse_import_target_segment(trimmed, leading_whitespace);
}
let remainder = trimmed.strip_prefix('[')?;
let (guard, remainder) = remainder.split_once(']')?;
if guard.trim().is_empty() {
return None;
}
let leading_inner_whitespace = remainder.len().saturating_sub(remainder.trim_start().len());
let remainder = remainder.trim_start();
if !remainder.starts_with("<<") {
return None;
}
parse_import_target_segment(
remainder,
leading_whitespace + guard.len() + 2 + leading_inner_whitespace,
)
}
fn parse_import_target_segment(line: &str, import_offset: usize) -> Option<ImportTarget> {
let after_import = line.strip_prefix("<<")?;
let leading_whitespace = after_import.len().saturating_sub(after_import.trim_start().len());
let target = after_import.trim();
if target.is_empty() {
return None;
}
let start_character = import_offset + 2 + leading_whitespace;
let end_character = start_character + target.len();
Some(ImportTarget {
path: target.to_string(),
start_character,
end_character,
})
}
fn resolve_import(
import_target: &ImportTarget,
working_dir: &Path,
source_path: Option<&Path>,
line_index: usize,
import_stack: &mut Vec<PathBuf>,
) -> Result<String, PreprocessError> {
let import_path = if Path::new(import_target.path.as_str()).is_absolute() {
PathBuf::from(import_target.path.as_str())
} else {
working_dir.join(import_target.path.as_str())
};
let import_location = PreprocessLocation::new(
source_path.map(Path::to_path_buf),
line_index,
import_target.start_character,
import_target.end_character,
);
let normalized_import_path = normalize_path_for_cycle(import_path.as_path(), working_dir);
if let Some(cycle_start) = import_stack
.iter()
.position(|active_path| *active_path == normalized_import_path)
{
return Err(import_cycle_error(
import_stack,
cycle_start,
normalized_import_path.as_path(),
import_location,
));
}
let file_content = std::fs::read_to_string(&import_path).map_err(|err| {
PreprocessError::new(
"fragment_import_io",
format!("Failed to read import '{}': {}", import_path.display(), err),
import_location.clone(),
)
.with_symbol(fragment_import_symbol(import_target.path.as_str()))
})?;
let import_working_dir = import_path
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| working_dir.to_path_buf());
preprocess_with_source_path(
&file_content,
&import_working_dir,
Some(import_path.as_path()),
import_stack,
)
.map_err(|error| error.with_import_context(import_location, import_path.as_path()))
}
fn normalize_path_for_cycle(path: &Path, working_dir: &Path) -> PathBuf {
let candidate = if path.is_absolute() {
path.to_path_buf()
} else {
working_dir.join(path)
};
candidate.canonicalize().unwrap_or(candidate)
}
fn import_cycle_error(
import_stack: &[PathBuf],
cycle_start: usize,
import_path: &Path,
location: PreprocessLocation,
) -> PreprocessError {
let cycle = import_stack[cycle_start..]
.iter()
.map(|path| path.display().to_string())
.chain(std::iter::once(import_path.display().to_string()))
.collect::<Vec<_>>()
.join(" -> ");
PreprocessError::new(
"fragment_import_cycle",
format!("Fragment import cycle detected: {cycle}."),
location,
)
.with_symbol(fragment_import_symbol(import_path.display().to_string().as_str()))
.with_data(json!({
"cycleMembers": import_stack[cycle_start..]
.iter()
.map(|path| path.display().to_string())
.chain(std::iter::once(import_path.display().to_string()))
.collect::<Vec<_>>(),
}))
}
fn fragment_import_symbol(name: &str) -> HenDiagnosticSymbol {
HenDiagnosticSymbol {
kind: "fragment".to_string(),
name: name.to_string(),
role: "import".to_string(),
}
}
#[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 import_cycles_return_readable_error() {
let dir = tempdir().expect("tempdir should exist");
std::fs::write(dir.path().join("first.hen"), "<< second.hen\n")
.expect("first import should exist");
std::fs::write(dir.path().join("second.hen"), "<< first.hen\n")
.expect("second import should exist");
let err = preprocess("<< first.hen\n", dir.path()).expect_err("preprocess should fail");
assert!(err.to_string().contains("Fragment import cycle detected"));
assert!(err.to_string().contains("first.hen"));
assert!(err.to_string().contains("second.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"
);
}
}