use std::collections::HashMap;
use std::path::PathBuf;
use syn::visit::Visit;
use syn::{Expr, ExprPath, File};
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UsageKind {
Comparison,
FunctionArg { function: String, arg_position: usize },
MethodCall { method: String },
Assignment,
Return,
MatchPattern,
FieldInit { struct_name: String, field: String },
Other,
}
#[derive(Debug, Clone)]
pub struct ConstantUsage {
pub constant_name: String,
pub kind: UsageKind,
pub line: u32,
pub in_function: Option<String>,
pub in_test: bool,
}
pub struct UsageTracker {
constants_to_track: Vec<String>,
usages: Vec<ConstantUsage>,
current_function: Option<String>,
in_test: bool,
file_path: PathBuf,
}
impl UsageTracker {
pub fn new(file_path: PathBuf, constants_to_track: Vec<String>) -> Self {
Self {
constants_to_track,
usages: Vec::new(),
current_function: None,
in_test: false,
file_path,
}
}
pub fn track(
file_path: PathBuf,
file: &File,
constants_to_track: Vec<String>,
) -> Vec<ConstantUsage> {
let mut tracker = Self::new(file_path, constants_to_track);
tracker.visit_file(file);
tracker.usages
}
pub fn track_all(file_path: PathBuf, file: &File) -> Vec<ConstantUsage> {
let mut tracker = Self::new(file_path, Vec::new());
tracker.constants_to_track = Vec::new(); tracker.visit_file(file);
tracker.usages
}
fn is_tracked(&self, name: &str) -> bool {
if self.constants_to_track.is_empty() {
name.chars()
.all(|c| c.is_uppercase() || c == '_' || c.is_numeric())
&& !name.is_empty()
&& name.chars().next().map(|c| c.is_alphabetic()).unwrap_or(false)
} else {
self.constants_to_track.contains(&name.to_string())
}
}
fn record_usage(&mut self, name: String, kind: UsageKind, line: u32) {
self.usages.push(ConstantUsage {
constant_name: name,
kind,
line,
in_function: self.current_function.clone(),
in_test: self.in_test,
});
}
pub fn usages_by_constant(&self) -> HashMap<String, Vec<&ConstantUsage>> {
let mut map: HashMap<String, Vec<&ConstantUsage>> = HashMap::new();
for usage in &self.usages {
map.entry(usage.constant_name.clone())
.or_default()
.push(usage);
}
map
}
pub fn usages(&self) -> &[ConstantUsage] {
&self.usages
}
pub fn into_usages(self) -> Vec<ConstantUsage> {
self.usages
}
}
impl<'ast> Visit<'ast> for UsageTracker {
fn visit_expr_binary(&mut self, node: &'ast syn::ExprBinary) {
if matches!(node.op, syn::BinOp::Eq(_) | syn::BinOp::Ne(_)) {
if let Expr::Path(path) = &*node.left {
if let Some(name) = path.path.get_ident().map(|i| i.to_string()) {
if self.is_tracked(&name) {
self.record_usage(
name,
UsageKind::Comparison,
path.path.segments.first().map(|s| s.ident.span().start().line as u32).unwrap_or(0),
);
}
}
}
if let Expr::Path(path) = &*node.right {
if let Some(name) = path.path.get_ident().map(|i| i.to_string()) {
if self.is_tracked(&name) {
self.record_usage(
name,
UsageKind::Comparison,
path.path.segments.first().map(|s| s.ident.span().start().line as u32).unwrap_or(0),
);
}
}
}
}
syn::visit::visit_expr_binary(self, node);
}
fn visit_expr_call(&mut self, node: &'ast syn::ExprCall) {
let function = quote::quote!(#node.func).to_string();
for (pos, arg) in node.args.iter().enumerate() {
if let Expr::Path(path) = arg {
if let Some(name) = path.path.get_ident().map(|i| i.to_string()) {
if self.is_tracked(&name) {
self.record_usage(
name,
UsageKind::FunctionArg {
function: function.clone(),
arg_position: pos,
},
path.path.segments.first().map(|s| s.ident.span().start().line as u32).unwrap_or(0),
);
}
}
}
}
syn::visit::visit_expr_call(self, node);
}
fn visit_expr_method_call(&mut self, node: &'ast syn::ExprMethodCall) {
let method = node.method.to_string();
if let Expr::Path(path) = &*node.receiver {
if let Some(name) = path.path.get_ident().map(|i| i.to_string()) {
if self.is_tracked(&name) {
self.record_usage(
name,
UsageKind::MethodCall {
method: method.clone(),
},
path.path.segments.first().map(|s| s.ident.span().start().line as u32).unwrap_or(0),
);
}
}
}
for arg in &node.args {
if let Expr::Path(path) = arg {
if let Some(name) = path.path.get_ident().map(|i| i.to_string()) {
if self.is_tracked(&name) {
self.record_usage(
name,
UsageKind::FunctionArg {
function: format!(".{}", method),
arg_position: 0,
},
path.path.segments.first().map(|s| s.ident.span().start().line as u32).unwrap_or(0),
);
}
}
}
}
syn::visit::visit_expr_method_call(self, node);
}
fn visit_expr_return(&mut self, node: &'ast syn::ExprReturn) {
if let Some(Expr::Path(path)) = &node.expr.as_deref() {
if let Some(name) = path.path.get_ident().map(|i| i.to_string()) {
if self.is_tracked(&name) {
self.record_usage(
name,
UsageKind::Return,
path.path.segments.first().map(|s| s.ident.span().start().line as u32).unwrap_or(0),
);
}
}
}
syn::visit::visit_expr_return(self, node);
}
fn visit_expr_struct(&mut self, node: &'ast syn::ExprStruct) {
let struct_name = quote::quote!(#node.path).to_string();
for field in &node.fields {
if let syn::Member::Named(field_name) = &field.member {
if let Expr::Path(path) = &field.expr {
if let Some(name) = path.path.get_ident().map(|i| i.to_string()) {
if self.is_tracked(&name) {
self.record_usage(
name,
UsageKind::FieldInit {
struct_name: struct_name.clone(),
field: field_name.to_string(),
},
path.path.segments.first().map(|s| s.ident.span().start().line as u32).unwrap_or(0),
);
}
}
}
}
}
syn::visit::visit_expr_struct(self, node);
}
fn visit_item_fn(&mut self, node: &'ast syn::ItemFn) {
let old_function = self.current_function.take();
let old_test = self.in_test;
self.current_function = Some(node.sig.ident.to_string());
let is_test = node.attrs.iter().any(|attr| {
attr.path().is_ident("test")
|| attr
.path()
.segments
.last()
.map(|s| s.ident == "test")
.unwrap_or(false)
});
if is_test {
self.in_test = true;
}
syn::visit::visit_item_fn(self, node);
self.current_function = old_function;
self.in_test = old_test;
}
fn visit_item_mod(&mut self, node: &'ast syn::ItemMod) {
let old_test = self.in_test;
let is_test_mod = node.ident == "tests"
|| node.ident == "test"
|| node.attrs.iter().any(|attr| {
if attr.path().is_ident("cfg") {
if let Ok(meta) = attr.meta.require_list() {
return meta.tokens.to_string().contains("test");
}
}
false
});
if is_test_mod {
self.in_test = true;
}
syn::visit::visit_item_mod(self, node);
self.in_test = old_test;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_track_comparison_usage() {
let code = r#"
const TOKEN: &str = "secret";
fn check(input: &str) -> bool {
input == TOKEN
}
"#;
let file: syn::File = syn::parse_str(code).unwrap();
let usages = UsageTracker::track(
PathBuf::from("test.rs"),
&file,
vec!["TOKEN".to_string()],
);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].constant_name, "TOKEN");
assert!(matches!(usages[0].kind, UsageKind::Comparison));
}
#[test]
fn test_track_function_arg() {
let code = r#"
const API_KEY: &str = "key";
fn main() {
authenticate(API_KEY);
}
"#;
let file: syn::File = syn::parse_str(code).unwrap();
let usages = UsageTracker::track(
PathBuf::from("test.rs"),
&file,
vec!["API_KEY".to_string()],
);
assert_eq!(usages.len(), 1);
assert!(matches!(
&usages[0].kind,
UsageKind::FunctionArg { function, arg_position: 0 } if function.contains("authenticate")
));
}
#[test]
fn test_track_all_uppercase() {
let code = r#"
fn main() {
if x == TOKEN {}
call(SECRET);
}
"#;
let file: syn::File = syn::parse_str(code).unwrap();
let usages = UsageTracker::track_all(PathBuf::from("test.rs"), &file);
assert_eq!(usages.len(), 2);
assert!(usages.iter().any(|u| u.constant_name == "TOKEN"));
assert!(usages.iter().any(|u| u.constant_name == "SECRET"));
}
#[test]
fn test_function_context() {
let code = r#"
fn authenticate() {
check(TOKEN);
}
"#;
let file: syn::File = syn::parse_str(code).unwrap();
let usages = UsageTracker::track_all(PathBuf::from("test.rs"), &file);
assert_eq!(usages.len(), 1);
assert_eq!(usages[0].in_function, Some("authenticate".to_string()));
}
#[test]
fn test_test_context() {
let code = r#"
#[test]
fn test_auth() {
assert!(x == TOKEN);
}
"#;
let file: syn::File = syn::parse_str(code).unwrap();
let usages = UsageTracker::track_all(PathBuf::from("test.rs"), &file);
assert!(usages.iter().all(|u| u.in_test));
}
}