use std::collections::BTreeMap;
use std::collections::BTreeSet;
use std::fs;
use std::path::Path;
use anyhow::Context;
use anyhow::Result;
use proc_macro2::LineColumn;
use syn::Expr;
use syn::ExprPath;
use syn::Item;
use syn::ItemUse;
use syn::UseTree;
use syn::spanned::Spanned;
use syn::visit::Visit;
use walkdir::WalkDir;
use super::config::DiagnosticCode;
use super::constants::PUB_VISIBILITY_PREFIX;
use super::diagnostics::Finding;
use super::diagnostics::Severity;
use super::fix_support::FixSupport;
use super::imports::ImportGroup;
use super::imports::UseFix;
use super::imports::ValidatedFixSet;
use super::module_paths;
use super::selection::Selection;
pub(crate) struct PreferModuleImportScan {
pub findings: Vec<Finding>,
pub fixes: ValidatedFixSet,
}
pub(crate) fn scan_selection(selection: &Selection) -> Result<PreferModuleImportScan> {
let mut all_findings = Vec::new();
let mut all_fixes = Vec::new();
for package_root in &selection.package_roots {
let source_root = package_root.join("src");
if !source_root.is_dir() {
continue;
}
for entry in WalkDir::new(&source_root)
.into_iter()
.filter_map(Result::ok)
{
let path = entry.path();
if !entry.file_type().is_file()
|| path.extension().and_then(|ext| ext.to_str()) != Some("rs")
{
continue;
}
let (findings, fixes) =
scan_file(selection.analysis_root.as_path(), &source_root, path)?;
all_findings.extend(findings);
all_fixes.extend(fixes);
}
}
all_findings.sort_by(|a, b| (&a.path, a.line, a.column).cmp(&(&b.path, b.line, b.column)));
all_findings.dedup_by(|a, b| a.path == b.path && a.line == b.line && a.column == b.column);
Ok(PreferModuleImportScan {
findings: all_findings,
fixes: ValidatedFixSet::from_vec(all_fixes)?,
})
}
struct RawCandidate {
function_name: String,
module_name: String,
module_path: String,
absolute_module: Vec<String>,
replacement_use: String,
span_start: LineColumn,
span_end: LineColumn,
}
struct InlineCallCandidate {
function_name: String,
module_name: String,
module_path: String,
absolute_module: Vec<String>,
prefix_start: LineColumn,
leaf_start: LineColumn,
full_span_start: LineColumn,
full_span_end: LineColumn,
}
struct ScanFileContext<'a> {
analysis_root: &'a Path,
path: &'a Path,
text: &'a str,
offsets: &'a [usize],
}
impl ScanFileContext<'_> {
fn display_path(&self) -> String {
self.path
.strip_prefix(self.analysis_root)
.unwrap_or(self.path)
.to_string_lossy()
.replace('\\', "/")
}
}
struct ImportFindingInputs<'a> {
module_to_functions: &'a BTreeMap<String, Vec<RawCandidate>>,
func_to_module: &'a BTreeMap<&'a str, &'a str>,
references: &'a [BareReference],
}
struct InlineCallFindingInputs<'a> {
candidates: &'a [InlineCallCandidate],
will_import_modules: &'a BTreeSet<Vec<String>>,
file_insertion_offset: usize,
}
fn scan_file(
analysis_root: &Path,
source_root: &Path,
path: &Path,
) -> Result<(Vec<Finding>, Vec<UseFix>)> {
let text =
fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
let syntax =
syn::parse_file(&text).with_context(|| format!("failed to parse {}", path.display()))?;
let current_module_path = module_paths::file_module_path(source_root, path)
.with_context(|| format!("failed to determine module path for {}", path.display()))?;
let offsets = line_offsets(&text);
let file_context = ScanFileContext {
analysis_root,
path,
text: &text,
offsets: &offsets,
};
let declared_modules = collect_declared_modules(&syntax);
let mut detector = ImportDetector {
source_root,
current_module_path: ¤t_module_path,
declared_modules: &declared_modules,
candidates: Vec::new(),
};
detector.visit_file(&syntax);
let mut inline_detector = InlineCallDetector {
source_root,
current_module_path: ¤t_module_path,
declared_modules: &declared_modules,
candidates: Vec::new(),
inline_mod_depth: 0,
};
inline_detector.visit_file(&syntax);
if detector.candidates.is_empty() && inline_detector.candidates.is_empty() {
return Ok((Vec::new(), Vec::new()));
}
let mut module_to_functions: BTreeMap<String, Vec<RawCandidate>> = BTreeMap::new();
for candidate in detector.candidates {
module_to_functions
.entry(candidate.module_path.clone())
.or_default()
.push(candidate);
}
let imported_names: BTreeSet<String> = module_to_functions
.values()
.flatten()
.map(|c| c.function_name.clone())
.collect();
let mut collector = ReferenceCollector {
offsets: &offsets,
imported_names: &imported_names,
references: Vec::new(),
};
collector.visit_file(&syntax);
let mut func_to_module: BTreeMap<&str, &str> = BTreeMap::new();
for functions in module_to_functions.values() {
for func in functions {
func_to_module.insert(func.function_name.as_str(), func.module_name.as_str());
}
}
let (mut findings, mut fixes) = build_findings_and_fixes(
&file_context,
&ImportFindingInputs {
module_to_functions: &module_to_functions,
func_to_module: &func_to_module,
references: &collector.references,
},
);
if !inline_detector.candidates.is_empty() {
let will_import_modules = build_will_import_modules(
&syntax,
source_root,
¤t_module_path,
&module_to_functions,
);
let file_insertion_offset = file_level_insertion_offset(&syntax, &text, &offsets);
let (inline_findings, inline_fixes) = build_inline_call_findings_and_fixes(
&file_context,
&InlineCallFindingInputs {
candidates: &inline_detector.candidates,
will_import_modules: &will_import_modules,
file_insertion_offset,
},
);
findings.extend(inline_findings);
fixes.extend(inline_fixes);
}
Ok((findings, fixes))
}
fn collect_declared_modules(syntax: &syn::File) -> BTreeSet<String> {
syntax
.items
.iter()
.filter_map(|item| {
if let syn::Item::Mod(item_mod) = item
&& item_mod.content.is_none()
{
Some(item_mod.ident.to_string())
} else {
None
}
})
.collect()
}
fn build_will_import_modules(
syntax: &syn::File,
source_root: &Path,
current_module_path: &[String],
module_to_functions: &BTreeMap<String, Vec<RawCandidate>>,
) -> BTreeSet<Vec<String>> {
let mut will_import_modules: BTreeSet<Vec<String>> = BTreeSet::new();
for item in &syntax.items {
if let Item::Use(item_use) = item
&& let Some(flat) = flatten_use_tree(&item_use.tree)
&& flat.rename.is_none()
&& let Some(absolute) = resolve_to_absolute(&flat.segments, current_module_path)
&& !absolute.is_empty()
&& leaf_is_module(source_root, &absolute)
{
will_import_modules.insert(absolute);
}
}
for functions in module_to_functions.values() {
for candidate in functions {
will_import_modules.insert(candidate.absolute_module.clone());
}
}
will_import_modules
}
fn file_level_insertion_offset(syntax: &syn::File, text: &str, offsets: &[usize]) -> usize {
let mut last_use_end: Option<usize> = None;
let mut first_item_start: Option<usize> = None;
for item in &syntax.items {
let item_start = offset(offsets, item.span().start());
first_item_start.get_or_insert(item_start);
if let Item::Use(item_use) = item {
let end = offset(offsets, item_use.span().end());
let end = if text.as_bytes().get(end) == Some(&b'\n') {
end + 1
} else {
end
};
last_use_end = Some(end);
}
}
last_use_end.or(first_item_start).unwrap_or(0)
}
fn build_inline_call_findings_and_fixes(
file_context: &ScanFileContext<'_>,
inline_inputs: &InlineCallFindingInputs<'_>,
) -> (Vec<Finding>, Vec<UseFix>) {
let display_path = file_context.display_path();
let mut findings = Vec::new();
let mut fixes = Vec::new();
let mut inserted_modules: BTreeSet<Vec<String>> = BTreeSet::new();
for candidate in inline_inputs.candidates {
let prefix_start_byte = offset(file_context.offsets, candidate.prefix_start);
let leaf_start_byte = offset(file_context.offsets, candidate.leaf_start);
let full_start_byte = offset(file_context.offsets, candidate.full_span_start);
let full_end_byte = offset(file_context.offsets, candidate.full_span_end);
let source_line = file_context
.text
.lines()
.nth(candidate.full_span_start.line.saturating_sub(1))
.unwrap_or_default()
.to_string();
let full_path_text = file_context
.text
.get(full_start_byte..full_end_byte)
.unwrap_or_default()
.to_string();
findings.push(Finding {
severity: Severity::Warning,
code: DiagnosticCode::PreferModuleImport,
path: display_path.clone(),
line: candidate.full_span_start.line,
column: candidate.full_span_start.column + 1,
highlight_len: full_path_text.len().max(1),
source_line,
item: None,
message: format!(
"import the module `{}` instead of using the fully-qualified path for `{}`",
candidate.module_name, candidate.function_name
),
suggestion: Some(format!(
"add `use {};` and call `{}::{}`",
candidate.module_path, candidate.module_name, candidate.function_name
)),
fixability: FixSupport::PreferModuleImport,
related: None,
});
let group = Some(ImportGroup {
bare_name: candidate.module_name.clone(),
full_path: candidate.absolute_module.join("::"),
});
fixes.push(UseFix {
path: file_context.path.to_path_buf(),
start: prefix_start_byte,
end: leaf_start_byte,
replacement: format!("{}::", candidate.module_name),
import_group: group.clone(),
});
if inline_inputs
.will_import_modules
.contains(&candidate.absolute_module)
{
continue;
}
if !inserted_modules.insert(candidate.absolute_module.clone()) {
continue;
}
fixes.push(UseFix {
path: file_context.path.to_path_buf(),
start: inline_inputs.file_insertion_offset,
end: inline_inputs.file_insertion_offset,
replacement: format!("use {};\n", candidate.module_path),
import_group: group,
});
}
(findings, fixes)
}
fn build_findings_and_fixes(
file_context: &ScanFileContext<'_>,
import_inputs: &ImportFindingInputs<'_>,
) -> (Vec<Finding>, Vec<UseFix>) {
let display_path = file_context.display_path();
let mut findings = Vec::new();
let mut fixes = Vec::new();
let mut rewritten_modules: BTreeSet<String> = BTreeSet::new();
for functions in import_inputs.module_to_functions.values() {
for func in functions {
let byte_start = offset(file_context.offsets, func.span_start);
let byte_end = offset(file_context.offsets, func.span_end);
let byte_end_with_newline =
if file_context.text.as_bytes().get(byte_end) == Some(&b'\n') {
byte_end + 1
} else {
byte_end
};
let source_line = file_context
.text
.lines()
.nth(func.span_start.line.saturating_sub(1))
.unwrap_or_default()
.to_string();
findings.push(Finding {
severity: Severity::Warning,
code: DiagnosticCode::PreferModuleImport,
path: display_path.clone(),
line: func.span_start.line,
column: func.span_start.column + 1,
highlight_len: func.function_name.len().max(1),
source_line,
item: None,
message: format!(
"import the module `{}` instead of the function `{}`",
func.module_name, func.function_name
),
suggestion: Some(format!("consider using: `{}`", func.replacement_use)),
fixability: FixSupport::PreferModuleImport,
related: None,
});
let group = Some(ImportGroup {
bare_name: func.module_name.clone(),
full_path: func.absolute_module.join("::"),
});
if rewritten_modules.insert(func.module_path.clone()) {
fixes.push(UseFix {
path: file_context.path.to_path_buf(),
start: byte_start,
end: byte_end,
replacement: func.replacement_use.clone(),
import_group: group,
});
} else {
fixes.push(UseFix {
path: file_context.path.to_path_buf(),
start: byte_start,
end: byte_end_with_newline,
replacement: String::new(),
import_group: group,
});
}
}
}
for reference in import_inputs.references {
if let Some(&module_name) = import_inputs.func_to_module.get(reference.name.as_str()) {
let group = import_inputs
.module_to_functions
.iter()
.find_map(|(_, funcs)| {
funcs
.iter()
.find(|func| func.module_name == module_name)
.map(|func| ImportGroup {
bare_name: func.module_name.clone(),
full_path: func.absolute_module.join("::"),
})
});
fixes.push(UseFix {
path: file_context.path.to_path_buf(),
start: reference.byte_start,
end: reference.byte_end,
replacement: format!("{module_name}::{}", reference.name),
import_group: group,
});
}
}
(findings, fixes)
}
struct ImportDetector<'a> {
source_root: &'a Path,
current_module_path: &'a [String],
declared_modules: &'a BTreeSet<String>,
candidates: Vec<RawCandidate>,
}
impl Visit<'_> for ImportDetector<'_> {
fn visit_item_use(&mut self, node: &ItemUse) {
if let Some(candidate) = analyze_function_import(
self.source_root,
self.current_module_path,
self.declared_modules,
node,
) {
self.candidates.push(candidate);
}
}
}
struct InlineCallDetector<'a> {
source_root: &'a Path,
current_module_path: &'a [String],
declared_modules: &'a BTreeSet<String>,
candidates: Vec<InlineCallCandidate>,
inline_mod_depth: usize,
}
impl Visit<'_> for InlineCallDetector<'_> {
fn visit_item_use(&mut self, _: &ItemUse) {}
fn visit_item_mod(&mut self, node: &syn::ItemMod) {
if node.content.is_some() {
self.inline_mod_depth += 1;
syn::visit::visit_item_mod(self, node);
self.inline_mod_depth -= 1;
} else {
syn::visit::visit_item_mod(self, node);
}
}
fn visit_expr_path(&mut self, node: &ExprPath) {
if self.inline_mod_depth > 0 {
return;
}
if node.qself.is_some() {
return;
}
if let Some(candidate) = analyze_inline_call(
self.source_root,
self.current_module_path,
self.declared_modules,
node,
) {
self.candidates.push(candidate);
}
}
}
fn analyze_inline_call(
source_root: &Path,
current_module_path: &[String],
declared_modules: &BTreeSet<String>,
node: &ExprPath,
) -> Option<InlineCallCandidate> {
let path = &node.path;
let segments: Vec<String> = path.segments.iter().map(|s| s.ident.to_string()).collect();
if segments.len() < 3 {
return None;
}
let first = segments.first()?;
if first != "crate" && first != "super" {
return None;
}
let leaf = segments.last()?;
if !is_snake_case_function_name(leaf) {
return None;
}
let absolute_segments = resolve_to_absolute(&segments, current_module_path)?;
if absolute_segments.is_empty() {
return None;
}
if leaf_is_module(source_root, &absolute_segments) {
return None;
}
let absolute_module = absolute_segments[..absolute_segments.len() - 1].to_vec();
if absolute_module.is_empty() || !leaf_is_module(source_root, &absolute_module) {
return None;
}
let module_name = segments[segments.len() - 2].clone();
if module_name == "super" || module_name == "crate" {
return None;
}
if !is_snake_case_module_name(&module_name) {
return None;
}
if declared_modules.contains(&module_name) {
return None;
}
let module_segments = &segments[..segments.len() - 1];
let shortened = shorten_module_path(current_module_path, module_segments);
let module_path = shortened.join("::");
let first_seg = path.segments.first()?;
let leaf_seg = path.segments.last()?;
let prefix_start = first_seg.ident.span().start();
let leaf_start = leaf_seg.ident.span().start();
let full_span_start = path.span().start();
let full_span_end = path.span().end();
Some(InlineCallCandidate {
function_name: leaf.clone(),
module_name,
module_path,
absolute_module,
prefix_start,
leaf_start,
full_span_start,
full_span_end,
})
}
fn analyze_function_import(
source_root: &Path,
current_module_path: &[String],
declared_modules: &BTreeSet<String>,
node: &ItemUse,
) -> Option<RawCandidate> {
let flat = flatten_use_tree(&node.tree)?;
if flat.rename.is_some() {
return None;
}
let first = flat.segments.first()?;
if first != "crate" && first != "super" {
return None;
}
if flat.segments.len() < 3 {
return None;
}
let leaf = flat.segments.last()?;
if !is_snake_case_function_name(leaf) {
return None;
}
let absolute_segments = resolve_to_absolute(&flat.segments, current_module_path)?;
if leaf_is_module(source_root, &absolute_segments) {
return None;
}
let module_segments = &flat.segments[..flat.segments.len() - 1];
let module_name = flat.segments[flat.segments.len() - 2].clone();
if module_name == "super" || module_name == "crate" {
return None;
}
if !is_snake_case_module_name(&module_name) {
return None;
}
if declared_modules.contains(&module_name) {
return None;
}
let shortened_module_segments = shorten_module_path(current_module_path, module_segments);
let module_path = shortened_module_segments.join("::");
let vis_prefix = extract_visibility_prefix(node);
let replacement_use = format!("{vis_prefix}use {module_path};");
let span = node.span();
let absolute_module = absolute_segments[..absolute_segments.len() - 1].to_vec();
Some(RawCandidate {
function_name: leaf.clone(),
module_name,
module_path,
absolute_module,
replacement_use,
span_start: span.start(),
span_end: span.end(),
})
}
fn resolve_to_absolute(segments: &[String], current_module_path: &[String]) -> Option<Vec<String>> {
let first = segments.first()?;
if first == "crate" {
Some(segments[1..].to_vec())
} else if first == "super" {
let super_count = segments.iter().take_while(|s| *s == "super").count();
if super_count > current_module_path.len() {
return None;
}
let mut absolute = current_module_path[..current_module_path.len() - super_count].to_vec();
absolute.extend(segments[super_count..].iter().cloned());
Some(absolute)
} else {
None
}
}
fn leaf_is_module(source_root: &Path, absolute_segments: &[String]) -> bool {
if absolute_segments.is_empty() {
return false;
}
let parent_segments = &absolute_segments[..absolute_segments.len() - 1];
let leaf = &absolute_segments[absolute_segments.len() - 1];
let mut parent_dir = source_root.to_path_buf();
for seg in parent_segments {
parent_dir.push(seg);
}
parent_dir.join(format!("{leaf}.rs")).is_file()
|| parent_dir.join(leaf).join("mod.rs").is_file()
}
fn shorten_module_path(current_module_path: &[String], module_segments: &[String]) -> Vec<String> {
if module_segments.first().is_some_and(|s| s == "super") {
return module_segments.to_vec();
}
let Some(first) = module_segments.first() else {
return module_segments.to_vec();
};
if first != "crate" {
return module_segments.to_vec();
}
let target = &module_segments[1..];
if target.is_empty() {
return module_segments.to_vec();
}
let common = common_prefix_len(current_module_path, target);
if common == 0 {
return module_segments.to_vec();
}
let up_count = current_module_path.len().saturating_sub(common);
if up_count > 1 {
return module_segments.to_vec();
}
let mut relative = Vec::new();
if up_count == 1 {
relative.push("super".to_string());
}
relative.extend(target[common..].iter().cloned());
if relative.is_empty() || relative == module_segments[1..] {
return module_segments.to_vec();
}
relative
}
fn common_prefix_len(left: &[String], right: &[String]) -> usize {
left.iter()
.zip(right.iter())
.take_while(|(l, r)| l == r)
.count()
}
fn extract_visibility_prefix(node: &ItemUse) -> String {
match &node.vis {
syn::Visibility::Public(_) => PUB_VISIBILITY_PREFIX.to_string(),
syn::Visibility::Restricted(vis) => {
let path = &vis.path;
format!("pub({}) ", quote::quote!(#path))
},
syn::Visibility::Inherited => String::new(),
}
}
fn flatten_use_tree(tree: &UseTree) -> Option<FlattenedImport> {
let mut segments = Vec::new();
let mut cursor = tree;
loop {
match cursor {
UseTree::Path(path) => {
segments.push(path.ident.to_string());
cursor = &path.tree;
},
UseTree::Name(name) => {
segments.push(name.ident.to_string());
break Some(FlattenedImport {
segments,
rename: None,
});
},
UseTree::Rename(rename_tree) => {
segments.push(rename_tree.ident.to_string());
break Some(FlattenedImport {
segments,
rename: Some(rename_tree.rename.to_string()),
});
},
_ => break None,
}
}
}
struct FlattenedImport {
segments: Vec<String>,
rename: Option<String>,
}
fn is_snake_case_function_name(name: &str) -> bool {
let Some(first) = name.chars().next() else {
return false;
};
if !first.is_ascii_lowercase() && first != '_' {
return false;
}
if name
.chars()
.all(|ch| ch.is_ascii_uppercase() || ch == '_' || ch.is_ascii_digit())
{
return false;
}
name.chars()
.all(|ch| ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '_')
}
fn is_snake_case_module_name(name: &str) -> bool { is_snake_case_function_name(name) }
struct BareReference {
name: String,
byte_start: usize,
byte_end: usize,
}
struct ReferenceCollector<'a> {
offsets: &'a [usize],
imported_names: &'a BTreeSet<String>,
references: Vec<BareReference>,
}
impl Visit<'_> for ReferenceCollector<'_> {
fn visit_item_use(&mut self, _: &ItemUse) {
}
fn visit_expr(&mut self, node: &Expr) {
match node {
Expr::Path(expr_path) => {
if expr_path.qself.is_none() && expr_path.path.segments.len() == 1 {
let seg = &expr_path.path.segments[0];
let name = seg.ident.to_string();
if self.imported_names.contains(&name) {
let span = seg.ident.span();
let start = offset(self.offsets, span.start());
let end = offset(self.offsets, span.end());
self.references.push(BareReference {
name,
byte_start: start,
byte_end: end,
});
}
}
},
_ => syn::visit::visit_expr(self, node),
}
}
fn visit_macro(&mut self, node: &syn::Macro) {
collect_bare_refs_from_tokens(
&node.tokens,
self.offsets,
self.imported_names,
&mut self.references,
);
syn::visit::visit_macro(self, node);
}
}
fn collect_bare_refs_from_tokens(
tokens: &proc_macro2::TokenStream,
offsets: &[usize],
imported_names: &BTreeSet<String>,
references: &mut Vec<BareReference>,
) {
let mut prev_colon_joint = false;
let mut prev_is_colon_colon = false;
for tt in tokens.clone() {
match tt {
proc_macro2::TokenTree::Ident(ref ident) => {
let name = ident.to_string();
if !prev_is_colon_colon && imported_names.contains(&name) {
let span = ident.span();
let start = offset(offsets, span.start());
let end = offset(offsets, span.end());
references.push(BareReference {
name,
byte_start: start,
byte_end: end,
});
}
prev_colon_joint = false;
prev_is_colon_colon = false;
},
proc_macro2::TokenTree::Punct(ref punct) => {
if punct.as_char() == ':' {
if prev_colon_joint {
prev_is_colon_colon = true;
prev_colon_joint = false;
} else if punct.spacing() == proc_macro2::Spacing::Joint {
prev_colon_joint = true;
prev_is_colon_colon = false;
} else {
prev_colon_joint = false;
prev_is_colon_colon = false;
}
} else {
prev_colon_joint = false;
prev_is_colon_colon = false;
}
},
proc_macro2::TokenTree::Group(ref group) => {
collect_bare_refs_from_tokens(&group.stream(), offsets, imported_names, references);
prev_is_colon_colon = false;
},
proc_macro2::TokenTree::Literal(_) => {
prev_colon_joint = false;
prev_is_colon_colon = false;
},
}
}
}
fn line_offsets(text: &str) -> Vec<usize> {
let mut offsets = vec![0];
for (idx, ch) in text.char_indices() {
if ch == '\n' {
offsets.push(idx + 1);
}
}
offsets
}
fn offset(line_offsets: &[usize], position: LineColumn) -> usize {
line_offsets
.get(position.line.saturating_sub(1))
.copied()
.unwrap_or(0)
+ position.column
}
#[cfg(test)]
#[allow(
clippy::expect_used,
reason = "tests should panic on unexpected values"
)]
mod tests {
use std::collections::BTreeSet;
use super::collect_bare_refs_from_tokens;
use super::is_snake_case_function_name;
use super::line_offsets;
#[test]
fn snake_case_detects_functions() {
assert!(is_snake_case_function_name("do_thing"));
assert!(is_snake_case_function_name("func_a"));
assert!(is_snake_case_function_name("process_data"));
assert!(is_snake_case_function_name("a"));
}
#[test]
fn snake_case_rejects_types() {
assert!(!is_snake_case_function_name("MyType"));
assert!(!is_snake_case_function_name("Thing"));
assert!(!is_snake_case_function_name("PublicContainer"));
}
#[test]
fn snake_case_rejects_constants() {
assert!(!is_snake_case_function_name("MAX_SIZE"));
assert!(!is_snake_case_function_name("DEFAULT_PORT"));
}
#[test]
fn snake_case_rejects_empty() {
assert!(!is_snake_case_function_name(""));
}
#[test]
fn collect_bare_refs_finds_ident_in_macro_tokens() {
let src = r"matches!(do_thing(x), MyEnum::Variant)";
let offsets = line_offsets(src);
let mut names = BTreeSet::new();
names.insert("do_thing".to_string());
let tokens: proc_macro2::TokenStream = src.parse().expect("parse tokens");
let mut refs = Vec::new();
collect_bare_refs_from_tokens(&tokens, &offsets, &names, &mut refs);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].name, "do_thing");
assert_eq!(&src[refs[0].byte_start..refs[0].byte_end], "do_thing");
}
#[test]
fn collect_bare_refs_skips_qualified_ident_in_macro_tokens() {
let src = r"matches!(module::do_thing(x), MyEnum::Variant)";
let offsets = line_offsets(src);
let mut names = BTreeSet::new();
names.insert("do_thing".to_string());
let tokens: proc_macro2::TokenStream = src.parse().expect("parse tokens");
let mut refs = Vec::new();
collect_bare_refs_from_tokens(&tokens, &offsets, &names, &mut refs);
assert!(refs.is_empty(), "qualified path should not match");
}
#[test]
fn collect_bare_refs_finds_nested_in_group() {
let src = r"assert!(do_thing(foo(bar())))";
let offsets = line_offsets(src);
let mut names = BTreeSet::new();
names.insert("do_thing".to_string());
let tokens: proc_macro2::TokenStream = src.parse().expect("parse tokens");
let mut refs = Vec::new();
collect_bare_refs_from_tokens(&tokens, &offsets, &names, &mut refs);
assert_eq!(refs.len(), 1);
assert_eq!(refs[0].name, "do_thing");
}
}