Skip to main content

lexicon_api/
extract.rs

1use std::path::Path;
2
3use syn::visit::Visit;
4
5use crate::error::ApiError;
6use crate::schema::{ApiItem, ApiItemKind, ApiSnapshot, Visibility};
7
8/// Extract public API items from a Rust source string.
9pub 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
20/// Extract public API items from a Rust source file.
21pub 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
27/// Extract a full API snapshot from a directory of Rust source files.
28pub 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                    // Skip files that fail to parse
54                }
55                Err(e) => return Err(e),
56            }
57        }
58    }
59    Ok(())
60}
61
62fn chrono_now() -> String {
63    // Simple ISO 8601 timestamp without pulling in chrono
64    // We use a fixed format for reproducibility in tests
65    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    // Simple formatting: just use the unix timestamp in a readable way
71    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        // Recurse into module body
310        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        // Should have the module and the function inside it
446        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}