1use quote::quote;
60use serde_yaml_ng as serde_yaml;
61use std::{
62 collections::HashMap,
63 fs,
64 path::{Path},
65};
66use syn::{
67 Attribute,
68 ItemFn, ItemStruct, ImplItem,
69 Meta,
70 Visibility,
71 visit::Visit,
72 FnArg, Pat, Type, ReturnType, Expr, ExprCall, ExprMethodCall, ExprPath,
73};
74use walkdir::{WalkDir, DirEntry};
75
76pub use data::{
79 AnalysisResult, StructDecl, FunctionDecl,
80 YamlOutput, YamlSubproject, YamlFunction, YamlStruct, YamlCall,
81};
82
83pub fn analyze_project(root: &Path) -> Result<AnalysisResult, Box<dyn std::error::Error>> {
112 if !root.is_dir() {
113 return Err(format!("'{}' is not a directory", root.display()).into());
114 }
115 let (structs, functions) = collect_all_items(root)?;
116 Ok(AnalysisResult { structs, functions })
117}
118
119pub fn analysis_to_yaml(
149 analysis: &AnalysisResult,
150 root: &Path,
151) -> Result<String, Box<dyn std::error::Error>> {
152 let cargo_toml = root.join("Cargo.toml");
153 let crate_name = read_crate_name(&cargo_toml);
154
155 let index = index_functions(&analysis.functions);
156
157 let yaml_functions: Vec<YamlFunction> = analysis
158 .functions
159 .iter()
160 .map(|f| {
161 let calls: Vec<YamlCall> = f
162 .calls
163 .iter()
164 .map(|call_name| {
165 if index.contains_key(call_name) {
166 let target = &index[call_name];
167 YamlCall {
168 name: call_name.clone(),
169 file: Some(target.file.clone()),
170 line: Some(target.line),
171 external: None,
172 }
173 } else {
174 YamlCall {
175 name: call_name.clone(),
176 file: None,
177 line: None,
178 external: Some(true),
179 }
180 }
181 })
182 .collect();
183
184 YamlFunction {
185 name: f.full_name.clone(),
186 file: f.file.clone(),
187 line: f.line,
188 returns: f.returns.clone(),
189 parameters: if f.parameters.is_empty() {
190 None
191 } else {
192 Some(f.parameters.clone())
193 },
194 docstring: f.docstring.clone(),
195 attributes: f.attributes.clone(),
196 calls,
197 }
198 })
199 .collect();
200
201 let yaml_structs: Vec<YamlStruct> = analysis
202 .structs
203 .iter()
204 .map(|s| YamlStruct {
205 name: s.name.clone(),
206 file: s.file.clone(),
207 line: s.line,
208 })
209 .collect();
210
211 let subproject = YamlSubproject {
212 name: crate_name,
213 root: ".".to_string(),
214 structures: yaml_structs,
215 functions: yaml_functions,
216 };
217
218 let output = YamlOutput {
219 version: "1.0".to_string(),
220 project_name: None,
221 subprojects: vec![subproject],
222 };
223
224 let yaml = serde_yaml::to_string(&output)?;
225 Ok(yaml)
226}
227
228mod data {
232 use serde::Serialize;
233
234 #[derive(Debug, Clone)]
236 pub struct StructDecl {
237 pub name: String,
239 pub file: String,
241 pub line: usize,
243 }
244
245 #[derive(Debug, Clone)]
247 pub struct FunctionDecl {
248 pub full_name: String,
250 pub file: String,
252 pub line: usize,
254 pub returns: String,
256 pub parameters: Vec<String>,
258 pub docstring: Option<String>,
260 pub attributes: Vec<String>,
264 pub calls: Vec<String>,
266 }
267
268 #[derive(Debug)]
270 pub struct AnalysisResult {
271 pub structs: Vec<StructDecl>,
273 pub functions: Vec<FunctionDecl>,
275 }
276
277 #[derive(Serialize, Debug)]
281 pub struct YamlStruct {
282 pub name: String,
283 pub file: String,
284 pub line: usize,
285 }
286
287 #[derive(Serialize, Debug)]
289 pub struct YamlCall {
290 pub name: String,
292 #[serde(skip_serializing_if = "Option::is_none")]
294 pub file: Option<String>,
295 #[serde(skip_serializing_if = "Option::is_none")]
297 pub line: Option<usize>,
298 #[serde(skip_serializing_if = "Option::is_none")]
300 pub external: Option<bool>,
301 }
302
303 #[derive(Serialize, Debug)]
305 pub struct YamlFunction {
306 pub name: String,
307 pub file: String,
308 pub line: usize,
309 pub returns: String,
310 #[serde(skip_serializing_if = "Option::is_none")]
311 pub parameters: Option<Vec<String>>,
312 #[serde(skip_serializing_if = "Option::is_none")]
313 pub docstring: Option<String>,
314 #[serde(skip_serializing_if = "Vec::is_empty")]
315 pub attributes: Vec<String>,
316 #[serde(skip_serializing_if = "Vec::is_empty")]
317 pub calls: Vec<YamlCall>,
318 }
319
320 #[derive(Serialize, Debug)]
322 pub struct YamlSubproject {
323 pub name: String,
325 pub root: String,
327 #[serde(skip_serializing_if = "Vec::is_empty")]
328 pub structures: Vec<YamlStruct>,
329 #[serde(skip_serializing_if = "Vec::is_empty")]
330 pub functions: Vec<YamlFunction>,
331 }
332
333 #[derive(Serialize, Debug)]
335 pub struct YamlOutput {
336 pub version: String,
338 #[serde(skip_serializing_if = "Option::is_none")]
340 pub project_name: Option<String>,
341 pub subprojects: Vec<YamlSubproject>,
343 }
344}
345
346fn is_noise_call(name: &str) -> bool {
353 const NOISE: &[&str] = &[
354 "clone", "to_string", "into", "from", "as_ref", "deref", "borrow", "as_mut",
355 "map", "map_err", "and_then", "or_else", "unwrap", "expect", "ok", "err",
356 "is_ok", "is_err", "is_some", "is_none", "unwrap_or", "unwrap_or_else",
357 "iter", "into_iter", "next", "collect", "filter", "find", "for_each",
358 "enumerate", "zip", "take", "skip", "inspect",
359 "push", "pop", "insert", "remove", "get", "contains", "len", "is_empty",
360 "clear", "extend", "keys", "values",
361 "new", "default", "Some", "Ok", "Err",
362 "info", "warn", "error", "debug", "trace",
363 "serialize", "deserialize",
364 "join", "starts_with", "ends_with", "contains", "file_name", "extension",
365 ];
366 NOISE.contains(&name)
367}
368
369fn is_meaningful_call(name: &str) -> bool {
376 if name.is_empty() {
377 return false;
378 }
379 if is_noise_call(name) {
380 return false;
381 }
382 if name.len() <= 2 && name.chars().all(|c| c.is_ascii_lowercase()) {
383 return false;
384 }
385 name.starts_with(|c: char| c.is_alphabetic() || c == '_') || name.contains("::")
386}
387
388fn path_to_string(path: &syn::Path) -> String {
390 path.segments
391 .iter()
392 .map(|s| s.ident.to_string())
393 .collect::<Vec<_>>()
394 .join("::")
395}
396
397fn is_doc_attr(attr: &Attribute) -> bool {
399 attr.path().segments.len() == 1 && attr.path().is_ident("doc")
400}
401
402fn extract_docstring(attrs: &[Attribute]) -> Option<String> {
406 let mut lines = Vec::new();
407 for attr in attrs {
408 if !attr.path().is_ident("doc") {
409 continue;
410 }
411 match &attr.meta {
412 Meta::NameValue(namevalue) => {
413 if let syn::Expr::Lit(syn::ExprLit {
414 lit: syn::Lit::Str(lit),
415 ..
416 }) = &namevalue.value
417 {
418 let mut s = lit.value();
419 if s.starts_with(' ') {
420 s = s[1..].to_string();
421 }
422 lines.push(s);
423 }
424 }
425 Meta::List(meta_list) => {
426 if let Ok(lit) = meta_list.parse_args::<syn::LitStr>() {
427 lines.push(lit.value());
428 }
429 }
430 Meta::Path(_) => {}
431 }
432 }
433 if lines.is_empty() {
434 None
435 } else {
436 Some(lines.join("\n"))
437 }
438}
439
440fn extract_attributes_from_fn(item_fn: &ItemFn) -> Vec<String> {
447 let mut attrs = Vec::new();
448 for attr in &item_fn.attrs {
449 if is_doc_attr(attr) {
450 continue;
451 }
452 if let Some(seg) = attr.path().segments.last() {
453 attrs.push(format!("#[{}]", seg.ident));
454 }
455 }
456 if matches!(item_fn.vis, Visibility::Public(_)) {
457 attrs.push("pub".to_string());
458 }
459 if item_fn.sig.asyncness.is_some() {
460 attrs.push("async".to_string());
461 }
462 if item_fn.sig.unsafety.is_some() {
463 attrs.push("unsafe".to_string());
464 }
465 if item_fn.sig.constness.is_some() {
466 attrs.push("const".to_string());
467 }
468 attrs
469}
470
471fn extract_attributes_from_impl_method(
475 sig: &syn::Signature,
476 attrs: &[Attribute],
477) -> Vec<String> {
478 let mut result = Vec::new();
479 for attr in attrs {
480 if is_doc_attr(attr) {
481 continue;
482 }
483 if let Some(seg) = attr.path().segments.last() {
484 result.push(format!("#[{}]", seg.ident));
485 }
486 }
487 if sig.asyncness.is_some() {
488 result.push("async".to_string());
489 }
490 if sig.unsafety.is_some() {
491 result.push("unsafe".to_string());
492 }
493 if sig.constness.is_some() {
494 result.push("const".to_string());
495 }
496 result
497}
498
499fn format_return_type(ret: &ReturnType) -> String {
503 match ret {
504 ReturnType::Default => "()".to_string(),
505 ReturnType::Type(_, ty) => {
506 let s = quote! { #ty }.to_string();
507 if s.len() > 60 {
508 "> &".to_string()
509 } else {
510 s
511 }
512 }
513 }
514}
515
516fn format_parameters(
522 inputs: &syn::punctuated::Punctuated<FnArg,
523 syn::Token![,]>
524) -> Vec<String> {
525 inputs.iter().map(|arg| {
526 match arg {
527 FnArg::Typed(pat_type) => {
528 let pat_str = match &*pat_type.pat {
529 Pat::Ident(p) => p.ident.to_string(),
530 _ => "_".to_string(),
531 };
532 let ty_str = quote! { #pat_type.ty }.to_string();
533 format!("{}: {}", pat_str, ty_str)
534 }
535 FnArg::Receiver(r) => {
536 let mut s = String::from("self");
537 if let Some((_, lifetime)) = &r.reference {
538 s.insert_str(0, "&");
539 if let Some(lt) = lifetime {
540 s.insert_str(1, &format!("{} ", lt));
541 } else {
542 s.insert(1, ' ');
543 }
544 if r.mutability.is_some() {
545 let pos = s.find(' ').unwrap_or(0) + 1;
546 s.insert_str(pos, "mut ");
547 }
548 } else if r.mutability.is_some() {
549 s = "mut self".to_string();
550 }
551 s
552 }
553 }
554 }).collect()
555}
556
557struct FullVisitor {
561 current_file: String,
562 functions: Vec<FunctionDecl>,
563 structs: Vec<data::StructDecl>,
564}
565
566#[derive(Default)]
568struct CallVisitor {
569 calls: Vec<String>,
570}
571
572impl<'ast> Visit<'ast> for FullVisitor {
573 fn visit_item_struct(&mut self, i: &'ast ItemStruct) {
574 self.structs.push(data::StructDecl {
575 name: i.ident.to_string(),
576 file: self.current_file.clone(),
577 line: i.ident.span().start().line,
578 });
579 syn::visit::visit_item_struct(self, i);
580 }
581
582 fn visit_item_fn(&mut self, i: &'ast ItemFn) {
583 let name = i.sig.ident.to_string();
584 let line = i.sig.ident.span().start().line;
585 let returns = format_return_type(&i.sig.output);
586 let parameters = format_parameters(&i.sig.inputs);
587 let docstring = extract_docstring(&i.attrs);
588 let attributes = extract_attributes_from_fn(i);
589
590 let mut call_visitor = CallVisitor::default();
591 call_visitor.visit_block(&i.block);
592
593 let calls = call_visitor
594 .calls
595 .into_iter()
596 .filter(|c| is_meaningful_call(c))
597 .collect();
598
599 self.functions.push(FunctionDecl {
600 full_name: name,
601 file: self.current_file.clone(),
602 line,
603 returns,
604 parameters,
605 docstring,
606 attributes,
607 calls,
608 });
609 syn::visit::visit_item_fn(self, i);
610 }
611
612 fn visit_item_impl(&mut self, i: &'ast syn::ItemImpl) {
613 let impl_type_name = match &*i.self_ty {
614 Type::Path(type_path) => {
615 type_path
616 .path
617 .segments
618 .iter()
619 .map(|s| s.ident.to_string())
620 .collect::<Vec<_>>()
621 .join("::")
622 }
623 _ => "UnknownType".to_string(),
624 };
625
626 for item in &i.items {
627 if let ImplItem::Fn(method) = item {
628 let method_name = method.sig.ident.to_string();
629 let full_name = format!("{}::{}", impl_type_name, method_name);
630 let line = method.sig.ident.span().start().line;
631 let returns = format_return_type(&method.sig.output);
632 let parameters = format_parameters(&method.sig.inputs);
633 let docstring = extract_docstring(&method.attrs);
634 let attributes = extract_attributes_from_impl_method(&method.sig, &method.attrs);
635
636 let mut call_visitor = CallVisitor::default();
637 call_visitor.visit_block(&method.block);
638
639 let calls = call_visitor
640 .calls
641 .into_iter()
642 .filter(|c| is_meaningful_call(c))
643 .collect();
644
645 self.functions.push(FunctionDecl {
646 full_name,
647 file: self.current_file.clone(),
648 line,
649 returns,
650 parameters,
651 docstring,
652 attributes,
653 calls,
654 });
655 }
656 }
657 syn::visit::visit_item_impl(self, i);
658 }
659}
660
661impl<'ast> Visit<'ast> for CallVisitor {
662 fn visit_expr_call(&mut self, node: &'ast ExprCall) {
663 if let Expr::Path(ExprPath { path, .. }) = &*node.func {
664 let path_str = path_to_string(path);
665 if !path_str.is_empty() {
666 self.calls.push(path_str);
667 }
668 }
669 syn::visit::visit_expr_call(self, node);
670 }
671
672 fn visit_expr_method_call(&mut self, node: &'ast ExprMethodCall) {
673 let method_name = node.method.to_string();
674 self.calls.push(method_name);
675 syn::visit::visit_expr_method_call(self, node);
676 }
677}
678
679fn parse_file(
683 path: &Path,
684 root: &Path,
685) -> Result<(Vec<data::StructDecl>, Vec<FunctionDecl>), Box<dyn std::error::Error>> {
686 let code = fs::read_to_string(path)?;
687
688 if code.trim().is_empty() {
689 return Ok((Vec::new(), Vec::new()));
690 }
691
692 let syntax = match syn::parse_file(&code) {
693 Ok(syntax) => syntax,
694 Err(e) => {
695 eprintln!("Skipping invalid Rust file: {}: {}", path.display(), e);
696 return Ok((Vec::new(), Vec::new()));
697 }
698 };
699
700 let relative_path = path
701 .strip_prefix(root)
702 .unwrap_or(path)
703 .to_string_lossy()
704 .to_string();
705
706 let mut visitor = FullVisitor {
707 current_file: relative_path,
708 functions: Vec::new(),
709 structs: Vec::new(),
710 };
711 visitor.visit_file(&syntax);
712 Ok((visitor.structs, visitor.functions))
713}
714
715fn is_hidden(entry: &DirEntry) -> bool {
717 entry
718 .file_name()
719 .to_str()
720 .map(|s| s.starts_with('.'))
721 .unwrap_or(false)
722}
723
724fn should_skip_entry(entry: &DirEntry) -> bool {
730 if is_hidden(entry) {
731 return true;
732 }
733 let name = entry.file_name();
734 name == "target"
735 || name == ".git"
736 || name == "node_modules"
737 || name == "dist"
738 || name == "build"
739}
740
741fn collect_all_items(
743 root_dir: &Path,
744) -> Result<(Vec<data::StructDecl>, Vec<FunctionDecl>), Box<dyn std::error::Error>> {
745 let mut all_structs = Vec::new();
746 let mut all_functions = Vec::new();
747
748 for entry in WalkDir::new(root_dir)
749 .into_iter()
750 .filter_entry(|e| !should_skip_entry(e))
751 .filter_map(|e| e.ok())
752 .filter(|e| e.path().extension().map_or(false, |ext| ext == "rs"))
753 {
754 if let Some(name) = entry.path().file_name().and_then(|n| n.to_str()) {
755 if name.contains('~') || name.ends_with(".bk") || name.ends_with(".tmp") {
756 continue;
757 }
758 }
759
760 let (structs, functions) = parse_file(entry.path(), root_dir)?;
761 all_structs.extend(structs);
762 all_functions.extend(functions);
763 }
764
765 Ok((all_structs, all_functions))
766}
767
768fn index_functions(functions: &[FunctionDecl]) -> HashMap<String, &FunctionDecl> {
770 let mut map = HashMap::new();
771 for f in functions {
772 map.insert(f.full_name.clone(), f);
773 }
774 map
775}
776
777fn read_crate_name(cargo_toml_path: &Path) -> String {
781 let contents = match fs::read_to_string(cargo_toml_path) {
782 Ok(c) => c,
783 Err(_) => return "unknown".to_string(),
784 };
785 let contents = contents.trim_start_matches('\u{feff}'); let table: toml::Table = match contents.parse() {
787 Ok(t) => t,
788 Err(_) => return "unknown".to_string(),
789 };
790 table
791 .get("package")
792 .and_then(|p| p.as_table())
793 .and_then(|p| p.get("name"))
794 .and_then(|n| n.as_str())
795 .map(|s| s.to_string())
796 .unwrap_or_else(|| "unknown".to_string())
797}
798
799#[cfg(test)]
800mod tests {
801 use super::*;
802 use syn::parse_quote;
803
804 #[test]
805 fn test_is_meaningful_call() {
806 assert!(is_meaningful_call("validate_token"));
807 assert!(is_meaningful_call("std::time::sleep"));
808 assert!(!is_meaningful_call("unwrap"));
809 assert!(!is_meaningful_call("x"));
810 assert!(!is_meaningful_call("ok"));
811 assert!(is_meaningful_call("x::y")); }
813
814 #[test]
815 fn test_path_to_string() {
816 let path = syn::parse_str::<syn::Path>("auth::validator::check").unwrap();
817 assert_eq!(path_to_string(&path), "auth::validator::check");
818 }
819
820 #[test]
821 fn test_extract_docstring() {
822 let attrs = vec![
823 syn::parse_quote!(#[doc = "First line"]),
824 syn::parse_quote!(#[doc = "Second line"]),
825 ];
826 assert_eq!(
827 extract_docstring(&attrs),
828 Some("First line\nSecond line".to_string())
829 );
830 }
831
832 #[test]
833 fn test_extract_attributes_from_fn() {
834 let item_fn: syn::ItemFn = syn::parse_str(
835 r#"#[test]
836 #[instrument]
837 pub async unsafe fn demo() {}"#
838 ).unwrap();
839 let attrs = extract_attributes_from_fn(&item_fn);
840 assert!(attrs.contains(&"#[test]".to_string()));
841 assert!(attrs.contains(&"#[instrument]".to_string()));
842 assert!(attrs.contains(&"pub".to_string()));
843 assert!(attrs.contains(&"async".to_string()));
844 assert!(attrs.contains(&"unsafe".to_string()));
845 }
846
847 #[test]
848 fn test_format_parameters_regular() {
849 let arg1: syn::FnArg = parse_quote!(a: i32);
850 let arg2: syn::FnArg = parse_quote!(b: &str);
851
852 let mut inputs = syn::punctuated::Punctuated::new();
853 inputs.push(arg1);
854 inputs.push(arg2);
855
856 let params = format_parameters(&inputs);
857 assert_eq!(params.len(), 2);
858 assert!(params[0].starts_with("a: "));
859 assert!(params[1].starts_with("b: "));
860 }
861
862}