use std::cell::RefCell;
use std::path::{Path, PathBuf};
use std::rc::Rc;
use std::time::SystemTime;
use proc_macro::TokenStream;
use quote::quote;
use syn::parse::{Parse, ParseStream};
use syn::punctuated::Punctuated;
use syn::{Expr, ItemFn, Lit, Token};
struct CachedGraph {
graph: Rc<supersigil_core::DocumentGraph>,
input_fingerprint: Vec<InputFingerprintEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct InputFingerprintEntry {
path: PathBuf,
modified: SystemTime,
len: u64,
}
thread_local! {
static GRAPH_CACHE: RefCell<Option<CachedGraph>> = const { RefCell::new(None) };
}
struct VerifiesArgs {
refs: Punctuated<Expr, Token![,]>,
}
impl Parse for VerifiesArgs {
fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
let refs = Punctuated::parse_terminated(input)?;
Ok(Self { refs })
}
}
fn fingerprint_inputs(paths: &[PathBuf]) -> Vec<InputFingerprintEntry> {
paths
.iter()
.map(|path| match std::fs::metadata(path) {
Ok(metadata) => InputFingerprintEntry {
path: path.clone(),
modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
len: metadata.len(),
},
Err(_) => InputFingerprintEntry {
path: path.clone(),
modified: SystemTime::UNIX_EPOCH,
len: 0,
},
})
.collect()
}
fn resolve_project_root() -> Result<Option<PathBuf>, String> {
if let Ok(root) = std::env::var("SUPERSIGIL_PROJECT_ROOT") {
if root.is_empty() {
return Ok(None);
}
let p = PathBuf::from(&root);
if p.join(supersigil_core::CONFIG_FILENAME).is_file() {
return Ok(Some(p));
}
return Err(format!(
"SUPERSIGIL_PROJECT_ROOT is set to \"{root}\" but no supersigil.toml \
was found at that path"
));
}
let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") else {
return Ok(None);
};
Ok(supersigil_core::find_config(Path::new(&manifest_dir))
.ok()
.flatten()
.and_then(|p| p.parent().map(Path::to_path_buf)))
}
fn should_validate(config: &supersigil_core::Config) -> bool {
should_validate_with_profile(config, &std::env::var("PROFILE").unwrap_or_default())
}
fn should_validate_with_profile(config: &supersigil_core::Config, profile: &str) -> bool {
use supersigil_core::RustValidationPolicy;
let policy = config
.ecosystem
.rust
.as_ref()
.map_or(RustValidationPolicy::Dev, |r| r.validation);
match policy {
RustValidationPolicy::Off => false,
RustValidationPolicy::All => true,
RustValidationPolicy::Dev => profile != "release",
}
}
type GraphErrors = Vec<(Option<String>, String)>;
fn graph_error(context: &str, errors: &[impl std::fmt::Display]) -> GraphErrors {
let detail = errors
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
vec![(None, format!("supersigil: {context}: {detail}"))]
}
fn check_cached_graph() -> Option<Rc<supersigil_core::DocumentGraph>> {
GRAPH_CACHE.with(|cache| {
let borrow = cache.borrow();
let cached = borrow.as_ref()?;
let still_valid = cached.input_fingerprint.iter().all(|entry| {
match std::fs::metadata(&entry.path) {
Ok(meta) => {
meta.modified().unwrap_or(SystemTime::UNIX_EPOCH) == entry.modified
&& meta.len() == entry.len
}
Err(_) => entry.modified == SystemTime::UNIX_EPOCH && entry.len == 0,
}
});
still_valid.then(|| Rc::clone(&cached.graph))
})
}
fn get_or_build_graph(
project_root: &Path,
) -> Result<Option<Rc<supersigil_core::DocumentGraph>>, GraphErrors> {
if let Some(graph) = check_cached_graph() {
return Ok(Some(graph));
}
let config_path = project_root.join(supersigil_core::CONFIG_FILENAME);
let config = match supersigil_core::load_config(&config_path) {
Ok(c) => c,
Err(errs) => {
return Err(graph_error(
&format!("failed to load config at \"{}\"", config_path.display()),
&errs,
));
}
};
if !should_validate(&config) {
return Ok(None);
}
let inputs = supersigil_core::resolve_workspace_validation_inputs(&config, project_root)
.map_err(|err| vec![(None, format!("supersigil: {err}"))])?;
let current_fingerprint = fingerprint_inputs(&inputs.all_paths());
let component_defs = supersigil_core::ComponentDefs::merge(
supersigil_core::ComponentDefs::defaults(),
config.components.clone(),
)
.map_err(|errs| graph_error("invalid component definitions", &errs))?;
let mut documents = Vec::new();
let mut parse_errors: Vec<String> = Vec::new();
for file in &inputs.spec_files {
match supersigil_parser::parse_file(file, &component_defs) {
Ok(supersigil_core::ParseResult::Document(doc)) => documents.push(doc),
Ok(supersigil_core::ParseResult::NotSupersigil(_)) => {}
Err(errs) => {
let detail = errs
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join("; ");
parse_errors.push(format!("{}: {detail}", file.display()));
}
}
}
if !parse_errors.is_empty() {
return Err(graph_error("failed to parse spec files", &parse_errors));
}
let graph = match supersigil_core::build_graph(documents, &config) {
Ok(g) => g,
Err(errs) => return Err(graph_error("failed to build document graph", &errs)),
};
let graph = Rc::new(graph);
GRAPH_CACHE.with(|cache| {
*cache.borrow_mut() = Some(CachedGraph {
graph: Rc::clone(&graph),
input_fingerprint: current_fingerprint,
});
});
Ok(Some(graph))
}
fn validate_refs(refs: &[String], project_root: &Path) -> Vec<(Option<String>, String)> {
let graph = match get_or_build_graph(project_root) {
Ok(Some(g)) => g,
Ok(None) => return Vec::new(),
Err(errors) => return errors,
};
let mut errors = Vec::new();
for ref_str in refs {
let Some((doc_id, fragment)) = ref_str.split_once('#') else {
continue;
};
if graph.component(doc_id, fragment).is_none() {
errors.push((
Some(ref_str.clone()),
format!(
"unresolved criterion reference \"{ref_str}\": \
no matching criterion found in the specification graph"
),
));
}
}
errors
}
fn validate_ref_shape(ref_str: &str, span: proc_macro2::Span) -> syn::Result<()> {
if !supersigil_core::is_valid_criterion_ref(ref_str) {
return Err(syn::Error::new(
span,
format!(
"invalid criterion reference \"{ref_str}\": expected `document-id#criterion-id`"
),
));
}
Ok(())
}
#[proc_macro_attribute]
pub fn verifies(attr: TokenStream, item: TokenStream) -> TokenStream {
let args: VerifiesArgs = match syn::parse(attr) {
Ok(a) => a,
Err(e) => return e.to_compile_error().into(),
};
if args.refs.is_empty() {
let err = syn::Error::new(
proc_macro2::Span::call_site(),
"`#[verifies(...)]` requires at least one criterion reference string",
);
return err.to_compile_error().into();
}
let mut ref_strings: Vec<String> = Vec::new();
let mut ref_spans: Vec<proc_macro2::Span> = Vec::new();
for expr in &args.refs {
let Expr::Lit(syn::ExprLit {
lit: Lit::Str(s), ..
}) = expr
else {
let err = syn::Error::new_spanned(
expr,
format!(
"expected a string literal criterion reference, found `{}`",
quote!(#expr)
),
);
return err.to_compile_error().into();
};
let ref_string = s.value();
if let Err(err) = validate_ref_shape(&ref_string, s.span()) {
return err.to_compile_error().into();
}
ref_strings.push(ref_string);
ref_spans.push(s.span());
}
let item_clone: proc_macro2::TokenStream = item.clone().into();
if syn::parse2::<ItemFn>(item_clone).is_err() {
let err = syn::Error::new(
proc_macro2::Span::call_site(),
"`#[verifies(...)]` can only be applied to functions",
);
return err.to_compile_error().into();
}
match resolve_project_root() {
Ok(Some(project_root)) => {
let errors = validate_refs(&ref_strings, &project_root);
if !errors.is_empty() {
let mut combined: Option<syn::Error> = None;
for (ref_str, message) in &errors {
let span = ref_str
.as_ref()
.and_then(|r| ref_strings.iter().position(|s| s == r))
.map_or_else(proc_macro2::Span::call_site, |idx| ref_spans[idx]);
let err = syn::Error::new(span, message);
match &mut combined {
None => combined = Some(err),
Some(existing) => existing.combine(err),
}
}
if let Some(combined) = combined {
return combined.to_compile_error().into();
}
}
}
Ok(None) => {
}
Err(msg) => {
let err = syn::Error::new(proc_macro2::Span::call_site(), msg);
return err.to_compile_error().into();
}
}
item
}
#[cfg(test)]
mod tests {
use std::fs;
use tempfile::TempDir;
use super::*;
fn clear_graph_cache() {
GRAPH_CACHE.with(|cache| {
*cache.borrow_mut() = None;
});
}
fn write_config(root: &Path) {
fs::write(
root.join("supersigil.toml"),
"paths = [\"specs/**/*.md\"]\n",
)
.unwrap();
}
fn write_spec(root: &Path, criterion_id: &str) {
fs::create_dir_all(root.join("specs")).unwrap();
fs::write(
root.join("specs/auth.md"),
format!(
"---\nsupersigil:\n id: auth/req\n type: requirements\n status: approved\n---\n\n```supersigil-xml\n<AcceptanceCriteria>\n <Criterion id=\"{criterion_id}\">\n Must log in.\n </Criterion>\n</AcceptanceCriteria>\n```\n"
),
)
.unwrap();
}
fn config_with_policy(
policy: supersigil_core::RustValidationPolicy,
) -> supersigil_core::Config {
supersigil_core::Config {
ecosystem: supersigil_core::EcosystemConfig {
rust: Some(supersigil_core::RustEcosystemConfig {
validation: policy,
..Default::default()
}),
..Default::default()
},
..Default::default()
}
}
#[test]
fn should_validate_off_skips() {
let config = config_with_policy(supersigil_core::RustValidationPolicy::Off);
assert!(!should_validate(&config), "policy=off must skip validation");
}
#[test]
fn should_validate_all_always_validates() {
let config = config_with_policy(supersigil_core::RustValidationPolicy::All);
assert!(
should_validate(&config),
"policy=all must validate unconditionally"
);
}
#[test]
fn should_validate_dev_validates_in_debug() {
let config = config_with_policy(supersigil_core::RustValidationPolicy::Dev);
assert!(
should_validate_with_profile(&config, "debug"),
"policy=dev must validate when PROFILE=debug"
);
}
#[test]
fn should_validate_dev_skips_in_release() {
let config = config_with_policy(supersigil_core::RustValidationPolicy::Dev);
assert!(
!should_validate_with_profile(&config, "release"),
"policy=dev must skip validation when PROFILE=release"
);
}
#[test]
fn should_validate_default_is_dev() {
let config = supersigil_core::Config::default();
assert!(
should_validate_with_profile(&config, "debug"),
"default policy (dev) must validate in debug"
);
assert!(
!should_validate_with_profile(&config, "release"),
"default policy (dev) must skip validation in release"
);
}
#[test]
fn validate_refs_rebuilds_graph_when_spec_file_changes() {
let tmp = TempDir::new().unwrap();
let project_root = tmp.path();
write_config(project_root);
write_spec(project_root, "ac-1");
clear_graph_cache();
let refs = vec!["auth/req#ac-1".to_string()];
let first = validate_refs(&refs, project_root);
assert!(first.is_empty(), "initial ref should resolve: {first:?}");
write_spec(project_root, "criterion-two-longer-than-before");
let second = validate_refs(&refs, project_root);
assert!(
second
.iter()
.any(|(_, message)| message.contains("unresolved criterion reference")),
"changed spec should invalidate the cache and make the old ref fail: {second:?}",
);
}
}