Skip to main content

supersigil_rust_macros/
lib.rs

1//! Proc-macro crate for the `supersigil-rust` ecosystem plugin.
2//!
3//! Provides the `#[verifies(...)]` attribute macro that links Rust test
4//! functions to supersigil specification criteria. This crate is not intended
5//! to be depended on directly -- consumers should use `supersigil-rust`, which
6//! re-exports the macro.
7
8use std::cell::RefCell;
9use std::path::{Path, PathBuf};
10use std::rc::Rc;
11use std::time::SystemTime;
12
13use proc_macro::TokenStream;
14use quote::quote;
15use syn::parse::{Parse, ParseStream};
16use syn::punctuated::Punctuated;
17use syn::{Expr, ItemFn, Lit, Token};
18
19// ---------------------------------------------------------------------------
20// Compile-time graph cache
21// ---------------------------------------------------------------------------
22
23/// Cached document graph for compile-time validation. The proc macro runs
24/// in a single compiler process, so we cache the graph in a `thread_local!`
25/// to avoid re-reading config, re-expanding globs, re-parsing spec files,
26/// and re-building the graph for every `#[verifies]` invocation.
27struct CachedGraph {
28    graph: Rc<supersigil_core::DocumentGraph>,
29    input_fingerprint: Vec<InputFingerprintEntry>,
30}
31
32#[derive(Debug, Clone, PartialEq, Eq)]
33struct InputFingerprintEntry {
34    path: PathBuf,
35    modified: SystemTime,
36    len: u64,
37}
38
39thread_local! {
40    static GRAPH_CACHE: RefCell<Option<CachedGraph>> = const { RefCell::new(None) };
41}
42
43// ---------------------------------------------------------------------------
44// Attribute argument parsing
45// ---------------------------------------------------------------------------
46
47/// Parsed arguments for `#[verifies("ref1", "ref2", ...)]`.
48struct VerifiesArgs {
49    refs: Punctuated<Expr, Token![,]>,
50}
51
52impl Parse for VerifiesArgs {
53    fn parse(input: ParseStream<'_>) -> syn::Result<Self> {
54        let refs = Punctuated::parse_terminated(input)?;
55        Ok(Self { refs })
56    }
57}
58
59// ---------------------------------------------------------------------------
60// Graph validation
61// ---------------------------------------------------------------------------
62
63fn fingerprint_inputs(paths: &[PathBuf]) -> Vec<InputFingerprintEntry> {
64    paths
65        .iter()
66        .map(|path| match std::fs::metadata(path) {
67            Ok(metadata) => InputFingerprintEntry {
68                path: path.clone(),
69                modified: metadata.modified().unwrap_or(SystemTime::UNIX_EPOCH),
70                len: metadata.len(),
71            },
72            Err(_) => InputFingerprintEntry {
73                path: path.clone(),
74                modified: SystemTime::UNIX_EPOCH,
75                len: 0,
76            },
77        })
78        .collect()
79}
80
81/// Determine the project root path: either from `SUPERSIGIL_PROJECT_ROOT`
82/// env var or by walking up from `CARGO_MANIFEST_DIR`.
83///
84/// Returns:
85/// - `Ok(None)` — no project root found or explicitly disabled; skip validation.
86/// - `Ok(Some(path))` — found project root; proceed with validation.
87/// - `Err(msg)` — explicit project root is invalid; emit a compile error.
88fn resolve_project_root() -> Result<Option<PathBuf>, String> {
89    // First check explicit env var.
90    if let Ok(root) = std::env::var("SUPERSIGIL_PROJECT_ROOT") {
91        // Empty value means "explicitly disabled".
92        if root.is_empty() {
93            return Ok(None);
94        }
95        let p = PathBuf::from(&root);
96        if p.join(supersigil_core::CONFIG_FILENAME).is_file() {
97            return Ok(Some(p));
98        }
99        // Explicit root set but config not found — this is an error.
100        return Err(format!(
101            "SUPERSIGIL_PROJECT_ROOT is set to \"{root}\" but no supersigil.toml \
102             was found at that path"
103        ));
104    }
105
106    // Walk up from CARGO_MANIFEST_DIR.
107    let Ok(manifest_dir) = std::env::var("CARGO_MANIFEST_DIR") else {
108        return Ok(None);
109    };
110    Ok(supersigil_core::find_config(Path::new(&manifest_dir))
111        .ok()
112        .flatten()
113        .and_then(|p| p.parent().map(Path::to_path_buf)))
114}
115
116/// Check whether graph validation should run given the loaded config.
117fn should_validate(config: &supersigil_core::Config) -> bool {
118    should_validate_with_profile(config, &std::env::var("PROFILE").unwrap_or_default())
119}
120
121fn should_validate_with_profile(config: &supersigil_core::Config, profile: &str) -> bool {
122    use supersigil_core::RustValidationPolicy;
123
124    let policy = config
125        .ecosystem
126        .rust
127        .as_ref()
128        .map_or(RustValidationPolicy::Dev, |r| r.validation);
129
130    match policy {
131        RustValidationPolicy::Off => false,
132        RustValidationPolicy::All => true,
133        RustValidationPolicy::Dev => profile != "release",
134    }
135}
136
137/// Build (or retrieve from cache) the document graph for the given project root.
138///
139/// Returns `Ok(None)` if validation should be skipped, `Ok(Some(graph))` on
140/// success, or `Err` with an error message.
141type GraphErrors = Vec<(Option<String>, String)>;
142
143fn graph_error(context: &str, errors: &[impl std::fmt::Display]) -> GraphErrors {
144    let detail = errors
145        .iter()
146        .map(ToString::to_string)
147        .collect::<Vec<_>>()
148        .join("; ");
149    vec![(None, format!("supersigil: {context}: {detail}"))]
150}
151
152/// Quick cache check: re-stat the previously fingerprinted files without
153/// re-reading the config or re-expanding globs. Within a single compilation
154/// the file set is stable, so this avoids redundant work on every
155/// `#[verifies]` invocation after the first.
156fn check_cached_graph() -> Option<Rc<supersigil_core::DocumentGraph>> {
157    GRAPH_CACHE.with(|cache| {
158        let borrow = cache.borrow();
159        let cached = borrow.as_ref()?;
160        let still_valid = cached.input_fingerprint.iter().all(|entry| {
161            match std::fs::metadata(&entry.path) {
162                Ok(meta) => {
163                    meta.modified().unwrap_or(SystemTime::UNIX_EPOCH) == entry.modified
164                        && meta.len() == entry.len
165                }
166                // File disappeared — only matches if it was already missing when cached.
167                Err(_) => entry.modified == SystemTime::UNIX_EPOCH && entry.len == 0,
168            }
169        });
170        still_valid.then(|| Rc::clone(&cached.graph))
171    })
172}
173
174fn get_or_build_graph(
175    project_root: &Path,
176) -> Result<Option<Rc<supersigil_core::DocumentGraph>>, GraphErrors> {
177    // Fast path: validate the existing cache with only N stat calls,
178    // skipping config parsing, glob expansion, and fingerprint allocation.
179    if let Some(graph) = check_cached_graph() {
180        return Ok(Some(graph));
181    }
182
183    // Slow path: full config parse + glob expansion + graph build.
184    let config_path = project_root.join(supersigil_core::CONFIG_FILENAME);
185    let config = match supersigil_core::load_config(&config_path) {
186        Ok(c) => c,
187        Err(errs) => {
188            return Err(graph_error(
189                &format!("failed to load config at \"{}\"", config_path.display()),
190                &errs,
191            ));
192        }
193    };
194
195    if !should_validate(&config) {
196        return Ok(None);
197    }
198
199    let inputs = supersigil_core::resolve_workspace_validation_inputs(&config, project_root)
200        .map_err(|err| vec![(None, format!("supersigil: {err}"))])?;
201    let current_fingerprint = fingerprint_inputs(&inputs.all_paths());
202
203    let component_defs = supersigil_core::ComponentDefs::merge(
204        supersigil_core::ComponentDefs::defaults(),
205        config.components.clone(),
206    )
207    .map_err(|errs| graph_error("invalid component definitions", &errs))?;
208
209    let mut documents = Vec::new();
210    let mut parse_errors: Vec<String> = Vec::new();
211    for file in &inputs.spec_files {
212        match supersigil_parser::parse_file(file, &component_defs) {
213            Ok(supersigil_core::ParseResult::Document(doc)) => documents.push(doc),
214            Ok(supersigil_core::ParseResult::NotSupersigil(_)) => {}
215            Err(errs) => {
216                let detail = errs
217                    .iter()
218                    .map(ToString::to_string)
219                    .collect::<Vec<_>>()
220                    .join("; ");
221                parse_errors.push(format!("{}: {detail}", file.display()));
222            }
223        }
224    }
225    if !parse_errors.is_empty() {
226        return Err(graph_error("failed to parse spec files", &parse_errors));
227    }
228
229    let graph = match supersigil_core::build_graph(documents, &config) {
230        Ok(g) => g,
231        Err(errs) => return Err(graph_error("failed to build document graph", &errs)),
232    };
233
234    // Store in cache.
235    let graph = Rc::new(graph);
236    GRAPH_CACHE.with(|cache| {
237        *cache.borrow_mut() = Some(CachedGraph {
238            graph: Rc::clone(&graph),
239            input_fingerprint: current_fingerprint,
240        });
241    });
242
243    Ok(Some(graph))
244}
245
246/// Validate criterion refs against the document graph.
247///
248/// Returns a list of error messages. Each entry is either:
249/// - `(Some(ref_string), message)` — tied to a specific ref (use its span)
250/// - `(None, message)` — a general error (use `call_site` span)
251fn validate_refs(refs: &[String], project_root: &Path) -> Vec<(Option<String>, String)> {
252    let graph = match get_or_build_graph(project_root) {
253        Ok(Some(g)) => g,
254        Ok(None) => return Vec::new(),
255        Err(errors) => return errors,
256    };
257
258    // Check each ref.
259    let mut errors = Vec::new();
260    for ref_str in refs {
261        let Some((doc_id, fragment)) = ref_str.split_once('#') else {
262            continue;
263        };
264
265        if graph.component(doc_id, fragment).is_none() {
266            errors.push((
267                Some(ref_str.clone()),
268                format!(
269                    "unresolved criterion reference \"{ref_str}\": \
270                     no matching criterion found in the specification graph"
271                ),
272            ));
273        }
274    }
275    errors
276}
277
278fn validate_ref_shape(ref_str: &str, span: proc_macro2::Span) -> syn::Result<()> {
279    if !supersigil_core::is_valid_criterion_ref(ref_str) {
280        return Err(syn::Error::new(
281            span,
282            format!(
283                "invalid criterion reference \"{ref_str}\": expected `document-id#criterion-id`"
284            ),
285        ));
286    }
287    Ok(())
288}
289
290// ---------------------------------------------------------------------------
291// Proc-macro entry point
292// ---------------------------------------------------------------------------
293
294/// Attribute macro that marks a test function as verifying one or more
295/// specification criteria.
296///
297/// # Usage
298///
299/// ```ignore
300/// #[supersigil::verifies("req/auth#crit-1")]
301/// #[test]
302/// fn test_login_succeeds() {
303///     // ...
304/// }
305/// ```
306///
307/// The macro validates:
308/// 1. **Syntax**: at least one string-literal argument.
309/// 2. **Shape**: each ref must use the `document-id#criterion-id` form.
310/// 3. **Graph** (when enabled): each criterion ref resolves in the `DocumentGraph`.
311///
312/// The annotated item is emitted unchanged — no runtime behaviour is added.
313#[proc_macro_attribute]
314pub fn verifies(attr: TokenStream, item: TokenStream) -> TokenStream {
315    // Parse attribute arguments.
316    let args: VerifiesArgs = match syn::parse(attr) {
317        Ok(a) => a,
318        Err(e) => return e.to_compile_error().into(),
319    };
320
321    // Must have at least one argument.
322    if args.refs.is_empty() {
323        let err = syn::Error::new(
324            proc_macro2::Span::call_site(),
325            "`#[verifies(...)]` requires at least one criterion reference string",
326        );
327        return err.to_compile_error().into();
328    }
329
330    // Each argument must be a string literal.
331    let mut ref_strings: Vec<String> = Vec::new();
332    let mut ref_spans: Vec<proc_macro2::Span> = Vec::new();
333    for expr in &args.refs {
334        let Expr::Lit(syn::ExprLit {
335            lit: Lit::Str(s), ..
336        }) = expr
337        else {
338            let err = syn::Error::new_spanned(
339                expr,
340                format!(
341                    "expected a string literal criterion reference, found `{}`",
342                    quote!(#expr)
343                ),
344            );
345            return err.to_compile_error().into();
346        };
347
348        let ref_string = s.value();
349        if let Err(err) = validate_ref_shape(&ref_string, s.span()) {
350            return err.to_compile_error().into();
351        }
352        ref_strings.push(ref_string);
353        ref_spans.push(s.span());
354    }
355
356    // The annotated item must be a function.
357    let item_clone: proc_macro2::TokenStream = item.clone().into();
358    if syn::parse2::<ItemFn>(item_clone).is_err() {
359        let err = syn::Error::new(
360            proc_macro2::Span::call_site(),
361            "`#[verifies(...)]` can only be applied to functions",
362        );
363        return err.to_compile_error().into();
364    }
365
366    // Optional graph validation.
367    match resolve_project_root() {
368        Ok(Some(project_root)) => {
369            let errors = validate_refs(&ref_strings, &project_root);
370            if !errors.is_empty() {
371                let mut combined: Option<syn::Error> = None;
372                for (ref_str, message) in &errors {
373                    let span = ref_str
374                        .as_ref()
375                        .and_then(|r| ref_strings.iter().position(|s| s == r))
376                        .map_or_else(proc_macro2::Span::call_site, |idx| ref_spans[idx]);
377                    let err = syn::Error::new(span, message);
378                    match &mut combined {
379                        None => combined = Some(err),
380                        Some(existing) => existing.combine(err),
381                    }
382                }
383                if let Some(combined) = combined {
384                    return combined.to_compile_error().into();
385                }
386            }
387        }
388        Ok(None) => {
389            // No project root found — skip validation.
390        }
391        Err(msg) => {
392            let err = syn::Error::new(proc_macro2::Span::call_site(), msg);
393            return err.to_compile_error().into();
394        }
395    }
396
397    // Emit item unchanged.
398    item
399}
400
401#[cfg(test)]
402mod tests {
403    use std::fs;
404
405    use tempfile::TempDir;
406
407    use super::*;
408
409    fn clear_graph_cache() {
410        GRAPH_CACHE.with(|cache| {
411            *cache.borrow_mut() = None;
412        });
413    }
414
415    fn write_config(root: &Path) {
416        fs::write(
417            root.join("supersigil.toml"),
418            "paths = [\"specs/**/*.md\"]\n",
419        )
420        .unwrap();
421    }
422
423    fn write_spec(root: &Path, criterion_id: &str) {
424        fs::create_dir_all(root.join("specs")).unwrap();
425        fs::write(
426            root.join("specs/auth.md"),
427            format!(
428                "---\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"
429            ),
430        )
431        .unwrap();
432    }
433
434    fn config_with_policy(
435        policy: supersigil_core::RustValidationPolicy,
436    ) -> supersigil_core::Config {
437        supersigil_core::Config {
438            ecosystem: supersigil_core::EcosystemConfig {
439                rust: Some(supersigil_core::RustEcosystemConfig {
440                    validation: policy,
441                    ..Default::default()
442                }),
443                ..Default::default()
444            },
445            ..Default::default()
446        }
447    }
448
449    #[test]
450    fn should_validate_off_skips() {
451        let config = config_with_policy(supersigil_core::RustValidationPolicy::Off);
452        assert!(!should_validate(&config), "policy=off must skip validation");
453    }
454
455    #[test]
456    fn should_validate_all_always_validates() {
457        let config = config_with_policy(supersigil_core::RustValidationPolicy::All);
458        assert!(
459            should_validate(&config),
460            "policy=all must validate unconditionally"
461        );
462    }
463
464    #[test]
465    fn should_validate_dev_validates_in_debug() {
466        let config = config_with_policy(supersigil_core::RustValidationPolicy::Dev);
467        assert!(
468            should_validate_with_profile(&config, "debug"),
469            "policy=dev must validate when PROFILE=debug"
470        );
471    }
472
473    #[test]
474    fn should_validate_dev_skips_in_release() {
475        let config = config_with_policy(supersigil_core::RustValidationPolicy::Dev);
476        assert!(
477            !should_validate_with_profile(&config, "release"),
478            "policy=dev must skip validation when PROFILE=release"
479        );
480    }
481
482    #[test]
483    fn should_validate_default_is_dev() {
484        // When no rust config is provided, the default policy is Dev.
485        let config = supersigil_core::Config::default();
486        assert!(
487            should_validate_with_profile(&config, "debug"),
488            "default policy (dev) must validate in debug"
489        );
490        assert!(
491            !should_validate_with_profile(&config, "release"),
492            "default policy (dev) must skip validation in release"
493        );
494    }
495
496    #[test]
497    fn validate_refs_rebuilds_graph_when_spec_file_changes() {
498        let tmp = TempDir::new().unwrap();
499        let project_root = tmp.path();
500        write_config(project_root);
501        write_spec(project_root, "ac-1");
502        clear_graph_cache();
503
504        let refs = vec!["auth/req#ac-1".to_string()];
505        let first = validate_refs(&refs, project_root);
506        assert!(first.is_empty(), "initial ref should resolve: {first:?}");
507
508        write_spec(project_root, "criterion-two-longer-than-before");
509
510        let second = validate_refs(&refs, project_root);
511        assert!(
512            second
513                .iter()
514                .any(|(_, message)| message.contains("unresolved criterion reference")),
515            "changed spec should invalidate the cache and make the old ref fail: {second:?}",
516        );
517    }
518}