use std::borrow::Cow;
use hurl_core::ast::visit::{self, Visitor};
use hurl_core::ast::{
Comment, Entry, ExprKind, OptionKind, Placeholder, Template, TemplateElement,
};
use proef_core::engine::{FragmentScanError, ScannedFile, ScannedFragment};
const MARKER: &str = "@proef";
pub(crate) fn scan(text: &str) -> Result<ScannedFile, FragmentScanError> {
let stripped = text.strip_prefix('\u{feff}').unwrap_or(text);
let normalized = if stripped.ends_with('\n') {
Cow::Borrowed(stripped)
} else {
Cow::Owned(format!("{stripped}\n"))
};
let file =
hurl_core::parser::parse_hurl_file(&normalized).map_err(|err| FragmentScanError {
line: err.pos.line,
column: err.pos.column,
message: format!("{:?}", err.kind),
})?;
for lt in &file.line_terminators {
if let Some(comment) = <.comment
&& annotation_name(&comment.value).is_some()
{
return Err(at_comment(
comment,
"`@proef` annotation is followed by no request".to_owned(),
));
}
}
let lines: Vec<&str> = normalized.lines().collect();
let starts: Vec<usize> = file.entries.iter().map(start_line).collect();
let mut out = Vec::with_capacity(file.entries.len());
let mut unannotated = Vec::new();
for (index, entry) in file.entries.iter().enumerate() {
let Some(name) = annotation(entry)? else {
unannotated.push(starts[index]);
continue;
};
let start = starts[index];
let end = starts
.get(index + 1)
.copied()
.unwrap_or(lines.len() + 1)
.min(lines.len() + 1);
let body = lines
.get(start.saturating_sub(1)..end.saturating_sub(1))
.unwrap_or_default()
.join("\n");
let mut collect = Collect::default();
visit::walk_entry(&mut collect, entry);
out.push(ScannedFragment {
name,
text: format!("{}\n", body.trim_end()),
line: start,
placeholders: collect.placeholders,
declared_options: declared_options(entry),
supplied_variables: supplied_variables(entry),
});
}
Ok(ScannedFile {
fragments: out,
unannotated,
})
}
fn declared_options(entry: &Entry) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for option in entry.request.options() {
let Some(family) = crate::recognise_option(option.kind.identifier()).and_then(|o| o.family)
else {
continue;
};
if !out.iter().any(|seen| seen == family) {
out.push(family.to_owned());
}
}
out
}
fn supplied_variables(entry: &Entry) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for option in entry.request.options() {
if let OptionKind::Variable(definition) = &option.kind
&& !out.contains(&definition.name)
{
out.push(definition.name.clone());
}
}
out
}
fn start_line(entry: &Entry) -> usize {
entry
.request
.line_terminators
.iter()
.filter_map(|lt| lt.comment.as_ref())
.find(|comment| annotation_name(&comment.value).is_some())
.map_or_else(
|| entry.request.space0.source_info.start.line,
|comment| comment.source_info.start.line,
)
}
fn at_comment(comment: &Comment, message: impl Into<String>) -> FragmentScanError {
FragmentScanError {
line: comment.source_info.start.line,
column: comment.source_info.start.column,
message: message.into(),
}
}
fn annotation(entry: &Entry) -> Result<Option<String>, FragmentScanError> {
let mut found: Option<String> = None;
for lt in &entry.request.line_terminators {
let Some(comment) = <.comment else { continue };
let Some(name) = annotation_name(&comment.value) else {
continue;
};
if found.is_some() {
return Err(at_comment(
comment,
"request carries more than one `@proef` annotation".to_owned(),
));
}
if name.is_empty() {
return Err(at_comment(
comment,
"`@proef` needs a fragment name".to_owned(),
));
}
if name.split_whitespace().count() > 1 {
return Err(at_comment(
comment,
format!(
"`@proef` takes a name and nothing else, but found `{name}` — \
step settings belong in the pack"
),
));
}
if name.contains('#') {
return Err(at_comment(
comment,
format!(
"`@proef` name `{name}` contains `#`, which separates a file from a \
fragment in `ref: file.hurl#name` — such a name could never be \
referenced"
),
));
}
found = Some(name);
}
Ok(found)
}
fn annotation_name(comment: &str) -> Option<String> {
let rest = comment.trim_start().strip_prefix(MARKER)?;
if !rest.is_empty() && !rest.starts_with(char::is_whitespace) {
return None;
}
Some(rest.trim().to_owned())
}
#[derive(Default)]
struct Collect {
placeholders: Vec<String>,
}
impl Collect {
fn scan_template(&mut self, template: &Template) {
for element in &template.elements {
if let TemplateElement::Placeholder(placeholder) = element {
self.record(placeholder);
}
}
}
fn record(&mut self, placeholder: &Placeholder) {
if let ExprKind::Variable(variable) = &placeholder.expr.kind
&& !self.placeholders.contains(&variable.name)
{
self.placeholders.push(variable.name.clone());
}
}
}
impl Visitor for Collect {
fn visit_template(&mut self, template: &Template) {
self.scan_template(template);
}
fn visit_url(&mut self, url: &Template) {
self.scan_template(url);
}
fn visit_filename(&mut self, filename: &Template) {
self.scan_template(filename);
}
fn visit_placeholder(&mut self, placeholder: &Placeholder) {
self.record(placeholder);
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used)]
use super::*;
const FILE: &str = concat!(
"# a corpus file proef did not write\n",
"# @proef admin.search\n",
"GET {{base}}/api/v1/admin/search/{{index}}\n",
"Authorization: Bearer {{apiToken}}\n",
"[Query]\n",
"q: {{q}}\n",
"HTTP 200\n",
"[Captures]\n",
"recordId: jsonpath \"$[0].id\"\n",
"\n",
"DELETE {{base}}/api/v1/admin/records/{{recordId}}\n",
"HTTP 204\n",
);
#[test]
fn an_annotated_entry_reports_its_name_reads_and_writes() {
let scanned = scan(FILE).unwrap();
assert_eq!(
scanned.fragments.len(),
1,
"only the annotated entry is reported"
);
let search = &scanned.fragments[0];
assert_eq!(search.name, "admin.search");
assert_eq!(search.placeholders, ["base", "index", "apiToken", "q"]);
assert!(search.declared_options.is_empty());
}
#[test]
fn an_unannotated_entry_is_dropped_but_still_ends_the_one_before_it() {
let scanned = scan(FILE).unwrap();
assert_eq!(scanned.fragments.len(), 1);
assert!(
!scanned.fragments[0].text.contains("DELETE"),
"the dropped entry still marks where the annotated one stops: {}",
scanned.fragments[0].text
);
assert!(
!scanned.fragments[0]
.placeholders
.contains(&"recordId".to_owned()),
"nor may its reads be attributed to the fragment: {:?}",
scanned.fragments[0].placeholders
);
}
#[test]
fn fragment_text_starts_at_the_annotation_not_the_file_header() {
let scanned = scan(FILE).unwrap();
assert!(
scanned.fragments[0]
.text
.starts_with("# @proef admin.search\n")
);
assert!(
!scanned.fragments[0]
.text
.contains("a corpus file proef did not write"),
"hurl attaches a file's header to its first entry; a fragment is not the header"
);
assert!(
scanned.fragments[0]
.text
.trim_end()
.ends_with("recordId: jsonpath \"$[0].id\"")
);
assert!(
!scanned.fragments[0].text.contains("DELETE"),
"an entry must not swallow the next one"
);
assert_eq!(
scanned.fragments[0].line, 2,
"the annotation line, not the header above it"
);
assert!(hurl_core::parser::parse_hurl_file(&scanned.fragments[0].text).is_ok());
}
#[test]
fn a_retry_option_is_reported_for_the_double_declaration_check() {
let scanned = scan("# @proef poll\nGET http://x\n[Options]\nretry: 3\nHTTP 200\n").unwrap();
assert_eq!(scanned.fragments[0].declared_options, ["retry"]);
}
#[test]
fn a_supplied_variable_is_reported_by_name() {
let scanned = scan(concat!(
"# @proef auth\n",
"GET http://x\n",
"[Options]\n",
"variable: token=local-default\n",
"variable: token=again\n",
"[Query]\n",
"t: {{token}}\n",
"u: {{other}}\n",
"HTTP 200\n",
))
.unwrap();
assert_eq!(
scanned.fragments[0].supplied_variables,
["token"],
"deduped by name"
);
assert_eq!(
scanned.fragments[0].placeholders,
["token", "other"],
"supplying a variable does not stop the entry from reading it"
);
assert!(
scanned.fragments[0].declared_options.is_empty(),
"`variable:` is not an option *family* — it clashes name-to-name"
);
}
#[test]
fn every_option_family_the_scanner_emits_is_one_the_pack_knows() {
let scanned = scan(concat!(
"# @proef every\n",
"GET http://x\n",
"[Options]\n",
"retry: 3\n",
"retry-interval: 500\n",
"delay: 100\n",
"HTTP 200\n",
))
.unwrap();
assert_eq!(
scanned.fragments[0].declared_options,
["retry", "delay"],
"`retry-interval` folds into `retry`; both families are reported once"
);
for family in &scanned.fragments[0].declared_options {
assert!(
proef_core::engine::OPTION_FAMILIES.contains(&family.as_str()),
"`{family}` is not a family the pack can declare, so the clash check cannot see it"
);
}
}
#[test]
fn the_marker_must_be_its_own_word() {
let scanned = scan("# @proefX note\nGET http://x\n").unwrap();
assert!(scanned.fragments.is_empty());
assert_eq!(scanned.unannotated, [2]);
}
#[test]
fn a_leading_byte_order_mark_is_stripped_before_parsing() {
let scanned = scan("\u{feff}# @proef ping\nGET http://x\nHTTP 200\n").unwrap();
assert_eq!(scanned.fragments.len(), 1);
assert_eq!(scanned.fragments[0].name, "ping");
assert!(
!scanned.fragments[0].text.starts_with('\u{feff}'),
"and it must not travel into the artifact, which has to be valid hurl"
);
}
#[test]
fn an_annotation_carrying_more_than_a_name_is_refused() {
let err = scan("# @proef poll retry=3\nGET http://x\n").unwrap_err();
assert!(
err.message.contains("a name and nothing else"),
"{}",
err.message
);
assert_eq!(err.line, 1);
assert!(
scan("# @proef\nGET http://x\n").is_err(),
"a bare marker names nothing"
);
}
#[test]
fn two_annotations_on_one_request_are_refused() {
let err = scan("# @proef one\n# @proef two\nGET http://x\n").unwrap_err();
assert!(err.message.contains("more than one"), "{}", err.message);
}
#[test]
fn an_annotation_after_the_last_request_is_refused() {
let err = scan("GET http://x\nHTTP 200\n\n# @proef orphan\n").unwrap_err();
assert!(err.message.contains("no request"), "{}", err.message);
assert_eq!(err.line, 4);
}
#[test]
fn an_unparseable_file_reports_its_position() {
let err = scan("GET http://x\nHTTP notastatus\n").unwrap_err();
assert_eq!(err.line, 2);
}
}