Skip to content
Merged
1 change: 1 addition & 0 deletions .changepacks/changepack_log_T3zG0KC3ZHXyofRMxILja.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{"changes":{"Cargo.toml":"Patch"},"note":"Optimize","date":"2026-02-27T13:50:39.128469700Z"}
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

10 changes: 5 additions & 5 deletions crates/vespera_macro/src/collector.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,10 +22,11 @@ pub fn collect_metadata(
folder_name: &str,
) -> MacroResult<(CollectedMetadata, HashMap<String, syn::File>)> {
let mut metadata = CollectedMetadata::new();
let mut file_asts = HashMap::new();

let files = collect_files(folder_path).map_err(|e| err_call_site(format!("vespera! macro: failed to scan route folder '{}': {}. Verify the folder exists and is readable.", folder_path.display(), e)))?;

let mut file_asts = HashMap::with_capacity(files.len());

for file in files {
if file.extension().is_none_or(|e| e != "rs") {
continue;
Expand All @@ -42,8 +43,9 @@ pub fn collect_metadata(
let file_ast = syn::parse_file(&content).map_err(|e| err_call_site(format!("vespera! macro: syntax error in '{}': {}. Fix the Rust syntax errors in this file.", file.display(), e)))?;

// Store file AST for downstream reuse (keyed by display path to match RouteMetadata.file_path)
let file_path_key = file.display().to_string();
file_asts.insert(file_path_key, file_ast.clone());
let file_path = file.display().to_string();
file_asts.insert(file_path.clone(), file_ast);
let file_ast = &file_asts[&file_path];

// Get module path
let segments = file
Expand All @@ -64,8 +66,6 @@ pub fn collect_metadata(
format!("{}::{}", folder_name, segments.join("::"))
};

let file_path = file.display().to_string();

// Pre-compute base path once per file (avoids repeated segments.join per route)
let base_path = format!("/{}", segments.join("/"));

Expand Down
6 changes: 0 additions & 6 deletions crates/vespera_macro/src/file_utils.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,6 @@ use std::{
path::{Path, PathBuf},
};

/// Read and parse a Rust source file, returning None on error (silent).
pub fn try_read_and_parse_file(path: &Path) -> Option<syn::File> {
let content = std::fs::read_to_string(path).ok()?;
syn::parse_file(&content).ok()
}

/// Read and parse a Rust source file, printing warnings on error.
#[allow(clippy::similar_names)]
pub fn read_and_parse_file_warn(path: &Path, context: &str) -> Option<syn::File> {
Expand Down
2 changes: 1 addition & 1 deletion crates/vespera_macro/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ mod http;
mod metadata;
mod method;
mod openapi_generator;
mod parse_utils;

mod parser;
mod route;
mod route_impl;
Expand Down
121 changes: 81 additions & 40 deletions crates/vespera_macro/src/openapi_generator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ use crate::{
metadata::CollectedMetadata,
parser::{
build_operation_from_function, extract_default, extract_field_rename, extract_rename_all,
parse_enum_to_schema, parse_struct_to_schema, rename_field, strip_raw_prefix,
parse_enum_to_schema, parse_struct_to_schema, rename_field, strip_raw_prefix_owned,
},
schema_macro::type_utils::get_type_default as utils_get_type_default,
};
Expand Down Expand Up @@ -103,13 +103,12 @@ pub fn generate_openapi_doc_with_metadata(
fn build_schema_lookups(
metadata: &CollectedMetadata,
) -> (HashSet<String>, HashMap<String, String>) {
let mut known_schema_names = HashSet::new();
let mut struct_definitions = HashMap::new();
let mut known_schema_names = HashSet::with_capacity(metadata.structs.len());
let mut struct_definitions = HashMap::with_capacity(metadata.structs.len());

for struct_meta in &metadata.structs {
let schema_name = struct_meta.name.clone();
known_schema_names.insert(schema_name);
struct_definitions.insert(struct_meta.name.clone(), struct_meta.definition.clone());
known_schema_names.insert(struct_meta.name.clone());
}

(known_schema_names, struct_definitions)
Expand Down Expand Up @@ -139,7 +138,7 @@ fn build_file_cache(metadata: &CollectedMetadata) -> HashMap<String, syn::File>
/// Enables O(1) lookup of which file contains a given struct definition,
/// replacing the previous O(routes × file_read) linear scan.
fn build_struct_file_index(file_cache: &HashMap<String, syn::File>) -> HashMap<String, &str> {
let mut index = HashMap::new();
let mut index = HashMap::with_capacity(file_cache.len() * 4);
for (path, ast) in file_cache {
for item in &ast.items {
if let syn::Item::Struct(s) = item {
Expand Down Expand Up @@ -232,47 +231,63 @@ fn build_path_items(
let mut paths = BTreeMap::new();
let mut all_tags = BTreeSet::new();

// Pre-build function name index for O(1) lookup instead of O(items) per route
let fn_index: HashMap<&str, HashMap<String, &syn::ItemFn>> = file_cache
.iter()
.map(|(path, ast)| {
let fns: HashMap<String, &syn::ItemFn> = ast
.items
.iter()
.filter_map(|item| {
if let syn::Item::Fn(fn_item) = item {
Some((fn_item.sig.ident.to_string(), fn_item))
} else {
None
}
})
.collect();
(path.as_str(), fns)
})
.collect();

for route_meta in &metadata.routes {
let Some(file_ast) = file_cache.get(&route_meta.file_path) else {
let Some(fns) = fn_index.get(route_meta.file_path.as_str()) else {
continue;
};

for item in &file_ast.items {
if let syn::Item::Fn(fn_item) = item
&& fn_item.sig.ident == route_meta.function_name
{
let Ok(method) = HttpMethod::try_from(route_meta.method.as_str()) else {
eprintln!(
"vespera: skipping route '{}' — unknown HTTP method '{}'",
route_meta.path, route_meta.method
);
continue;
};
let Some(fn_item) = fns.get(&route_meta.function_name) else {
continue;
};

if let Some(tags) = &route_meta.tags {
for tag in tags {
all_tags.insert(tag.clone());
}
}
let Ok(method) = HttpMethod::try_from(route_meta.method.as_str()) else {
eprintln!(
"vespera: skipping route '{}' — unknown HTTP method '{}'",
route_meta.path, route_meta.method
);
continue;
};

let mut operation = build_operation_from_function(
&fn_item.sig,
&route_meta.path,
known_schema_names,
struct_definitions,
route_meta.error_status.as_deref(),
route_meta.tags.as_deref(),
);
operation.description.clone_from(&route_meta.description);

let path_item = paths
.entry(route_meta.path.clone())
.or_insert_with(PathItem::default);

path_item.set_operation(method, operation);
break;
if let Some(tags) = &route_meta.tags {
for tag in tags {
all_tags.insert(tag.clone());
}
}

let mut operation = build_operation_from_function(
&fn_item.sig,
&route_meta.path,
known_schema_names,
struct_definitions,
route_meta.error_status.as_deref(),
route_meta.tags.as_deref(),
);
operation.description.clone_from(&route_meta.description);

let path_item = paths
.entry(route_meta.path.clone())
.or_insert_with(PathItem::default);

path_item.set_operation(method, operation);
}

(paths, all_tags)
Expand Down Expand Up @@ -321,7 +336,7 @@ fn process_default_functions(
for field in &fields_named.named {
let rust_field_name = field.ident.as_ref().map_or_else(
|| "unknown".to_string(),
|i| strip_raw_prefix(&i.to_string()).to_string(),
|i| strip_raw_prefix_owned(i.to_string()),
);
let field_name = extract_field_rename(&field.attrs)
.unwrap_or_else(|| rename_field(&rust_field_name, struct_rename_all.as_deref()));
Expand Down Expand Up @@ -1663,4 +1678,30 @@ pub fn create_users() -> String {
panic!("Expected inline schema with default");
}
}

#[test]
fn test_generate_openapi_route_function_not_in_ast() {
let temp_dir = TempDir::new().expect("Failed to create temp dir");
let route_content = "pub fn get_items() -> String { \"items\".to_string() }\n";
let route_file = create_temp_file(&temp_dir, "users.rs", route_content);

let mut metadata = CollectedMetadata::new();
metadata.routes.push(RouteMetadata {
method: "GET".to_string(),
path: "/users".to_string(),
function_name: "get_users".to_string(),
module_path: "test::users".to_string(),
file_path: route_file.to_string_lossy().to_string(),
signature: "fn get_users() -> String".to_string(),
error_status: None,
tags: None,
description: None,
});

let doc = generate_openapi_doc_with_metadata(None, None, None, &metadata, None);
assert!(
doc.paths.is_empty(),
"Route with non-matching function should be skipped"
);
}
}
Loading