1use std::path::Path;
2
3use syn::visit::Visit;
4
5use crate::error::ApiError;
6use crate::schema::{ApiItem, ApiItemKind, ApiSnapshot, Visibility};
7
8pub fn extract_from_source(source: &str, file_path: &str) -> Result<Vec<ApiItem>, ApiError> {
10 let syntax = syn::parse_file(source)?;
11 let mut visitor = ApiVisitor {
12 items: Vec::new(),
13 module_path: Vec::new(),
14 file_path: file_path.to_string(),
15 };
16 visitor.visit_file(&syntax);
17 Ok(visitor.items)
18}
19
20pub fn extract_from_file(path: &Path) -> Result<Vec<ApiItem>, ApiError> {
22 let source = std::fs::read_to_string(path)?;
23 let file_path = path.to_string_lossy().to_string();
24 extract_from_source(&source, &file_path)
25}
26
27pub fn extract_from_dir(dir: &Path) -> Result<ApiSnapshot, ApiError> {
29 let mut all_items = Vec::new();
30 walk_rs_files(dir, &mut all_items)?;
31 Ok(ApiSnapshot {
32 crate_name: dir
33 .file_name()
34 .map(|n| n.to_string_lossy().to_string())
35 .unwrap_or_default(),
36 version: None,
37 items: all_items,
38 extracted_at: chrono_now(),
39 })
40}
41
42fn walk_rs_files(dir: &Path, items: &mut Vec<ApiItem>) -> Result<(), ApiError> {
43 let entries = std::fs::read_dir(dir)?;
44 for entry in entries {
45 let entry = entry?;
46 let path = entry.path();
47 if path.is_dir() {
48 walk_rs_files(&path, items)?;
49 } else if path.extension().is_some_and(|e| e == "rs") {
50 match extract_from_file(&path) {
51 Ok(mut file_items) => items.append(&mut file_items),
52 Err(ApiError::Parse(_)) => {
53 }
55 Err(e) => return Err(e),
56 }
57 }
58 }
59 Ok(())
60}
61
62fn chrono_now() -> String {
63 let now = std::time::SystemTime::now();
66 let dur = now
67 .duration_since(std::time::UNIX_EPOCH)
68 .unwrap_or_default();
69 let secs = dur.as_secs();
70 format!("{secs}")
72}
73
74fn convert_visibility(vis: &syn::Visibility) -> Visibility {
75 match vis {
76 syn::Visibility::Public(_) => Visibility::Public,
77 syn::Visibility::Restricted(r) => {
78 let path_str = r.path.segments.iter()
79 .map(|s| s.ident.to_string())
80 .collect::<Vec<_>>()
81 .join("::");
82 if path_str == "crate" {
83 Visibility::Crate
84 } else if path_str == "super" || path_str.contains("in") {
85 Visibility::Restricted
86 } else {
87 Visibility::Restricted
88 }
89 }
90 syn::Visibility::Inherited => Visibility::Private,
91 }
92}
93
94fn extract_doc_summary(attrs: &[syn::Attribute]) -> Option<String> {
95 for attr in attrs {
96 if attr.path().is_ident("doc") {
97 if let syn::Meta::NameValue(nv) = &attr.meta {
98 if let syn::Expr::Lit(expr_lit) = &nv.value {
99 if let syn::Lit::Str(s) = &expr_lit.lit {
100 let text = s.value();
101 let trimmed = text.trim();
102 if !trimmed.is_empty() {
103 return Some(trimmed.to_string());
104 }
105 }
106 }
107 }
108 }
109 }
110 None
111}
112
113fn format_fn_signature(sig: &syn::Signature) -> String {
114 let unsafety = if sig.unsafety.is_some() { "unsafe " } else { "" };
115 let asyncness = if sig.asyncness.is_some() { "async " } else { "" };
116 let ident = &sig.ident;
117
118 let generics = if sig.generics.params.is_empty() {
119 String::new()
120 } else {
121 let params: Vec<String> = sig.generics.params.iter().map(|p| {
122 quote_to_string(p)
123 }).collect();
124 format!("<{}>", params.join(", "))
125 };
126
127 let inputs: Vec<String> = sig.inputs.iter().map(|arg| {
128 quote_to_string(arg)
129 }).collect();
130
131 let output = match &sig.output {
132 syn::ReturnType::Default => String::new(),
133 syn::ReturnType::Type(_, ty) => format!(" -> {}", quote_to_string(ty)),
134 };
135
136 let where_clause = sig.generics.where_clause.as_ref().map(|w| {
137 format!(" {}", quote_to_string(w))
138 }).unwrap_or_default();
139
140 format!("{asyncness}{unsafety}fn {ident}{generics}({inputs}){output}{where_clause}",
141 inputs = inputs.join(", "))
142}
143
144fn quote_to_string(tokens: &dyn quote::ToTokens) -> String {
145 let ts = quote::quote!(#tokens);
146 ts.to_string()
147}
148
149fn format_generics(generics: &syn::Generics) -> String {
150 if generics.params.is_empty() {
151 return String::new();
152 }
153 let params: Vec<String> = generics.params.iter().map(|p| quote_to_string(p)).collect();
154 format!("<{}>", params.join(", "))
155}
156
157fn format_supertraits(supertraits: &syn::punctuated::Punctuated<syn::TypeParamBound, syn::token::Plus>) -> String {
158 if supertraits.is_empty() {
159 return String::new();
160 }
161 let bounds: Vec<String> = supertraits.iter().map(|b| quote_to_string(b)).collect();
162 format!(": {}", bounds.join(" + "))
163}
164
165struct ApiVisitor {
166 items: Vec<ApiItem>,
167 module_path: Vec<String>,
168 file_path: String,
169}
170
171impl<'ast> Visit<'ast> for ApiVisitor {
172 fn visit_item_struct(&mut self, node: &'ast syn::ItemStruct) {
173 let vis = convert_visibility(&node.vis);
174 if matches!(vis, Visibility::Public | Visibility::Crate) {
175 let generics = format_generics(&node.generics);
176 self.items.push(ApiItem {
177 kind: ApiItemKind::Struct,
178 name: node.ident.to_string(),
179 module_path: self.module_path.clone(),
180 signature: format!("struct {}{generics}", node.ident),
181 visibility: vis,
182 trait_associations: vec![],
183 stability: None,
184 doc_summary: extract_doc_summary(&node.attrs),
185 span_file: Some(self.file_path.clone()),
186 span_line: Some(node.ident.span().start().line as u32),
187 });
188 }
189 syn::visit::visit_item_struct(self, node);
190 }
191
192 fn visit_item_enum(&mut self, node: &'ast syn::ItemEnum) {
193 let vis = convert_visibility(&node.vis);
194 if matches!(vis, Visibility::Public | Visibility::Crate) {
195 let generics = format_generics(&node.generics);
196 self.items.push(ApiItem {
197 kind: ApiItemKind::Enum,
198 name: node.ident.to_string(),
199 module_path: self.module_path.clone(),
200 signature: format!("enum {}{generics}", node.ident),
201 visibility: vis,
202 trait_associations: vec![],
203 stability: None,
204 doc_summary: extract_doc_summary(&node.attrs),
205 span_file: Some(self.file_path.clone()),
206 span_line: Some(node.ident.span().start().line as u32),
207 });
208 }
209 syn::visit::visit_item_enum(self, node);
210 }
211
212 fn visit_item_trait(&mut self, node: &'ast syn::ItemTrait) {
213 let vis = convert_visibility(&node.vis);
214 if matches!(vis, Visibility::Public | Visibility::Crate) {
215 let generics = format_generics(&node.generics);
216 let supers = format_supertraits(&node.supertraits);
217 self.items.push(ApiItem {
218 kind: ApiItemKind::Trait,
219 name: node.ident.to_string(),
220 module_path: self.module_path.clone(),
221 signature: format!("trait {}{generics}{supers}", node.ident),
222 visibility: vis,
223 trait_associations: vec![],
224 stability: None,
225 doc_summary: extract_doc_summary(&node.attrs),
226 span_file: Some(self.file_path.clone()),
227 span_line: Some(node.ident.span().start().line as u32),
228 });
229 }
230 syn::visit::visit_item_trait(self, node);
231 }
232
233 fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
234 let vis = convert_visibility(&node.vis);
235 if matches!(vis, Visibility::Public | Visibility::Crate) {
236 self.items.push(ApiItem {
237 kind: ApiItemKind::Function,
238 name: node.sig.ident.to_string(),
239 module_path: self.module_path.clone(),
240 signature: format_fn_signature(&node.sig),
241 visibility: vis,
242 trait_associations: vec![],
243 stability: None,
244 doc_summary: extract_doc_summary(&node.attrs),
245 span_file: Some(self.file_path.clone()),
246 span_line: Some(node.sig.ident.span().start().line as u32),
247 });
248 }
249 syn::visit::visit_item_fn(self, node);
250 }
251
252 fn visit_item_const(&mut self, node: &'ast syn::ItemConst) {
253 let vis = convert_visibility(&node.vis);
254 if matches!(vis, Visibility::Public | Visibility::Crate) {
255 let ty = quote_to_string(&node.ty);
256 self.items.push(ApiItem {
257 kind: ApiItemKind::Constant,
258 name: node.ident.to_string(),
259 module_path: self.module_path.clone(),
260 signature: format!("const {}: {ty}", node.ident),
261 visibility: vis,
262 trait_associations: vec![],
263 stability: None,
264 doc_summary: extract_doc_summary(&node.attrs),
265 span_file: Some(self.file_path.clone()),
266 span_line: Some(node.ident.span().start().line as u32),
267 });
268 }
269 syn::visit::visit_item_const(self, node);
270 }
271
272 fn visit_item_type(&mut self, node: &'ast syn::ItemType) {
273 let vis = convert_visibility(&node.vis);
274 if matches!(vis, Visibility::Public | Visibility::Crate) {
275 let generics = format_generics(&node.generics);
276 let ty = quote_to_string(&node.ty);
277 self.items.push(ApiItem {
278 kind: ApiItemKind::TypeAlias,
279 name: node.ident.to_string(),
280 module_path: self.module_path.clone(),
281 signature: format!("type {}{generics} = {ty}", node.ident),
282 visibility: vis,
283 trait_associations: vec![],
284 stability: None,
285 doc_summary: extract_doc_summary(&node.attrs),
286 span_file: Some(self.file_path.clone()),
287 span_line: Some(node.ident.span().start().line as u32),
288 });
289 }
290 syn::visit::visit_item_type(self, node);
291 }
292
293 fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
294 let vis = convert_visibility(&node.vis);
295 if matches!(vis, Visibility::Public | Visibility::Crate) {
296 self.items.push(ApiItem {
297 kind: ApiItemKind::Module,
298 name: node.ident.to_string(),
299 module_path: self.module_path.clone(),
300 signature: format!("mod {}", node.ident),
301 visibility: vis,
302 trait_associations: vec![],
303 stability: None,
304 doc_summary: extract_doc_summary(&node.attrs),
305 span_file: Some(self.file_path.clone()),
306 span_line: Some(node.ident.span().start().line as u32),
307 });
308 }
309 self.module_path.push(node.ident.to_string());
311 syn::visit::visit_item_mod(self, node);
312 self.module_path.pop();
313 }
314}
315
316#[cfg(test)]
317mod tests {
318 use super::*;
319
320 #[test]
321 fn extract_pub_struct() {
322 let source = r#"
323 pub struct Foo {
324 pub x: i32,
325 }
326 "#;
327 let items = extract_from_source(source, "test.rs").unwrap();
328 assert_eq!(items.len(), 1);
329 assert_eq!(items[0].kind, ApiItemKind::Struct);
330 assert_eq!(items[0].name, "Foo");
331 assert_eq!(items[0].signature, "struct Foo");
332 assert_eq!(items[0].visibility, Visibility::Public);
333 }
334
335 #[test]
336 fn extract_pub_enum() {
337 let source = r#"
338 pub enum Color {
339 Red,
340 Green,
341 Blue,
342 }
343 "#;
344 let items = extract_from_source(source, "test.rs").unwrap();
345 assert_eq!(items.len(), 1);
346 assert_eq!(items[0].kind, ApiItemKind::Enum);
347 assert_eq!(items[0].name, "Color");
348 assert_eq!(items[0].signature, "enum Color");
349 }
350
351 #[test]
352 fn extract_pub_trait() {
353 let source = r#"
354 pub trait Drawable: Clone {
355 fn draw(&self);
356 }
357 "#;
358 let items = extract_from_source(source, "test.rs").unwrap();
359 assert_eq!(items.len(), 1);
360 assert_eq!(items[0].kind, ApiItemKind::Trait);
361 assert_eq!(items[0].name, "Drawable");
362 assert!(items[0].signature.contains("trait Drawable"));
363 assert!(items[0].signature.contains("Clone"));
364 }
365
366 #[test]
367 fn extract_pub_function() {
368 let source = r#"
369 pub fn add(a: i32, b: i32) -> i32 {
370 a + b
371 }
372 "#;
373 let items = extract_from_source(source, "test.rs").unwrap();
374 assert_eq!(items.len(), 1);
375 assert_eq!(items[0].kind, ApiItemKind::Function);
376 assert_eq!(items[0].name, "add");
377 assert!(items[0].signature.contains("fn add"));
378 assert!(items[0].signature.contains("i32"));
379 }
380
381 #[test]
382 fn extract_pub_const() {
383 let source = r#"
384 pub const MAX: u32 = 100;
385 "#;
386 let items = extract_from_source(source, "test.rs").unwrap();
387 assert_eq!(items.len(), 1);
388 assert_eq!(items[0].kind, ApiItemKind::Constant);
389 assert_eq!(items[0].name, "MAX");
390 }
391
392 #[test]
393 fn extract_pub_type_alias() {
394 let source = r#"
395 pub type Result<T> = std::result::Result<T, MyError>;
396 "#;
397 let items = extract_from_source(source, "test.rs").unwrap();
398 assert_eq!(items.len(), 1);
399 assert_eq!(items[0].kind, ApiItemKind::TypeAlias);
400 assert_eq!(items[0].name, "Result");
401 }
402
403 #[test]
404 fn skip_private_items() {
405 let source = r#"
406 struct Private;
407 fn private_fn() {}
408 pub struct Public;
409 "#;
410 let items = extract_from_source(source, "test.rs").unwrap();
411 assert_eq!(items.len(), 1);
412 assert_eq!(items[0].name, "Public");
413 }
414
415 #[test]
416 fn extract_doc_comment() {
417 let source = r#"
418 /// Does something useful.
419 /// More details here.
420 pub fn useful() {}
421 "#;
422 let items = extract_from_source(source, "test.rs").unwrap();
423 assert_eq!(items.len(), 1);
424 assert_eq!(items[0].doc_summary.as_deref(), Some("Does something useful."));
425 }
426
427 #[test]
428 fn extract_pub_crate() {
429 let source = r#"
430 pub(crate) fn internal() {}
431 "#;
432 let items = extract_from_source(source, "test.rs").unwrap();
433 assert_eq!(items.len(), 1);
434 assert_eq!(items[0].visibility, Visibility::Crate);
435 }
436
437 #[test]
438 fn extract_nested_module() {
439 let source = r#"
440 pub mod outer {
441 pub fn inner_fn() {}
442 }
443 "#;
444 let items = extract_from_source(source, "test.rs").unwrap();
445 assert_eq!(items.len(), 2);
447 let func = items.iter().find(|i| i.kind == ApiItemKind::Function).unwrap();
448 assert_eq!(func.module_path, vec!["outer".to_string()]);
449 }
450}