use std::collections::{BTreeMap, BTreeSet};
use std::fs;
use std::path::{Path, PathBuf};
use syn::spanned::Spanned;
use syn::visit::Visit;
use syn::{File, ItemUse, Macro, Path as SynPath, UseTree};
#[derive(Default)]
struct QualifiedPathVisitor {
paths: Vec<SynPath>,
}
#[derive(Default)]
struct ImportVisitor {
imports: Vec<(String, String)>,
}
fn is_type_like(ident: &syn::Ident) -> bool {
ident
.to_string()
.chars()
.next()
.is_some_and(char::is_uppercase)
}
impl<'ast> Visit<'ast> for ImportVisitor {
fn visit_item_use(&mut self, item: &'ast ItemUse) {
collect_imports(&item.tree, &[], &mut self.imports);
syn::visit::visit_item_use(self, item);
}
}
impl<'ast> Visit<'ast> for QualifiedPathVisitor {
fn visit_item_use(&mut self, _: &'ast ItemUse) {}
fn visit_path(&mut self, path: &'ast SynPath) {
if path.segments.len() >= 3
&& path
.segments
.iter()
.any(|segment| is_type_like(&segment.ident))
{
self.paths.push(path.clone());
}
syn::visit::visit_path(self, path);
}
fn visit_macro(&mut self, macro_node: &'ast Macro) {
if macro_node.path.segments.len() >= 3
&& macro_node
.path
.segments
.iter()
.any(|segment| is_type_like(&segment.ident))
{
self.paths.push(macro_node.path.clone());
}
}
}
pub struct LintResult {
pub errors: usize,
pub warnings: usize,
}
#[derive(Default)]
pub struct LintOptions {
pub no_banner: bool,
}
pub fn lint(root: &Path) -> LintResult {
lint_with_options(root, &LintOptions::default())
}
pub fn lint_with_options(
root: &Path,
options: &LintOptions,
) -> LintResult {
if !options.no_banner {
println!("Running lint...");
}
let mut errors = 0;
let warnings = 0;
let mut warning_locations =
BTreeMap::<String, BTreeMap<String, Vec<usize>>>::new();
for path in rust_files(root) {
let Ok(text) = fs::read_to_string(&path) else {
continue;
};
let relative = path.strip_prefix(root).unwrap_or(&path).display();
for line_number in import_spacing_violations(&text) {
println!(
"{relative}:{line_number}: error: blank line between imports"
);
println!(" Why: Keep all imports in one contiguous group.");
println!(" Fix: remove the blank line between the imports.");
errors += 1;
}
let Ok(syntax) = syn::parse_file(&text) else {
continue;
};
let ambiguous_imports = ambiguous_imports(&syntax);
for (path, line_number) in qualified_paths(&syntax) {
if path.segments.last().is_some_and(|segment| {
ambiguous_imports.contains(&segment.ident.to_string())
}) {
let name = path
.segments
.last()
.map(|segment| segment.ident.to_string())
.unwrap_or_default();
println!(
"warn: {relative}:{line_number}: skipped ambiguous import `{name}`"
);
continue;
}
let qualified_path = path
.segments
.iter()
.map(|segment| segment.ident.to_string())
.collect::<Vec<_>>()
.join("::");
warning_locations
.entry(relative.to_string())
.or_default()
.entry(qualified_path)
.or_default()
.push(line_number);
errors += 1;
}
}
for (file, paths) in warning_locations {
println!("error: {file}");
for (qualified_path, line_numbers) in paths {
let line_numbers = line_numbers
.into_iter()
.map(|line_number| line_number.to_string())
.collect::<Vec<_>>()
.join(", ");
println!(" {line_numbers} {qualified_path}");
}
}
if errors > 0 {
println!("Why: Qualified paths may be longer than necessary. Consider importing part of the path.");
} else if !options.no_banner {
println!("Lint passed.");
}
LintResult { errors, warnings }
}
fn collect_imports(
tree: &UseTree,
prefix: &[String],
imports: &mut Vec<(String, String)>,
) {
match tree {
UseTree::Path(path) => {
let mut prefix = prefix.to_vec();
prefix.push(path.ident.to_string());
collect_imports(&path.tree, &prefix, imports);
}
UseTree::Name(name) => {
let mut source = prefix.to_vec();
source.push(name.ident.to_string());
imports.push((name.ident.to_string(), source.join("::")));
}
UseTree::Rename(rename) => {
let mut source = prefix.to_vec();
source.push(rename.ident.to_string());
imports.push((rename.rename.to_string(), source.join("::")));
}
UseTree::Group(group) => {
for tree in &group.items {
collect_imports(tree, prefix, imports);
}
}
UseTree::Glob(_) => {}
}
}
fn ambiguous_imports(file: &File) -> BTreeSet<String> {
let mut visitor = ImportVisitor::default();
visitor.visit_file(file);
let mut sources = BTreeMap::<String, BTreeSet<String>>::new();
for (name, source) in visitor.imports {
sources.entry(name).or_default().insert(source);
}
sources
.into_iter()
.filter_map(|(name, sources)| (sources.len() > 1).then_some(name))
.collect()
}
fn qualified_paths(file: &File) -> Vec<(SynPath, usize)> {
let mut visitor = QualifiedPathVisitor::default();
visitor.visit_file(file);
visitor
.paths
.into_iter()
.map(|path| {
let line_number = path.span().start().line;
(path, line_number)
})
.collect()
}
fn rust_files(root: &Path) -> impl Iterator<Item = PathBuf> {
let mut files = Vec::new();
collect_rust_files(root, root, &mut files);
files.into_iter()
}
fn collect_rust_files(
root: &Path,
directory: &Path,
files: &mut Vec<PathBuf>,
) {
let Ok(entries) = fs::read_dir(directory) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
let relative = path.strip_prefix(root).unwrap_or(&path);
if relative.components().any(|component| {
matches!(
component.as_os_str().to_str(),
Some(".git" | "target" | "tmp" | "web" | "scripts")
)
}) {
continue;
}
if path.is_dir() {
collect_rust_files(root, &path, files);
} else if path
.extension()
.is_some_and(|extension| extension == "rs")
{
files.push(path);
}
}
}
fn is_import_start(line: &str) -> bool {
let line = line.trim_start();
line.starts_with("use ")
|| line.starts_with("pub use ")
|| line.starts_with("pub(crate) use ")
|| line.starts_with("pub(super) use ")
}
fn import_spacing_violations(text: &str) -> Vec<usize> {
let lines: Vec<_> = text.lines().collect();
let mut violations = Vec::new();
let mut import_group = false;
let mut import_statement_open = false;
for (index, line) in lines.iter().enumerate() {
if line.trim().is_empty() {
if import_group
&& !import_statement_open
&& lines[index + 1..]
.iter()
.find(|next| !next.trim().is_empty())
.is_some_and(|next| is_import_start(next))
{
violations.push(index + 1);
}
continue;
}
if !import_statement_open && !is_import_start(line) {
import_group = false;
continue;
}
import_group = true;
import_statement_open = !line.trim_end().ends_with(';');
}
violations
}
#[cfg(test)]
mod tests {
use super::{
ambiguous_imports, import_spacing_violations, qualified_paths,
};
#[test]
fn detects_blank_lines_between_import_groups() {
let source = "use std::sync::Arc;\n\nuse anyhow::Result;\n";
assert_eq!(import_spacing_violations(source), vec![2]);
}
#[test]
fn ignores_blank_lines_after_imports() {
let source = "use std::sync::Arc;\n\nfn main() {}\n";
assert!(import_spacing_violations(source).is_empty());
}
#[test]
fn handles_multiline_imports() {
let source = "use crate::{\n Foo,\n};\n\nuse std::sync::Arc;\n";
assert_eq!(import_spacing_violations(source), vec![4]);
}
#[test]
fn finds_qualified_paths_with_spans() {
let file = syn::parse_file(
"fn main() { std::fs::File::create(\"file\"); }",
)
.unwrap();
let paths = qualified_paths(&file);
assert_eq!(paths.len(), 1);
assert_eq!(paths[0].0.segments.last().unwrap().ident, "create");
assert_eq!(paths[0].1, 1);
}
#[test]
fn ignores_comments_strings_and_imports() {
let file = syn::parse_file(
"use std::fs::File;\n// std::fs::File::create\nfn main() { let _ = \"std::fs::File::create\"; }",
)
.unwrap();
assert!(qualified_paths(&file).is_empty());
}
#[test]
fn ignores_qualified_function_paths() {
let file = syn::parse_file(
"fn main() { actix_web::rt::spawn(async {}); actix_web::web::get(); }",
)
.unwrap();
assert!(qualified_paths(&file).is_empty());
}
#[test]
fn finds_qualified_type_paths() {
let file = syn::parse_file(
"fn main() { actix_web::App::new(); ahp::Status::Idle; }",
)
.unwrap();
let paths = qualified_paths(&file);
assert_eq!(paths.len(), 2);
}
#[test]
fn finds_ambiguous_imports() {
let file = syn::parse_file(
"use std::sync::Mutex; use tokio::sync::Mutex;",
)
.unwrap();
assert!(ambiguous_imports(&file).contains("Mutex"));
}
#[test]
fn ignores_aliased_imports_when_finding_ambiguity() {
let file = syn::parse_file(
"use std::sync::Mutex; use tokio::sync::Mutex as TokioMutex;",
)
.unwrap();
assert!(!ambiguous_imports(&file).contains("Mutex"));
}
}