#[allow(clippy::too_many_arguments)]
#[provable_contracts_macros::contract("pmat-core.yaml", equation = "path_exists")]
pub async fn handle_analyze_symbol_table(
project_path: PathBuf,
format: crate::cli::SymbolTableOutputFormat,
filter: Option<crate::cli::SymbolTypeFilter>,
query: Option<String>,
include: &[String],
exclude: &[String],
show_unreferenced: bool,
show_references: bool,
output: Option<PathBuf>,
_perf: bool,
top_files: usize,
) -> Result<()> {
crate::cli::ensure_analysis_path_exists(&project_path)?;
crate::status_eprintln!("🔍 Building symbol table for project...");
let table = build_symbol_table(&project_path, include, exclude, top_files).await?;
let filtered = apply_filters(table, filter, query, top_files)?;
let content = format_output(
filtered,
format,
show_unreferenced,
show_references,
top_files,
)?;
if let Some(output_path) = output {
tokio::fs::write(&output_path, &content).await?;
crate::status_eprintln!("✅ Symbol table written to: {}", output_path.display());
} else {
println!("{content}");
}
Ok(())
}
async fn build_symbol_table(
project_path: &Path,
include: &[String],
exclude: &[String],
top_files: usize,
) -> Result<SymbolTable> {
let files = collect_files(project_path, include, exclude).await?;
let mut sources = Vec::with_capacity(files.len());
for file in files {
let content = tokio::fs::read_to_string(&file).await?;
sources.push(FileSource {
path: file.to_string_lossy().to_string(),
content,
});
}
let mut symbols = Vec::new();
for source in &sources {
symbols.extend(extract_symbols_simple(&source.content, &source.path)?);
}
let unresolved = resolve_references(&sources, &mut symbols);
let unreferenced = find_unreferenced_symbols(&symbols, &unresolved);
let (most_referenced, referenced_symbol_count) = find_most_referenced(&symbols, top_files);
Ok(SymbolTable {
total_symbols: symbols.len(),
symbols,
unreferenced_symbols: unreferenced,
most_referenced,
referenced_symbol_count,
})
}
async fn collect_files(
project_path: &Path,
include: &[String],
exclude: &[String],
) -> Result<Vec<PathBuf>> {
let mut files = Vec::new();
if project_path.is_file() {
process_file(project_path.to_path_buf(), &mut files, include)?;
return Ok(files);
}
collect_files_recursive(project_path, &mut files, include, exclude).await?;
files.sort();
Ok(files)
}
async fn collect_files_recursive(
dir: &Path,
files: &mut Vec<PathBuf>,
include: &[String],
exclude: &[String],
) -> Result<()> {
let mut entries = tokio::fs::read_dir(dir).await?;
while let Some(entry) = entries.next_entry().await? {
process_directory_entry(entry, files, include, exclude).await?;
}
Ok(())
}
async fn process_directory_entry(
entry: tokio::fs::DirEntry,
files: &mut Vec<PathBuf>,
include: &[String],
exclude: &[String],
) -> Result<()> {
let path = entry.path();
if should_skip_path(&path, exclude) {
return Ok(());
}
if path.is_dir() {
process_directory(&path, files, include, exclude).await
} else {
process_file(path, files, include)
}
}
fn should_skip_path(path: &Path, exclude: &[String]) -> bool {
exclude.iter().any(|pattern| matches_pattern(path, pattern))
}
async fn process_directory(
path: &Path,
files: &mut Vec<PathBuf>,
include: &[String],
exclude: &[String],
) -> Result<()> {
if should_process_directory(path) {
Box::pin(collect_files_recursive(path, files, include, exclude)).await?;
}
Ok(())
}
fn should_process_directory(path: &Path) -> bool {
let name = path.file_name().unwrap_or_default().to_string_lossy();
!name.starts_with('.') && name != "node_modules" && name != "target"
}
fn process_file(path: PathBuf, files: &mut Vec<PathBuf>, include: &[String]) -> Result<()> {
if !is_source_file(&path) {
return Ok(());
}
if should_include_file(&path, include) {
files.push(path);
}
Ok(())
}
fn should_include_file(path: &Path, include: &[String]) -> bool {
include.is_empty() || include.iter().any(|pattern| matches_pattern(path, pattern))
}
fn matches_pattern(path: &Path, pattern: &str) -> bool {
if pattern.is_empty() {
return false;
}
let path_str = path.to_string_lossy();
if pattern.contains('*') || pattern.contains('?') || pattern.contains('[') {
if let Ok(glob) = glob::Pattern::new(pattern) {
let file_name = path.file_name().map(|n| n.to_string_lossy().to_string());
return glob.matches(&path_str) || file_name.is_some_and(|name| glob.matches(&name));
}
}
path_str.contains(pattern)
}
fn is_source_file(path: &Path) -> bool {
matches!(
path.extension().and_then(|s| s.to_str()),
Some("rs" | "js" | "ts" | "py" | "java" | "cpp" | "c" | "h" | "hpp" | "go" | "rb")
)
}
fn extract_symbols_simple(content: &str, file: &str) -> Result<Vec<Symbol>> {
use regex::Regex;
let mut symbols = Vec::new();
let patterns = vec![
(
Regex::new(r"(?m)^\s*(?:pub\s+)?(?:const\s+)?(?:async\s+)?fn\s+(\w+)")?,
SymbolKind::Function,
),
(
Regex::new(r"(?m)^\s*(?:export\s+)?class\s+(\w+)")?,
SymbolKind::Class,
),
(
Regex::new(r"(?m)^\s*(?:export\s+)?(?:async\s+)?function\s+(\w+)")?,
SymbolKind::Function,
),
(Regex::new(r"(?m)^\s*def\s+(\w+)")?, SymbolKind::Function),
(
Regex::new(r"(?m)^\s*(?:pub\s+)?const\s+(\w+)\s*[:=]")?,
SymbolKind::Constant,
),
(
Regex::new(r"(?m)^\s*(?:pub\s+)?static\s+(?:mut\s+)?(\w+)\s*[:=]")?,
SymbolKind::Variable,
),
(
Regex::new(r"(?m)^\s*(?:pub\s+)?mod\s+(\w+)")?,
SymbolKind::Module,
),
(
Regex::new(r"(?m)^\s*(?:pub\s+)?struct\s+(\w+)")?,
SymbolKind::Type,
),
(
Regex::new(r"(?m)^\s*(?:pub\s+)?enum\s+(\w+)")?,
SymbolKind::Enum,
),
(
Regex::new(r"(?m)^\s*(?:export\s+)?interface\s+(\w+)")?,
SymbolKind::Interface,
),
(
Regex::new(r"(?m)^\s*(?:pub\s+|export\s+)?trait\s+(\w+)")?,
SymbolKind::Interface,
),
(
Regex::new(r"(?m)^\s*(?:pub\s+|export\s+)?type\s+(\w+)")?,
SymbolKind::Type,
),
];
for (line_no, line) in content.lines().enumerate() {
for (pattern, kind) in &patterns {
if let Some(captures) = pattern.captures(line) {
if let Some(name) = captures.get(1) {
symbols.push(Symbol {
name: name.as_str().to_string(),
kind: kind.clone(),
file: file.to_string(),
line: line_no + 1,
column: name.start(),
visibility: detect_visibility(&line[..name.start()]),
references: vec![Reference {
file: file.to_string(),
line: line_no + 1,
column: name.start(),
kind: ReferenceKind::Definition,
}],
});
}
}
}
}
Ok(symbols)
}
fn detect_visibility(prefix: &str) -> Visibility {
if prefix.contains("pub ") || prefix.contains("export ") {
Visibility::Public
} else if prefix.contains("private ") {
Visibility::Private
} else if prefix.contains("protected ") {
Visibility::Protected
} else {
Visibility::Internal
}
}
fn usage_counts_by_name(symbols: &[Symbol]) -> HashMap<&str, usize> {
let mut counts: HashMap<&str, usize> = HashMap::new();
for symbol in symbols {
*counts.entry(symbol.name.as_str()).or_insert(0) += usage_count(symbol);
}
counts
}
fn find_unreferenced_symbols(symbols: &[Symbol], unresolved: &HashSet<String>) -> Vec<String> {
let counts = usage_counts_by_name(symbols);
let mut names: Vec<String> = counts
.into_iter()
.filter(|(name, count)| *count == 0 && !unresolved.contains(*name))
.map(|(name, _)| name.to_string())
.collect();
names.sort();
names
}
fn find_most_referenced(symbols: &[Symbol], limit: usize) -> (Vec<(String, usize)>, usize) {
let mut refs: Vec<(String, usize)> = usage_counts_by_name(symbols)
.into_iter()
.filter(|(_, count)| *count > 0)
.map(|(name, count)| (name.to_string(), count))
.collect();
refs.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0)));
let total = refs.len();
if limit > 0 {
refs.truncate(limit);
}
(refs, total)
}
#[cfg_attr(coverage_nightly, coverage(off))]
#[cfg(test)]
mod keyword_capture_tests {
use super::*;
#[test]
fn const_fn_is_a_function_named_after_the_fn_not_the_keyword() {
let content = "const fn answer() -> i32 { 42 }\npub fn other() -> i32 { answer() }\n";
let symbols = extract_symbols_simple(content, "lib.rs").unwrap();
let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
assert!(
!names.contains(&"fn"),
"the `fn` keyword must never become a symbol: {names:?}"
);
assert!(
symbols
.iter()
.any(|s| s.name == "answer" && matches!(s.kind, SymbolKind::Function)),
"`const fn answer` must be a Function named answer: {names:?}"
);
}
#[test]
fn static_mut_is_named_after_the_variable_not_the_mut_keyword() {
let content = "pub static mut COUNTER: u32 = 0;\n";
let symbols = extract_symbols_simple(content, "lib.rs").unwrap();
let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
assert!(!names.contains(&"mut"), "captured the keyword: {names:?}");
assert!(names.contains(&"COUNTER"), "lost the variable: {names:?}");
}
#[test]
fn plain_constants_and_statics_are_still_extracted() {
let content = "pub const KONST: u32 = 1;\npub static STAT: u32 = 2;\nconst js = 3;\n";
let symbols = extract_symbols_simple(content, "lib.rs").unwrap();
let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
assert!(names.contains(&"KONST"), "{names:?}");
assert!(names.contains(&"STAT"), "{names:?}");
assert!(names.contains(&"js"), "{names:?}");
}
#[test]
fn indented_declarations_are_not_invisible() {
let content = concat!(
"pub struct Widget {\n",
" pub id: u32,\n",
"}\n",
"\n",
"pub trait Drawable {\n",
" fn draw(&self);\n",
"}\n",
"\n",
"impl Widget {\n",
" pub fn new() -> Self { Widget { id: 0 } }\n",
" fn helper(&self) -> u32 { self.id }\n",
"}\n",
"\n",
"pub type WidgetAlias = Widget;\n",
"\n",
"mod inner {\n",
" pub fn nested_fn() {}\n",
"}\n",
"\n",
"pub fn top_level() {}\n",
);
let symbols = extract_symbols_simple(content, "a.rs").expect("extract");
let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
for expected in [
"Widget",
"Drawable",
"draw",
"new",
"helper",
"WidgetAlias",
"inner",
"nested_fn",
"top_level",
] {
assert!(
names.contains(&expected),
"`{expected}` is declared in the fixture but missing from the table: {names:?}"
);
}
let new_fn = symbols
.iter()
.find(|s| s.name == "new")
.expect("`new` must be extracted");
assert!(
matches!(new_fn.visibility, Visibility::Public),
"indented `pub fn new` reported as {:?}",
new_fn.visibility
);
let helper = symbols
.iter()
.find(|s| s.name == "helper")
.expect("`helper` must be extracted");
assert!(
matches!(helper.visibility, Visibility::Internal),
"indented private `fn helper` reported as {:?}",
helper.visibility
);
}
#[test]
fn exported_typescript_declarations_are_extracted() {
let content = concat!(
"export class ExportedClass {\n",
" method() { return 1; }\n",
"}\n",
"class PlainClass {}\n",
"export function exportedFn() {}\n",
"export interface Shape {}\n",
"export type Alias = Shape;\n",
);
let symbols = extract_symbols_simple(content, "b.ts").expect("extract");
let names: Vec<&str> = symbols.iter().map(|s| s.name.as_str()).collect();
for expected in [
"ExportedClass",
"PlainClass",
"exportedFn",
"Shape",
"Alias",
] {
assert!(names.contains(&expected), "missing `{expected}`: {names:?}");
}
}
}