vespera_macro 0.1.43

Procedural macros for Vespera - automatic route discovery and OpenAPI generation
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
//! Thread-local cache for file lookups to avoid redundant I/O and parsing.
//!
//! Within a single compilation, multiple `schema_type!` invocations may search
//! for structs in the same files. This module caches:
//! - The list of `.rs` files per source directory
//! - File contents with mtime-based invalidation
//! - Struct name → candidate file paths (cheap text-based pre-filter)
//!
//! Uses `thread_local!` because `syn::File` (and proc-macro types within it)
//! are not `Send`/`Sync`, and proc-macros run single-threaded anyway.
//! The mtime check handles rust-analyzer's proc-macro server, which may persist
//! across file edits.

use std::cell::RefCell;
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::time::SystemTime;

use super::circular::CircularAnalysis;
use super::file_lookup::collect_rs_files_recursive;
use crate::metadata::StructMetadata;

/// Internal cache state.
struct FileCache {
    /// Cached `.rs` file lists per source directory.
    file_lists: HashMap<PathBuf, Vec<PathBuf>>,

    /// Cached file contents: file path → (mtime, content string).
    /// Mtime is checked to invalidate stale entries in long-lived processes.
    file_contents: HashMap<PathBuf, (SystemTime, String)>,

    /// Struct name candidate index: (src_dir, struct_name) → files containing that name.
    /// Built from cheap `String::contains` search, not full parsing.
    struct_candidates: HashMap<(PathBuf, String), Vec<PathBuf>>,

    // NOTE: We CANNOT cache `syn::File` or `syn::ItemStruct` across proc-macro
    // invocations. Both `syn` and `proc_macro2` types contain `proc_macro::Span`
    // and `proc_macro::TokenStream` bridge handles allocated in the current
    // invocation's bridge context. Cloning them in a later invocation panics with
    // "use-after-free in `proc_macro` handle".
    //
    // Instead, `struct_definitions` caches extracted definition *strings* which have
    // no bridge handles and are safe to reuse. For callers needing `syn::File`,
    // `get_parsed_file()` caches the file *content* (safe string) and re-parses
    // per invocation, avoiding redundant disk I/O while staying safe.

    // --- Profiling counters (zero-cost when VESPERA_PROFILE is not set) ---
    /// Number of file content reads from disk (cache miss).
    file_disk_reads: usize,
    /// Number of file content cache hits.
    content_cache_hits: usize,
    /// Number of struct definitions parsed via syn::parse_str.
    struct_parses: usize,
    /// Number of full-file AST parses via syn::parse_file.
    ast_parses: usize,

    // --- Phase 4 caches ---
    /// Cached circular reference analysis results: (module_path, definition) → analysis.
    circular_analysis: HashMap<(String, String), CircularAnalysis>,
    /// Cached struct lookups by schema path: path_str → Option<StructMetadata>.
    /// `None` values are cached (negative cache) to avoid repeated failed lookups.
    struct_lookup: HashMap<String, Option<StructMetadata>>,
    /// Cached FK column lookups: (schema_path, via_rel) → Option<column_name>.
    fk_column_lookup: HashMap<(String, String), Option<String>>,
    /// Cached module path extraction from schema paths: path_str → Vec<module segments>.
    module_path_cache: HashMap<String, Vec<String>>,
    /// Cached struct definitions from files: file_path → (mtime, struct_name → definition_string).
    /// Unlike `syn::File`, strings have no `proc_macro::Span` handles, safe to cache.
    struct_definitions: HashMap<PathBuf, (SystemTime, HashMap<String, String>)>,
    /// Cached CARGO_MANIFEST_DIR value to avoid repeated syscalls.
    /// Within a single compilation, this never changes.
    manifest_dir: Option<String>,

    // --- Phase 4 profiling counters ---
    circular_cache_hits: usize,
    struct_lookup_cache_hits: usize,
    fk_column_cache_hits: usize,
    module_path_cache_hits: usize,
    struct_def_cache_hits: usize,
}

thread_local! {
    static FILE_CACHE: RefCell<FileCache> = RefCell::new(FileCache {
        file_lists: HashMap::with_capacity(4),
        file_contents: HashMap::with_capacity(32),
        struct_candidates: HashMap::with_capacity(32),
        file_disk_reads: 0,
        content_cache_hits: 0,
        struct_parses: 0,
        ast_parses: 0,
        circular_analysis: HashMap::with_capacity(16),
        struct_lookup: HashMap::with_capacity(32),
        fk_column_lookup: HashMap::with_capacity(16),
        module_path_cache: HashMap::with_capacity(32),
        manifest_dir: None,
        circular_cache_hits: 0,
        struct_lookup_cache_hits: 0,
        fk_column_cache_hits: 0,
        module_path_cache_hits: 0,
        struct_definitions: HashMap::with_capacity(32),
        struct_def_cache_hits: 0,
    });
}

/// Get `CARGO_MANIFEST_DIR` from cache, or read from env and cache.
///
/// Within a single compilation, this value never changes. Caching avoids
/// repeated syscalls (previously 20+ calls per `schema_type!` expansion).
pub fn get_manifest_dir() -> Option<String> {
    FILE_CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        if let Some(ref dir) = cache.manifest_dir {
            return Some(dir.clone());
        }
        let dir = std::env::var("CARGO_MANIFEST_DIR").ok();
        cache.manifest_dir.clone_from(&dir);
        dir
    })
}

/// Get a parsed `syn::File` for the given path.
///
/// Uses the file content cache to avoid redundant disk I/O, then parses with
/// `syn::parse_file` each time. We CANNOT cache `syn::File` across proc-macro
/// invocations because `proc_macro2`/`syn` types contain `proc_macro::TokenStream`
/// bridge handles that become invalid when the invocation that created them ends.
pub fn get_parsed_file(path: &Path) -> Option<syn::File> {
    FILE_CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        parse_file_cached(&mut cache, path)
    })
}

/// **Single call site for `syn::parse_file`.**
///
/// Reads file content from the mtime-validated content cache (avoids redundant
/// disk I/O), then calls `syn::parse_file`. The resulting `syn::File` is NOT
/// cached — it must be used and dropped within the current proc-macro invocation.
fn parse_file_cached(cache: &mut FileCache, path: &Path) -> Option<syn::File> {
    let content = get_file_content_inner(cache, path)?;
    cache.ast_parses += 1;
    syn::parse_file(&content).ok()
}

/// Get candidate files that likely contain `struct_name`, using cache when available.
///
/// Performs a cheap text-based search (`String::contains`) on file contents.
/// False positives are acceptable (struct name in comments/strings), but false
/// negatives are not. Results are cached per `(src_dir, struct_name)` pair.
pub fn get_struct_candidates(src_dir: &Path, struct_name: &str) -> Vec<PathBuf> {
    FILE_CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        let key = (src_dir.to_path_buf(), struct_name.to_string());

        if let Some(candidates) = cache.struct_candidates.get(&key) {
            return candidates.clone();
        }

        // Ensure file list is cached
        let files = if let Some(files) = cache.file_lists.get(src_dir) {
            files.clone()
        } else {
            let mut files = Vec::new();
            collect_rs_files_recursive(src_dir, &mut files);
            cache
                .file_lists
                .insert(src_dir.to_path_buf(), files.clone());
            files
        };

        // Filter using cheap text search, caching file contents along the way
        let candidates: Vec<PathBuf> = files
            .into_iter()
            .filter(|path| {
                let content = get_file_content_inner(&mut cache, path);
                content.is_some_and(|c| c.contains(struct_name))
            })
            .collect();

        cache.struct_candidates.insert(key, candidates.clone());
        candidates
    })
}
/// Ensure struct definitions are extracted and cached for the given file.
/// On first call, parses the file and caches all struct definitions as strings.
/// On subsequent calls, checks mtime to validate cache.
fn ensure_struct_definitions(cache: &mut FileCache, path: &Path) -> bool {
    let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok());

    if let Some(mtime) = current_mtime
        && let Some((cached_mtime, _)) = cache.struct_definitions.get(path)
        && *cached_mtime == mtime
    {
        cache.struct_def_cache_hits += 1;
        return true;
    }

    // Cache miss — parse file and extract all struct definitions.
    // Uses parse_file_cached: single syn::parse_file entry point.
    let Some(file_ast) = parse_file_cached(cache, path) else {
        return false;
    };

    let mut defs = HashMap::new();
    for item in &file_ast.items {
        if let syn::Item::Struct(struct_item) = item {
            let name = struct_item.ident.to_string();
            let def = quote::quote!(#struct_item).to_string();
            defs.insert(name, def);
        }
    }

    if let Some(mtime) = current_mtime {
        cache
            .struct_definitions
            .insert(path.to_path_buf(), (mtime, defs));
    }

    true
}

/// Get a struct definition string by name from a file, using cached extraction.
///
/// On first call for a file, parses via `syn::parse_file` and caches ALL struct
/// definitions as strings. Subsequent calls for the same file return from cache
/// without re-parsing.
///
/// The cached data contains no `proc_macro::Span` handles,
/// so it's safe to reuse across macro invocations.
pub fn get_struct_definition(path: &Path, struct_name: &str) -> Option<String> {
    FILE_CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        if !ensure_struct_definitions(&mut cache, path) {
            return None;
        }
        cache
            .struct_definitions
            .get(path)?
            .1
            .get(struct_name)
            .cloned()
    })
}

/// Internal helper: get file content from cache or read from disk.
/// Checks mtime for invalidation.
fn get_file_content_inner(cache: &mut FileCache, path: &Path) -> Option<String> {
    let current_mtime = std::fs::metadata(path).ok().and_then(|m| m.modified().ok());

    if let Some(mtime) = current_mtime
        && let Some((cached_mtime, content)) = cache.file_contents.get(path)
        && *cached_mtime == mtime
    {
        cache.content_cache_hits += 1;
        return Some(content.clone());
    }

    // Cache miss or stale — read and cache
    let content = std::fs::read_to_string(path).ok()?;
    cache.file_disk_reads += 1;

    if let Some(mtime) = current_mtime {
        cache
            .file_contents
            .insert(path.to_path_buf(), (mtime, content.clone()));
    }

    Some(content)
}

/// Parse a struct definition string via `syn::parse_str`.
///
/// NOTE: Results are NOT cached across calls. `syn::ItemStruct` contains
/// `proc_macro::Span` handles that are tied to a specific macro invocation
/// context — caching them causes "use-after-free" panics in the proc_macro bridge.
/// File I/O caching (via `get_struct_definition`) is the primary performance win;
/// definition string parsing is fast (microseconds per struct).
pub fn parse_struct_cached(definition: &str) -> Result<syn::ItemStruct, syn::Error> {
    FILE_CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        cache.struct_parses += 1;
        syn::parse_str(definition)
    })
}

/// 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());

    // 1. Check cache — borrow dropped at end of closure
    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;
    }

    // 2. Compute — this re-enters FILE_CACHE via parse_struct_cached (safe: our borrow is dropped)
    let result = super::circular::analyze_circular_refs(source_module_path, definition);

    // 3. Store — new borrow
    FILE_CACHE.with(|cache| {
        cache
            .borrow_mut()
            .circular_analysis
            .insert(key, result.clone());
    });

    result
}

/// Get or compute struct lookup by schema path, with caching.
///
/// Wraps `find_struct_from_schema_path` with a `HashMap<String, Option<StructMetadata>>`
/// cache. `None` values are cached too (negative cache) to avoid repeated failed lookups.
pub fn get_struct_from_schema_path(path_str: &str) -> Option<StructMetadata> {
    // 1. Check cache — borrow dropped at end of closure
    let cached = FILE_CACHE.with(|cache| cache.borrow().struct_lookup.get(path_str).cloned());
    if let Some(result) = cached {
        FILE_CACHE.with(|cache| cache.borrow_mut().struct_lookup_cache_hits += 1);
        return result;
    }

    // 2. Compute — this re-enters FILE_CACHE via get_struct_definition (safe: our borrow is dropped)
    let result = super::file_lookup::find_struct_from_schema_path(path_str);

    // 3. Store — new borrow
    FILE_CACHE.with(|cache| {
        cache
            .borrow_mut()
            .struct_lookup
            .insert(path_str.to_string(), result.clone());
    });

    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());

    // 1. Check cache — borrow dropped at end of closure
    let cached = FILE_CACHE.with(|cache| cache.borrow().fk_column_lookup.get(&key).cloned());
    if let Some(result) = cached {
        FILE_CACHE.with(|cache| cache.borrow_mut().fk_column_cache_hits += 1);
        return result;
    }

    // 2. Compute — this re-enters FILE_CACHE via get_struct_definition (safe: our borrow is dropped)
    let result = super::file_lookup::find_fk_column_from_target_entity(schema_path, via_rel);

    // 3. Store — new borrow
    FILE_CACHE.with(|cache| {
        cache
            .borrow_mut()
            .fk_column_lookup
            .insert(key, result.clone());
    });

    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();

    // 1. Check cache — borrow dropped at end of closure
    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;
    }

    // 2. Compute from the string directly (avoids double to_string())
    let segments: Vec<&str> = path_str
        .split("::")
        .map(str::trim)
        .filter(|s| !s.is_empty())
        .collect();

    let result = if segments.len() > 1 {
        segments[..segments.len() - 1]
            .iter()
            .map(ToString::to_string)
            .collect()
    } else {
        vec![]
    };

    // 3. Store — new borrow
    FILE_CACHE.with(|cache| {
        cache
            .borrow_mut()
            .module_path_cache
            .insert(path_str, result.clone());
    });

    result
}

/// Print profiling summary to stderr if `VESPERA_PROFILE` env var is set.
///
/// Call this at the end of macro execution to output cache statistics.
/// Silent by default — only outputs when `VESPERA_PROFILE=1`.
pub fn print_profile_summary() {
    if std::env::var("VESPERA_PROFILE").is_err() {
        return;
    }

    FILE_CACHE.with(|cache| {
        let cache = cache.borrow();
        eprintln!("[vespera-profile] File cache stats:");
        eprintln!(
            "  file I/O: {} disk reads, {} cache hits",
            cache.file_disk_reads, cache.content_cache_hits
        );
        eprintln!("  struct parses: {}", cache.struct_parses);
        eprintln!("  AST parses: {}", cache.ast_parses);
        eprintln!(
            "  cache entries: {} file lists, {} file contents, {} struct candidates",
            cache.file_lists.len(),
            cache.file_contents.len(),
            cache.struct_candidates.len()
        );
        eprintln!(
            "  circular analysis: {} cache hits, {} entries",
            cache.circular_cache_hits,
            cache.circular_analysis.len()
        );
        eprintln!(
            "  struct lookup: {} cache hits, {} entries",
            cache.struct_lookup_cache_hits,
            cache.struct_lookup.len()
        );
        eprintln!(
            "  FK column lookup: {} cache hits, {} entries",
            cache.fk_column_cache_hits,
            cache.fk_column_lookup.len()
        );
        eprintln!(
            "  struct definitions: {} cache hits, {} entries",
            cache.struct_def_cache_hits,
            cache.struct_definitions.len()
        );
        eprintln!(
            "  module path: {} cache hits, {} entries",
            cache.module_path_cache_hits,
            cache.module_path_cache.len()
        );
    });
}

/// Inject a fake struct definition into the cache for testing.
/// Uses the file's real mtime so `ensure_struct_definitions` won't invalidate the cache.
/// Enables tests to simulate scenarios where `get_struct_definition` succeeds
/// but `parse_struct_cached` fails (defensive code path).
#[cfg(test)]
pub fn inject_struct_definition_for_test(path: &std::path::Path, name: &str, definition: &str) {
    FILE_CACHE.with(|cache| {
        let mut cache = cache.borrow_mut();
        let mtime = std::fs::metadata(path)
            .ok()
            .and_then(|m| m.modified().ok())
            .unwrap_or(std::time::SystemTime::UNIX_EPOCH);
        let entry = cache
            .struct_definitions
            .entry(path.to_path_buf())
            .or_insert_with(|| (mtime, HashMap::new()));
        entry.0 = mtime;
        entry.1.insert(name.to_string(), definition.to_string());
    });
}

#[cfg(test)]
mod tests {

    use tempfile::TempDir;

    use super::*;

    #[test]
    fn test_get_struct_candidates_filters_correctly() {
        let temp_dir = TempDir::new().unwrap();
        let src_dir = temp_dir.path();

        std::fs::write(
            src_dir.join("has_model.rs"),
            "pub struct Model { pub id: i32 }",
        )
        .unwrap();
        std::fs::write(
            src_dir.join("no_model.rs"),
            "pub struct Other { pub x: i32 }",
        )
        .unwrap();

        let candidates = get_struct_candidates(src_dir, "Model");
        assert_eq!(candidates.len(), 1);
        assert!(candidates[0].ends_with("has_model.rs"));
    }

    #[test]
    fn test_get_struct_candidates_caches_result() {
        let temp_dir = TempDir::new().unwrap();
        let src_dir = temp_dir.path();

        std::fs::write(src_dir.join("file.rs"), "pub struct Target { pub id: i32 }").unwrap();

        let c1 = get_struct_candidates(src_dir, "Target");
        let c2 = get_struct_candidates(src_dir, "Target");
        assert_eq!(c1, c2, "Cached candidates should be identical");
    }

    #[test]
    fn test_get_struct_candidates_file_list_cache_hit() {
        let temp_dir = TempDir::new().unwrap();
        let src_dir = temp_dir.path();

        std::fs::write(
            src_dir.join("file_a.rs"),
            "pub struct Alpha { pub id: i32 }",
        )
        .unwrap();
        std::fs::write(
            src_dir.join("file_b.rs"),
            "pub struct Beta { pub name: String }",
        )
        .unwrap();

        // First call: populates file_lists cache for src_dir
        let result1 = get_struct_candidates(src_dir, "Alpha");
        assert_eq!(result1.len(), 1);

        // Second call: same src_dir, different struct_name
        // struct_candidates cache MISS (different key), but file_lists cache HIT → line 125
        let result2 = get_struct_candidates(src_dir, "Beta");
        assert_eq!(result2.len(), 1);
    }

    #[test]
    fn test_get_fk_column_cache_hit() {
        // First call: computes and caches result (None since path doesn't exist)
        let result1 = get_fk_column("nonexistent::path::Schema", "SomeRelation");
        // Second call: hits cache → lines 259-260
        let result2 = get_fk_column("nonexistent::path::Schema", "SomeRelation");
        assert_eq!(result1, result2);
    }

    #[serial_test::serial]
    #[test]
    fn test_print_profile_summary_with_profile_env() {
        // Set VESPERA_PROFILE to enable profiling output
        unsafe { std::env::set_var("VESPERA_PROFILE", "1") };

        // This should print profile summary to stderr (lines 311-321)
        print_profile_summary();

        // Clean up
        unsafe { std::env::remove_var("VESPERA_PROFILE") };
        // Test passes if no panic — output goes to stderr
    }

    #[serial_test::serial]
    #[test]
    fn test_print_profile_summary_without_profile_env() {
        // Ensure VESPERA_PROFILE is not set
        unsafe { std::env::remove_var("VESPERA_PROFILE") };

        // Should early-return at line 308 without printing anything
        print_profile_summary();
    }
}