cargo-mend 0.16.1

Opinionated visibility auditing for Rust crates and workspaces
use std::ffi::OsStr;
use std::fs;
use std::path::Path;

use anyhow::Context;
use anyhow::Result;
use syn::ItemMod;
use syn::ItemUse;
use syn::parse_file;
use syn::spanned::Spanned;
use syn::visit::Visit;
use walkdir::WalkDir;

use super::ImportScan;
use super::UseFix;
use super::ValidatedFixSet;
use super::path as import_path;
use crate::compiler::SOURCE_DIR_SRC;
use crate::config::DiagnosticCode;
use crate::reporting::Finding;
use crate::reporting::FixSupport;
use crate::reporting::Severity;
use crate::rust_syntax;
use crate::selection::Selection;

#[derive(Debug, Clone)]
struct ShortenImportFact {
    diagnostic_code: DiagnosticCode,
    message:         &'static str,
    path:            String,
    line:            usize,
    column:          usize,
    highlight_len:   usize,
    source_line:     String,
    replacement:     String,
}

#[derive(Debug)]
struct ImportFinding {
    shorten_import_fact: ShortenImportFact,
    use_fix:             UseFix,
}

impl From<ShortenImportFact> for Finding {
    fn from(fact: ShortenImportFact) -> Self {
        let replacement = fact.replacement;
        Self {
            severity:        Severity::Warning,
            diagnostic_code: fact.diagnostic_code,
            path:            fact.path,
            line:            fact.line,
            column:          fact.column,
            highlight_len:   fact.highlight_len,
            source_line:     fact.source_line,
            item:            None,
            message:         fact.message.to_string(),
            suggestion:      Some(format!("consider using: `{replacement}`")),
            fix_support:     FixSupport::ShortenImport,
            related:         None,
        }
    }
}

pub fn scan_selection(selection: &Selection) -> Result<ImportScan> {
    let findings_with_fixes = scan_selection_with_fixes(selection)?;
    let fixes = ValidatedFixSet::try_from(
        findings_with_fixes
            .iter()
            .map(|finding| finding.use_fix.clone())
            .collect::<Vec<_>>(),
    )?;
    Ok(ImportScan {
        findings: findings_with_fixes
            .iter()
            .map(|finding| Finding::from(finding.shorten_import_fact.clone()))
            .collect(),
        fixes,
    })
}

fn scan_selection_with_fixes(selection: &Selection) -> Result<Vec<ImportFinding>> {
    let mut findings = Vec::new();
    for package_root in &selection.package_roots {
        let source_root = package_root.join(SOURCE_DIR_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(OsStr::to_str) != Some("rs")
            {
                continue;
            }
            findings.extend(scan_file(
                selection.analysis_root.as_path(),
                &source_root,
                path,
            )?);
        }
    }
    findings.sort_by(|a, b| {
        (
            &a.shorten_import_fact.path,
            a.shorten_import_fact.line,
            a.shorten_import_fact.column,
            a.shorten_import_fact.diagnostic_code,
        )
            .cmp(&(
                &b.shorten_import_fact.path,
                b.shorten_import_fact.line,
                b.shorten_import_fact.column,
                b.shorten_import_fact.diagnostic_code,
            ))
    });
    findings.dedup_by(|a, b| {
        a.shorten_import_fact.path == b.shorten_import_fact.path
            && a.shorten_import_fact.line == b.shorten_import_fact.line
            && a.shorten_import_fact.column == b.shorten_import_fact.column
    });
    Ok(findings)
}

fn scan_file(analysis_root: &Path, source_root: &Path, path: &Path) -> Result<Vec<ImportFinding>> {
    let text =
        fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?;
    let syntax =
        parse_file(&text).with_context(|| format!("failed to parse {}", path.display()))?;
    let base_module_path = rust_syntax::file_module_path(source_root, path)
        .with_context(|| format!("failed to determine module path for {}", path.display()))?;
    let offsets = import_path::line_offsets(&text);
    let mut visitor = UseVisitor {
        analysis_root,
        path,
        text: &text,
        offsets: &offsets,
        current_module_path: base_module_path,
        findings: Vec::new(),
    };
    visitor.visit_file(&syntax);
    Ok(visitor.findings)
}

struct UseVisitor<'a> {
    analysis_root:       &'a Path,
    path:                &'a Path,
    text:                &'a str,
    offsets:             &'a [usize],
    current_module_path: Vec<String>,
    findings:            Vec<ImportFinding>,
}

impl Visit<'_> for UseVisitor<'_> {
    fn visit_item_mod(&mut self, node: &ItemMod) {
        if let Some((_, items)) = &node.content {
            self.current_module_path.push(node.ident.to_string());
            for item in items {
                self.visit_item(item);
            }
            self.current_module_path.pop();
        }
    }

    fn visit_item_use(&mut self, node: &ItemUse) {
        let candidate = import_path::analyze_use_tree(&self.current_module_path, &node.tree)
            .or_else(|| import_path::analyze_deep_super(&self.current_module_path, &node.tree));
        if let Some(candidate) = candidate {
            let span = node.span();
            let start = span.start();
            let end = span.end();
            let start_offset = import_path::offset(self.offsets, start);
            let end_offset = import_path::offset(self.offsets, end);
            let original_item = &self.text[start_offset..end_offset];
            let replacement =
                original_item.replacen(&candidate.original, &candidate.replacement, 1);
            let source_line = self
                .text
                .lines()
                .nth(start.line.saturating_sub(1))
                .unwrap_or_default()
                .to_string();
            let display_path = self
                .path
                .strip_prefix(self.analysis_root)
                .unwrap_or(self.path)
                .to_string_lossy()
                .replace('\\', "/");
            self.findings.push(ImportFinding {
                shorten_import_fact: ShortenImportFact {
                    diagnostic_code: candidate.diagnostic_code,
                    message: candidate.message,
                    path: display_path,
                    line: start.line,
                    column: start.column + 1,
                    highlight_len: candidate.original.len().max(1),
                    source_line,
                    replacement: replacement.clone(),
                },
                use_fix:             UseFix {
                    path: self.path.to_path_buf(),
                    start: start_offset,
                    end: end_offset,
                    replacement,
                    import_group: None,
                },
            });
        }
    }
}