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, MacroBody, MacroStepKind, 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>,
pub match_span: Option<Span>,
}
#[derive(Debug, Clone)]
pub struct UseRef {
pub pack: String,
pub span: Span,
pub target_macro: String,
}
#[derive(Debug, Default)]
pub struct SuiteAnalysis {
pub diagnostics: BTreeMap<String, Vec<Diag>>,
pub bindings: Vec<Binding>,
pub macros: Vec<MacroRef>,
pub use_refs: Vec<UseRef>,
}
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 (loaded, pack_diags) = pack::load_collecting(&sources, ctx.kinds);
for d in pack_diags {
let name = d.source_name.clone().unwrap_or_default();
out.push_diags(&name, [d]);
}
let packs: Arc<PackSet> = Arc::new(loaded);
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,
match_span: m.match_span,
});
}
out.use_refs = index_use_refs(&packs);
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 index_use_refs(packs: &PackSet) -> Vec<UseRef> {
let mut use_refs = Vec::new();
for m in packs.macros.values() {
let MacroBody::Steps(steps) = &m.body else {
continue;
};
let targets: Vec<&str> = steps
.iter()
.filter_map(|step| match &step.kind {
MacroStepKind::Use { target, .. } => Some(target.as_str()),
MacroStepKind::Payload { .. } => None,
})
.collect();
let spans = crate::pack::locate::use_line_spans(&m.source, &m.name);
if spans.len() != targets.len() {
continue; }
for (span, target) in spans.into_iter().zip(targets) {
if let Some(target_macro) = packs.find_use_target(target) {
use_refs.push(UseRef {
pack: m.pack.clone(),
span,
target_macro: target_macro.name.clone(),
});
}
}
}
use_refs
}
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(""))
}
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_degrades_when_one_pack_is_broken() {
let mut files = BTreeMap::new();
files.insert(
"packs/good.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(
"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/good.yaml".to_owned(), "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("broken 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.macros.iter().any(|m| m.name == "greet"),
"the good pack's macro must survive the broken sibling"
);
assert!(
analysis
.bindings
.iter()
.any(|b| b.macro_name == "greet" && b.feature == "f.feature"),
"the feature must still bind to the good-pack macro despite the broken pack"
);
}
#[test]
fn analyze_records_use_refs_and_match_spans() {
let mut files = BTreeMap::new();
files.insert(
"packs/p.yaml".to_owned(),
Arc::from(
"macros:\n base:\n match: the base\n steps:\n - hurl: |\n GET http://x\n wrapper:\n steps:\n - use: base\n",
),
);
let provider = MemProvider {
features: vec![],
packs: vec!["packs/p.yaml".to_owned()],
files,
};
let empty = BTreeMap::new();
let analysis = analyze_suite(&ctx_over(&provider, &empty));
let u = analysis
.use_refs
.iter()
.find(|u| u.target_macro == "base")
.expect("use_ref for base");
assert_eq!(u.pack, "packs/p.yaml");
let src = provider_text(&provider, "packs/p.yaml");
assert_eq!(&src[u.span.start..u.span.end], "use: base");
let base = analysis
.macros
.iter()
.find(|m| m.name == "base")
.expect("macro base");
assert!(base.match_span.is_some());
let wrapper = analysis
.macros
.iter()
.find(|m| m.name == "wrapper")
.expect("macro wrapper");
assert!(wrapper.match_span.is_none());
}
#[test]
fn analyze_skips_use_refs_when_flow_and_block_style_counts_diverge() {
let mut files = BTreeMap::new();
files.insert(
"packs/p.yaml".to_owned(),
Arc::from(
"macros:\n base:\n match: the base\n steps:\n - hurl: |\n GET http://x\n wrapper:\n steps:\n - {use: base}\n - use: base\n",
),
);
let provider = MemProvider {
features: vec![],
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,
"the mixed-style pack must be valid, zero errors: {:?}",
analysis.diagnostics
);
assert!(
analysis.use_refs.is_empty(),
"count mismatch must skip UseRef generation for wrapper entirely, \
not emit a misaligned pairing: {:?}",
analysis.use_refs
);
}
fn provider_text(p: &MemProvider, name: &str) -> String {
p.read(name).expect("provider source").to_string()
}
}