jlint 0.1.0

A command-line linter for Rust projects that flags noisy qualified paths and blank lines between imports
Documentation
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!`."),
  ("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::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."),
  ("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::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::oneshot", "The `oneshot` namespace is enough to distinguish this channel family. Import `oneshot`; the `tokio::sync` prefix adds no useful context."),
  ("tokio::sync::broadcast::channel", "The `broadcast` namespace is enough context at this call site. Import `broadcast` instead of repeating `tokio::sync`."),
  ("tokio::sync::watch::channel", "The `watch` namespace is enough context at this call site. Import `watch` instead of repeating `tokio::sync`."),
  ("base64::engine::general_purpose::STANDARD", "The base64 namespace is not useful at every use of this well-known constant. Import `STANDARD`."),
  ("std::collections::HashMap", "`HashMap` is clear without its standard-library prefix. Import it; long qualification makes ordinary collection code noisier."),
  ("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."),
];

pub struct LintResult {
  pub errors: usize,
  pub warnings: usize,
}

pub fn lint(root: &Path) -> LintResult {
  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;

  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.find_iter(line) {
          println!(
            "{relative}:{}: warning: {}",
            line_number + 1,
            matched.as_str().trim()
          );
          println!("  Why: This qualified path may be longer than necessary. Consider importing part of it.");
          warnings += 1;
        }
      }
    }
  }
  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]);
  }
}