jlint 0.3.0

A command-line linter for Rust projects that flags noisy qualified paths and blank lines between imports
Documentation
use std::collections::BTreeMap;
use std::fs;
use std::path::{Path, PathBuf};

const QUALIFIED_PATH_RULES: &[(&str, &str)] = &[
  ("anyhow::anyhow!", "This adds the crate name to a macro whose short name is already clear. Import it and use `anyhow!`."),
  ("base64::engine::general_purpose::STANDARD", "The base64 namespace is not useful at every use of this well-known constant. Import `STANDARD`."),
  ("std::cmp::Ordering", "`Ordering` provides the useful context, while the standard-library prefix does not. Import it unless another `Ordering` in this scope makes qualification genuinely helpful."),
  ("std::collections::HashMap", "`HashMap` is clear without its standard-library prefix. Import it; long qualification makes ordinary collection code noisier."),
  ("std::collections::hash_map::Iter", "Import `Iter` instead of repeating the standard-library collection path."),
  ("std::ffi::OsString::from_vec", "Import `OsString` and use `OsString::from_vec`; the full standard-library path is unnecessary here."),
  ("std::fs::File::create", "Import `File` and use `File::create`; the full standard-library path is unnecessary here."),
  ("std::fs::File::from_raw_fd", "Import `File` and use `File::from_raw_fd`; the full standard-library path is unnecessary here."),
  ("std::io::Error::last_os_error", "Import `Error` and use `Error::last_os_error`; the full standard-library path is unnecessary here."),
  ("std::io::ErrorKind::WouldBlock", "Import `ErrorKind` and use `ErrorKind::WouldBlock`; the full standard-library path is unnecessary here."),
  ("std::os::unix::fs::symlink", "Import `symlink` instead of repeating the standard-library path."),
  ("std::os::windows::fs::symlink_file", "Import `symlink_file` instead of repeating the standard-library path."),
  ("std::path::Path", "`Path` is already descriptive at the use site. Import it instead of repeating the standard-library path."),
  ("std::path::PathBuf", "`PathBuf` is already descriptive at the use site. Import it instead of repeating the standard-library path."),
  ("std::sync::Arc", "`Arc` is clear without its standard-library prefix. Import it; qualify it only when distinguishing competing `Arc` types is useful."),
  ("std::sync::Mutex", "This is a judgment call: qualification can distinguish standard and Tokio mutexes, but here the path is noise. Import `Mutex` when the surrounding context is unambiguous."),
  ("std::sync::RwLock", "This is a judgment call: qualification can distinguish standard and Tokio locks, but here the path is noise. Import `RwLock` when the surrounding context is unambiguous."),
  ("std::sync::atomic::AtomicU64", "`AtomicU64` is already specific and does not need the full standard-library path. Import it."),
  ("std::sync::atomic::Ordering", "`Ordering` is the useful part of this path. Import it unless another `Ordering` in the scope makes qualification helpful."),
  ("std::time::Instant::now", "Import `Instant` and use `Instant::now`; the full standard-library path is unnecessary here."),
  ("std::time::SystemTime::now", "Import `SystemTime` and use `SystemTime::now`; the full standard-library path is unnecessary here."),
  ("tokio::fs::OpenOptions::new", "Import `OpenOptions` and use `OpenOptions::new`; the full Tokio path is unnecessary here."),
  ("tokio::net::TcpListener::bind", "Import `TcpListener` and use `TcpListener::bind`; the full Tokio path is unnecessary here."),
  ("tokio::net::TcpStream::connect", "Import `TcpStream` and use `TcpStream::connect`; the full Tokio path is unnecessary here."),
  ("tokio::runtime::Builder::new_current_thread", "Import `Builder` and use `Builder::new_current_thread`; the full Tokio path is unnecessary here."),
  ("tokio::sync::Mutex::new", "Import `Mutex` and use `Mutex::new`; the full Tokio path is unnecessary here."),
  ("tokio::sync::RwLock::new", "Import `RwLock` and use `RwLock::new`; the full Tokio path is unnecessary here."),
  ("tokio::sync::broadcast::channel", "The `broadcast` namespace is enough context at this call site. Import `broadcast` instead of repeating `tokio::sync`."),
  ("tokio::sync::mpsc::Receiver", "The `mpsc` namespace distinguishes this from other `Receiver` types. Keep `mpsc`, but do not repeat the whole `tokio::sync` path."),
  ("tokio::sync::mpsc::Sender", "The `mpsc` namespace distinguishes this from other `Sender` types. Keep `mpsc`, but do not repeat the whole `tokio::sync` path."),
  ("tokio::sync::mpsc::UnboundedReceiver", "The `mpsc` namespace distinguishes this from other channel types. Keep `mpsc`, but do not repeat the whole `tokio::sync` path."),
  ("tokio::sync::mpsc::channel", "Import `mpsc` and use `mpsc::channel`; the full Tokio path is unnecessary here."),
  ("tokio::sync::oneshot", "The `oneshot` namespace is enough to distinguish this channel family. Import `oneshot`; the `tokio::sync` prefix adds no useful context."),
  ("tokio::sync::watch::channel", "The `watch` namespace is enough context at this call site. Import `watch` instead of repeating `tokio::sync`."),
  ("tokio::time::Instant::now", "Import `Instant` and use `Instant::now`; the full Tokio path is unnecessary here."),
];

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 generic_path = regex::Regex::new(r"(?:^|[^A-Za-z0-9_:])((?:[A-Za-z_][A-Za-z0-9_]*::){3,}[A-Za-z_][A-Za-z0-9_]*)").expect("valid qualified path regex");
  let mut errors = 0;
  let mut warnings = 0;
  let mut warning_locations = BTreeMap::<String, Vec<String>>::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;
    }
    for (line_number, line) in text.lines().enumerate() {
      let stripped = line.trim_start();
      if stripped.starts_with("//")
        || stripped.starts_with("use ")
        || stripped.starts_with("pub use ")
      {
        continue;
      }
      let mut exact_match = false;
      for (pattern, explanation) in QUALIFIED_PATH_RULES {
        if line.contains(pattern) {
          println!(
            "{relative}:{}: error: {pattern}",
            line_number + 1
          );
          println!("  Why: {explanation}");
          println!("  Fix: import the relevant type, macro, or short namespace");
          errors += 1;
          exact_match = true;
        }
      }
      if !exact_match {
        for matched in generic_path.captures_iter(line) {
          let qualified_path = matched[1].to_owned();
          warning_locations
            .entry(qualified_path)
            .or_default()
            .push(format!("{relative}:{}", line_number + 1));
          warnings += 1;
        }
      }
    }
  }
  for (qualified_path, locations) in warning_locations {
    if locations.len() == 1 {
      println!("warning: {qualified_path}");
    } else {
      println!(
        "warning: {qualified_path} ({} occurrences)",
        locations.len()
      );
    }
    println!("  {}", locations.join(", "));
  }
  if warnings > 0 {
    println!("Why: Qualified paths may be longer than necessary. Consider importing part of the path.");
  }
  LintResult { errors, warnings }
}

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::import_spacing_violations;

  #[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]);
  }
}