use std::collections::BTreeMap;
use std::sync::Arc;
use crate::bind;
use crate::diag::{Diag, Span};
use crate::emit;
use crate::engine::StepKindSpec;
use crate::feature;
use crate::lower::{self, LowerCtx};
use crate::pack::{self, PackSet, PackSource};
use crate::provider::SourceProvider;
use crate::world::{GlobalStore, World};
#[derive(Debug, Clone)]
pub struct Binding {
pub feature: String,
pub step_span: Span,
pub macro_name: String,
}
#[derive(Debug, Clone)]
pub struct MacroRef {
pub name: String,
pub pattern: Option<String>,
pub params: Vec<String>,
pub pack: String,
pub def_span: Option<Span>,
}
#[derive(Debug, Default)]
pub struct SuiteAnalysis {
pub diagnostics: BTreeMap<String, Vec<Diag>>,
pub bindings: Vec<Binding>,
pub macros: Vec<MacroRef>,
}
pub struct AnalyzeCtx<'a> {
pub provider: &'a dyn SourceProvider,
pub kinds: &'a [StepKindSpec],
pub kind_to_engine: &'a BTreeMap<String, String>,
pub env: &'a BTreeMap<String, String>,
pub config_vars: &'a BTreeMap<String, String>,
pub run_id: &'a str,
}
impl SuiteAnalysis {
fn push_diags(&mut self, name: &str, diags: impl IntoIterator<Item = Diag>) {
self.diagnostics.entry(name.to_owned()).or_default();
for d in diags {
let target = d.source_name.clone().unwrap_or_else(|| name.to_owned());
self.diagnostics.entry(target).or_default().push(d);
}
}
}
pub fn analyze_suite(ctx: &AnalyzeCtx<'_>) -> SuiteAnalysis {
let mut out = SuiteAnalysis::default();
let mut sources = pack::builtin_sources();
let pack_names = ctx.provider.discover_packs().unwrap_or_default();
for name in &pack_names {
match ctx.provider.read(name) {
Ok(text) => sources.push(PackSource {
name: name.clone(),
text,
}),
Err(e) => out.push_diags(name, [read_error_diag(name, &e.0)]),
}
}
let packs: Arc<PackSet> = match pack::load(&sources, ctx.kinds) {
Ok(set) => Arc::new(set),
Err(err) => {
for d in front_error_diags(err) {
let name = d.source_name.clone().unwrap_or_default();
out.push_diags(&name, [d]);
}
return out; }
};
for m in packs.macros.values() {
out.macros.push(MacroRef {
name: m.name.clone(),
pattern: m.pattern.clone(),
params: m.params.clone(),
pack: m.pack.clone(),
def_span: m.span,
});
}
let world = World::new(GlobalStore::default());
let feature_names = ctx.provider.discover_features().unwrap_or_default();
for name in &feature_names {
let text = match ctx.provider.read(name) {
Ok(t) => t,
Err(e) => {
out.push_diags(name, [read_error_diag(name, &e.0)]);
continue;
}
};
let file = match feature::parse(name, &text) {
Ok(f) => f,
Err(errs) => {
out.push_diags(name, errs);
continue; }
};
let (bound, bind_diags) = bind::bind_collect(&file, &packs);
out.push_diags(name, bind_diags);
for scenario in &bound {
for step in &scenario.steps {
out.bindings.push(Binding {
feature: name.clone(),
step_span: step.defn.span,
macro_name: step.macro_name.clone(),
});
}
}
let ctx_lower = LowerCtx {
feature: &file,
packs: &packs,
kind_to_engine: ctx.kind_to_engine,
env: ctx.env,
config_vars: ctx.config_vars,
run_id: ctx.run_id,
world: &world,
mode: crate::resolve::ResolveMode::DryRun,
};
for scenario in &bound {
match lower::lower(scenario, &ctx_lower) {
Ok(lowered) => {
out.push_diags(name, lowered.warnings.iter().cloned());
let stem = feature_stem(name);
if let Some(artifact) = emit::emit(&lowered, &stem, &world) {
let mut diags = Vec::new();
validate_artifact(&artifact, &lowered, ctx.kinds, &mut diags);
out.push_diags(name, diags);
}
}
Err(errs) => out.push_diags(name, errs),
}
}
}
out
}
fn feature_stem(name: &str) -> String {
std::path::Path::new(name).file_stem().map_or_else(
|| "feature".to_owned(),
|s| s.to_string_lossy().into_owned(),
)
}
fn read_error_diag(name: &str, msg: &str) -> Diag {
Diag::error(
"proef::source::unreadable",
format!("cannot read {name}: {msg}"),
)
.with_source(name.to_owned(), Arc::from(""))
}
fn front_error_diags(err: crate::diag::FrontError) -> Vec<Diag> {
match err {
crate::diag::FrontError::Diagnostics(list) => list,
crate::diag::FrontError::Core(core) => {
vec![Diag::error("proef::pack::load", core.to_string())]
}
}
}
pub fn validate_artifact(
artifact: &emit::Artifact,
lowered: &lower::LoweredScenario,
kinds: &[StepKindSpec],
diags: &mut Vec<Diag>,
) {
let Some(kind) = lowered
.batches
.iter()
.flat_map(|b| b.steps.iter())
.find(|s| matches!(s.payload, crate::step::StepPayload::HurlEntries(_)))
.map(|s| s.kind.as_str().to_owned())
else {
return;
};
let Some(validate) = kinds
.iter()
.find(|k| k.prefix == kind)
.and_then(|k| k.validate)
else {
return;
};
if let Err(err) = validate(&artifact.hurl_text) {
let offset: usize = artifact
.hurl_text
.split_inclusive('\n')
.take(err.line.saturating_sub(1))
.map(str::len)
.sum();
let line_len = artifact.hurl_text[offset..]
.lines()
.next()
.unwrap_or("")
.len();
diags.push(
Diag::error(
"proef::emit::invalid_artifact",
format!(
"emitted artifact `{}.hurl` does not parse: {} (line {}, column {})",
artifact.slug, err.message, err.line, err.column
),
)
.with_source(
format!("{}.hurl (emitted)", artifact.slug),
std::sync::Arc::from(artifact.hurl_text.as_str()),
)
.with_span(Span::clamped(
offset,
offset + line_len.max(1),
artifact.hurl_text.len(),
)),
);
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::expect_used)]
use super::*;
use crate::provider::{ProviderError, SourceProvider};
use std::collections::BTreeMap;
use std::sync::Arc;
struct MemProvider {
features: Vec<String>,
packs: Vec<String>,
files: BTreeMap<String, Arc<str>>,
}
impl SourceProvider for MemProvider {
fn discover_features(&self) -> Result<Vec<String>, ProviderError> {
Ok(self.features.clone())
}
fn discover_packs(&self) -> Result<Vec<String>, ProviderError> {
Ok(self.packs.clone())
}
fn read(&self, name: &str) -> Result<Arc<str>, ProviderError> {
self.files
.get(name)
.cloned()
.ok_or_else(|| ProviderError(format!("no source {name}")))
}
}
const KINDS: &[StepKindSpec] = &[StepKindSpec {
prefix: "hurl",
schema: "true",
validate: None,
}];
fn hurl_kind_map() -> &'static BTreeMap<String, String> {
use std::sync::OnceLock;
static M: OnceLock<BTreeMap<String, String>> = OnceLock::new();
M.get_or_init(|| BTreeMap::from([("hurl".to_owned(), "hurl".to_owned())]))
}
fn ctx_over<'a>(
provider: &'a dyn SourceProvider,
empty: &'a BTreeMap<String, String>,
) -> AnalyzeCtx<'a> {
AnalyzeCtx {
provider,
kinds: KINDS,
kind_to_engine: hurl_kind_map(),
env: empty,
config_vars: empty,
run_id: "lsp",
}
}
#[test]
fn analyze_surfaces_bindings_and_no_errors_on_a_clean_suite() {
let mut files = BTreeMap::new();
files.insert(
"packs/p.yaml".to_owned(),
Arc::from(
"macros:\n greet:\n params: [who]\n match: \"I greet {who}\"\n steps:\n - hurl: |\n GET http://x\n",
),
);
files.insert(
"f.feature".to_owned(),
Arc::from("Feature: F\n Scenario: S\n When I greet Sam\n"),
);
let provider = MemProvider {
features: vec!["f.feature".to_owned()],
packs: vec!["packs/p.yaml".to_owned()],
files,
};
let empty = BTreeMap::new();
let analysis = analyze_suite(&ctx_over(&provider, &empty));
let errors: usize = analysis
.diagnostics
.values()
.flatten()
.filter(|d| d.severity == crate::diag::Severity::Error)
.count();
assert_eq!(
errors, 0,
"clean suite must have zero errors: {:?}",
analysis.diagnostics
);
assert!(
analysis
.bindings
.iter()
.any(|b| b.macro_name == "greet" && b.feature == "f.feature"),
"the greet step must be recorded as a binding"
);
assert!(
analysis
.macros
.iter()
.any(|m| m.name == "greet" && m.pattern.is_some())
);
}
#[test]
fn analyze_collects_unbound_without_cascade() {
let mut files = BTreeMap::new();
files.insert("packs/p.yaml".to_owned(), Arc::from("macros: {}\n"));
files.insert(
"f.feature".to_owned(),
Arc::from("Feature: F\n Scenario: S\n When nothing matches this\n"),
);
let provider = MemProvider {
features: vec!["f.feature".to_owned()],
packs: vec!["packs/p.yaml".to_owned()],
files,
};
let empty = BTreeMap::new();
let analysis = analyze_suite(&ctx_over(&provider, &empty));
let feature_diags = analysis
.diagnostics
.get("f.feature")
.expect("feature bucket");
assert!(
feature_diags
.iter()
.any(|d| d.code == "proef::bind::unbound_step")
);
let errors: Vec<_> = feature_diags
.iter()
.filter(|d| d.severity == crate::diag::Severity::Error)
.collect();
assert_eq!(
errors.len(),
1,
"the unbound step must be the only error-severity diagnostic, no spurious extras: {feature_diags:?}"
);
assert_eq!(errors[0].code, "proef::bind::unbound_step");
}
#[test]
fn analyze_parse_failed_feature_does_not_cascade_to_sibling() {
let mut files = BTreeMap::new();
files.insert(
"packs/p.yaml".to_owned(),
Arc::from(
"macros:\n greet:\n params: [who]\n match: \"I greet {who}\"\n steps:\n - hurl: |\n GET http://x\n",
),
);
files.insert("bad.feature".to_owned(), Arc::from(" \n"));
files.insert(
"good.feature".to_owned(),
Arc::from("Feature: F\n Scenario: S\n When I greet Sam\n"),
);
let provider = MemProvider {
features: vec!["bad.feature".to_owned(), "good.feature".to_owned()],
packs: vec!["packs/p.yaml".to_owned()],
files,
};
let empty = BTreeMap::new();
let analysis = analyze_suite(&ctx_over(&provider, &empty));
let bad_diags = analysis
.diagnostics
.get("bad.feature")
.expect("bad.feature bucket");
assert!(
bad_diags
.iter()
.any(|d| d.code == "proef::feature::empty_file"),
"the parse-failed feature must carry its parse-error diagnostic: {bad_diags:?}"
);
let good_diags = analysis
.diagnostics
.get("good.feature")
.expect("good.feature bucket");
assert!(
good_diags
.iter()
.all(|d| d.severity != crate::diag::Severity::Error),
"the valid sibling feature must have no error diagnostics despite the parse failure next to it: {good_diags:?}"
);
assert!(
analysis
.bindings
.iter()
.any(|b| b.macro_name == "greet" && b.feature == "good.feature"),
"the valid sibling feature must still produce its binding — no cascade from the parse-failed feature"
);
}
#[test]
fn analyze_broken_pack_short_circuits_before_feature_binding() {
let mut files = BTreeMap::new();
files.insert(
"packs/broken.yaml".to_owned(),
Arc::from("macros: {}\nbogus: true\n"),
);
files.insert(
"f.feature".to_owned(),
Arc::from("Feature: F\n Scenario: S\n When I greet Sam\n"),
);
let provider = MemProvider {
features: vec!["f.feature".to_owned()],
packs: vec!["packs/broken.yaml".to_owned()],
files,
};
let empty = BTreeMap::new();
let analysis = analyze_suite(&ctx_over(&provider, &empty));
let pack_diags = analysis
.diagnostics
.get("packs/broken.yaml")
.expect("pack bucket");
assert!(
pack_diags.iter().any(|d| d.code == "proef::pack::yaml"),
"the broken pack must carry its yaml diagnostic: {pack_diags:?}"
);
assert!(
analysis.bindings.is_empty(),
"no bindings should be produced when the pack fails to load: {:?}",
analysis.bindings
);
let feature_diags = analysis.diagnostics.get("f.feature");
assert!(
feature_diags
.is_none_or(|diags| !diags.iter().any(|d| d.code.starts_with("proef::bind::"))),
"the feature must not be falsely reported when the pack short-circuited binding: {feature_diags:?}"
);
}
}