jlint 0.4.1

A command-line linter for Rust projects that flags noisy qualified paths and blank lines between imports
Documentation
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"));
  }
}