vespera_macro 0.4.0

Procedural macros for Vespera - automatic route discovery and OpenAPI generation
Documentation
//! Phase-4 path-string resolution caches, split out of `file_cache.rs` to
//! keep that module within the project's source-size budget.
//!
//! These caches key on schema PATH strings (not file paths) and resolve
//! through the lower file-content / struct-definition mtime caches in the
//! parent [`super`] module. They form a conceptually distinct layer (path
//! resolution: struct / FK / module-path / circular lookups) from the raw
//! file/dir/content caching that remains in `file_cache.rs`, and operate on a
//! disjoint set of [`super::FileCache`] fields (`circular_analysis`,
//! `struct_lookup`, `fk_column_lookup`, `module_path_cache`). They share the
//! parent's `FILE_CACHE` thread-local plus the `ensure_file_list` /
//! `get_fingerprint_cached` helpers via `super::`.
//!
//! Pure code move out of `file_cache.rs` — no logic or behaviour change.

use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::Path;
use std::sync::Arc;

use crate::metadata::StructMetadata;
use crate::schema_macro::circular::{CircularAnalysis, analyze_circular_refs};
use crate::schema_macro::file_lookup::{
    find_fk_column_from_target_entity, find_struct_from_schema_path,
};

use super::{FILE_CACHE, FileCache, PathLookupEntry, ensure_file_list, get_fingerprint_cached};

/// Outcome of probing a path-keyed lookup cache.
///
/// `Hit` short-circuits with the cached value. `Miss` carries the
/// `path_lookup_fingerprint` already computed during the probe, so the insert
/// path can reuse it instead of recomputing the (potentially `src`-tree-walking)
/// fingerprint a second time — the redundant double-fingerprint the previous
/// read-then-insert pair paid on every cache miss.
enum Probe<T> {
    Hit(T),
    Miss(u64),
}

/// Get or compute circular reference analysis, with caching.
///
/// The cache key is `(source_module_path_joined, definition)` since the same
/// model definition analyzed from the same module context always produces
/// the same result.
pub fn get_circular_analysis(source_module_path: &[String], definition: &str) -> CircularAnalysis {
    let key = (source_module_path.join("::"), definition.to_string());

    // The borrow must end before analyzing: analysis re-enters FILE_CACHE.
    let cached = FILE_CACHE.with(|cache| cache.borrow().circular_analysis.get(&key).cloned());
    if let Some(result) = cached {
        FILE_CACHE.with(|cache| cache.borrow_mut().circular_cache_hits += 1);
        return result;
    }

    let result = analyze_circular_refs(source_module_path, definition);

    FILE_CACHE.with(|cache| {
        cache
            .borrow_mut()
            .circular_analysis
            .insert(key, result.clone());
    });

    result
}

/// Dependency fingerprint for a schema PATH string, used to validate the
/// path-keyed lookup caches (`struct_lookup`, `fk_column_lookup`) across
/// epochs.
///
/// The digest folds in the path string, the crate's `src` dir, and the
/// mtime+len [`super::FileFingerprint`] of every file the resolution could
/// read: the two `{module}.rs` / `{module}/mod.rs` candidates for a qualified
/// path, or every `.rs` file under `src` for a single-segment one. Comparing
/// it against the stored `PathLookupEntry::fingerprint` is what makes a
/// surviving entry safe — an entry whose inputs changed on disk fails the
/// comparison and is re-resolved.
///
/// Together with `PathLookupEntry::last_epoch_validated` (which only skips
/// recomputing this fingerprint for an entry already validated in the
/// current epoch) this is the sole freshness mechanism for those caches: they
/// stay warm across epoch bumps so repeated `schema_type!` /
/// `#[derive(Schema)]` expansions in one crate build share path-resolution
/// work, and stay correct because a disk change perturbs the fingerprint.
fn path_lookup_fingerprint(cache: &mut FileCache, path_str: &str) -> u64 {
    let mut hasher = DefaultHasher::new();
    path_str.hash(&mut hasher);

    let Some(manifest_dir) = super::get_manifest_dir_inner(cache) else {
        return hasher.finish();
    };
    let src_dir = Path::new(&manifest_dir).join("src");
    src_dir.hash(&mut hasher);

    let segments: Vec<&str> = path_str
        .split("::")
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .filter(|s| *s != "crate" && *s != "self" && *s != "super")
        .collect();

    if segments.len() <= 1 {
        let files = ensure_file_list(cache, &src_dir);
        for path in files.iter() {
            fingerprint_path(cache, path, &mut hasher);
        }
        return hasher.finish();
    }

    let module_segments = &segments[..segments.len() - 1];
    let joined = module_segments.join("/");
    let candidates = [
        src_dir.join(format!("{joined}.rs")),
        src_dir.join(format!("{joined}/mod.rs")),
    ];
    for path in &candidates {
        fingerprint_path(cache, path, &mut hasher);
    }

    hasher.finish()
}

fn fingerprint_path(cache: &mut FileCache, path: &Path, hasher: &mut DefaultHasher) {
    path.hash(hasher);
    match get_fingerprint_cached(cache, path) {
        Some(fp) => {
            "fp:some".hash(hasher);
            if let Ok(duration) = fp.mtime.duration_since(std::time::UNIX_EPOCH) {
                duration.as_secs().hash(hasher);
                duration.subsec_nanos().hash(hasher);
            }
            // Length folds in alongside mtime so a size-changing,
            // timestamp-preserving edit re-resolves the path lookup.
            fp.len.hash(hasher);
        }
        None => "fp:none".hash(hasher),
    }
}

/// Get or compute struct lookup by schema path, with caching.
///
/// Wraps `find_struct_from_schema_path` with a
/// `HashMap<String, Option<Arc<StructMetadata>>>` cache. `None` values
/// are cached too (negative cache) to avoid repeated failed lookups.
/// The `Arc` makes cache hits O(1) instead of cloning the full struct
/// definition text per lookup.
///
/// The cache **survives epoch bumps**; freshness comes from
/// `PathLookupEntry::last_epoch_validated` plus the recomputed
/// [`path_lookup_fingerprint`]. An entry already validated in the current
/// epoch is trusted as-is; otherwise the fingerprint is recomputed and the
/// entry is only reused when it still matches, so a source edit that changes
/// any file the resolution reads forces a re-resolution — including in a
/// long-lived rust-analyzer proc-macro server.
pub fn get_struct_from_schema_path(path_str: &str) -> Option<Arc<StructMetadata>> {
    // Probe the cache; the borrow ends before the lookup below, which
    // re-enters FILE_CACHE.
    let probe = FILE_CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        let epoch = cache.epoch;
        // Epoch-hit fast path: an entry already validated THIS epoch is fresh
        // WITHOUT recomputing the fingerprint — which, for a single-segment
        // path, walks the entire `src` tree. The previous code computed the
        // fingerprint unconditionally even when the epoch stamp already proved
        // freshness.
        if let Some(entry) = cache.struct_lookup.get(path_str)
            && entry.last_epoch_validated == epoch
        {
            return Probe::Hit(entry.value.clone());
        }
        // Cross-epoch / absent: compute the fingerprint ONCE and reuse it for
        // the insert below on a miss.
        let fingerprint = path_lookup_fingerprint(&mut cache, path_str);
        if let Some(entry) = cache.struct_lookup.get_mut(path_str)
            && entry.fingerprint == fingerprint
        {
            // Re-stamp so further lookups this epoch take the fast path.
            entry.last_epoch_validated = epoch;
            return Probe::Hit(entry.value.clone());
        }
        Probe::Miss(fingerprint)
    });
    let fingerprint = match probe {
        Probe::Hit(value) => {
            FILE_CACHE.with(|cache| cache.borrow_mut().struct_lookup_cache_hits += 1);
            return value;
        }
        Probe::Miss(fingerprint) => fingerprint,
    };

    let result = find_struct_from_schema_path(path_str).map(Arc::new);

    FILE_CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        let epoch = cache.epoch;
        cache.struct_lookup.insert(
            path_str.to_string(),
            PathLookupEntry {
                value: result.clone(),
                fingerprint,
                last_epoch_validated: epoch,
            },
        );
    });

    result
}

/// Get or compute FK column lookup, with caching.
///
/// Wraps `find_fk_column_from_target_entity` with a `HashMap<(String, String), Option<String>>`
/// cache. Negative results (`None`) are cached to avoid repeated file lookups.
pub fn get_fk_column(schema_path: &str, via_rel: &str) -> Option<String> {
    let key = (schema_path.to_string(), via_rel.to_string());

    // Probe the cache (entries deliberately SURVIVE epoch bumps — freshness
    // comes from `last_epoch_validated` + `path_lookup_fingerprint`). The
    // borrow ends before the lookup below, which re-enters FILE_CACHE.
    let probe = FILE_CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        let epoch = cache.epoch;
        // Epoch-hit fast path: skip the (possibly `src`-tree-walking)
        // fingerprint when the entry was already validated this epoch.
        if let Some(entry) = cache.fk_column_lookup.get(&key)
            && entry.last_epoch_validated == epoch
        {
            return Probe::Hit(entry.value.clone());
        }
        // Cross-epoch / absent: compute the fingerprint ONCE, reuse on miss.
        let fingerprint = path_lookup_fingerprint(&mut cache, schema_path);
        if let Some(entry) = cache.fk_column_lookup.get_mut(&key)
            && entry.fingerprint == fingerprint
        {
            entry.last_epoch_validated = epoch;
            return Probe::Hit(entry.value.clone());
        }
        Probe::Miss(fingerprint)
    });
    let fingerprint = match probe {
        Probe::Hit(value) => {
            FILE_CACHE.with(|cache| cache.borrow_mut().fk_column_cache_hits += 1);
            return value;
        }
        Probe::Miss(fingerprint) => fingerprint,
    };

    let result = find_fk_column_from_target_entity(schema_path, via_rel);

    FILE_CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        let epoch = cache.epoch;
        cache.fk_column_lookup.insert(
            key,
            PathLookupEntry {
                value: result.clone(),
                fingerprint,
                last_epoch_validated: epoch,
            },
        );
    });

    result
}

/// Get or compute module path from schema path, with caching.
///
/// Wraps `extract_module_path_from_schema_path` logic with a `HashMap<String, Vec<String>>`
/// cache. The `schema_path` TokenStream is stringified once for both cache key and computation,
/// avoiding the double `.to_string()` that would occur when calling the uncached function.
pub fn get_module_path_from_schema_path(schema_path: &proc_macro2::TokenStream) -> Vec<String> {
    let path_str = schema_path.to_string();

    let cached = FILE_CACHE.with(|cache| cache.borrow().module_path_cache.get(&path_str).cloned());
    if let Some(result) = cached {
        FILE_CACHE.with(|cache| cache.borrow_mut().module_path_cache_hits += 1);
        return result;
    }

    let mut result: Vec<String> = path_str
        .split("::")
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .map(ToString::to_string)
        .collect();
    result.pop();

    FILE_CACHE.with(|cache| {
        cache
            .borrow_mut()
            .module_path_cache
            .insert(path_str, result.clone());
    });

    result
}

#[cfg(test)]
mod tests;