slopguard-core 0.1.31

Core analysis engine for slopguard
Documentation
use std::fs;
use std::path::Path;

use ast_grep_core::tree_sitter::LanguageExt;
use ast_grep_language::SupportLang;
use tempfile::{tempdir, TempDir};

use super::{evaluate_file, extract_imports, ManifestResolver, ResolutionKind};
use crate::finding::Finding;
use crate::rule::{parse_rule, Language, Rule};

const RUST_RULE: &str = r#"
id: unresolved-import
language: rust
severity: error
category: correctness
resolution: unresolved_import
message: "Unresolved import '$import'."
"#;

const TS_RULE: &str = r#"
id: unresolved-import
language: typescript
severity: error
category: correctness
resolution: unresolved_import
message: "Unresolved import '$import'."
"#;

fn rust_rule() -> Rule {
    parse_rule(RUST_RULE).unwrap()
}

fn ts_rule() -> Rule {
    parse_rule(TS_RULE).unwrap()
}

/// Write `source` to `dir/name` and resolve its imports for `lang`.
fn findings(dir: &Path, name: &str, source: &str, lang: Language, rule: &Rule) -> Vec<Finding> {
    let path = dir.join(name);
    fs::write(&path, source).unwrap();
    let ast_lang = match lang {
        Language::Rust => SupportLang::Rust,
        Language::TypeScript => SupportLang::TypeScript,
    };
    let root = ast_lang.ast_grep(source);
    let imports = extract_imports(&lang, &root);
    let mut manifests = ManifestResolver::new();
    evaluate_file(
        rule,
        ResolutionKind::UnresolvedImport,
        &lang,
        &path,
        &imports,
        &mut manifests,
    )
}

fn specifiers(findings: &[Finding]) -> Vec<String> {
    findings.iter().map(|f| f.matched_text.clone()).collect()
}

fn write_cargo(dir: &Path, body: &str) {
    fs::write(dir.join("Cargo.toml"), body).unwrap();
}

fn write_package_json(dir: &Path, body: &str) {
    fs::write(dir.join("package.json"), body).unwrap();
}

fn project() -> TempDir {
    tempdir().unwrap()
}

// Row 1: a bare TS import of a package absent from package.json is a finding on
// its import line.
#[test]
fn ts_missing_package_is_a_finding() {
    let dir = project();
    write_package_json(dir.path(), r#"{ "dependencies": { "react": "18" } }"#);
    let found = findings(
        dir.path(),
        "a.ts",
        "import { thing } from \"totally-made-up\";\n",
        Language::TypeScript,
        &ts_rule(),
    );
    assert_eq!(found.len(), 1, "got: {found:?}");
    assert_eq!(found[0].line, 1);
    assert!(found[0].message.contains("totally-made-up"));
}

// Row 2: a bare TS import present in deps/devDeps is not a finding.
#[test]
fn ts_declared_package_resolves() {
    let dir = project();
    write_package_json(
        dir.path(),
        r#"{ "dependencies": { "react": "18" }, "devDependencies": { "vitest": "1" } }"#,
    );
    let found = findings(
        dir.path(),
        "a.ts",
        "import React from \"react\";\nimport { test } from \"vitest\";\nimport sub from \"react/jsx-runtime\";\n",
        Language::TypeScript,
        &ts_rule(),
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}

// Row 3: a relative import resolves when the target exists, is a finding when it
// does not.
#[test]
fn ts_relative_import_existing_and_missing() {
    let dir = project();
    write_package_json(dir.path(), r#"{ "dependencies": {} }"#);
    fs::write(dir.path().join("helper.ts"), "export const x = 1;\n").unwrap();
    fs::create_dir(dir.path().join("mod")).unwrap();
    fs::write(dir.path().join("mod/index.ts"), "export const y = 2;\n").unwrap();

    let found = findings(
        dir.path(),
        "a.ts",
        "import { x } from \"./helper\";\nimport { y } from \"./mod\";\nimport { z } from \"./ghost\";\n",
        Language::TypeScript,
        &ts_rule(),
    );
    assert_eq!(found.len(), 1, "got: {:?}", specifiers(&found));
    assert!(found[0].message.contains("./ghost"));
}

// Row 4: Node builtins, with and without the `node:` prefix, resolve.
#[test]
fn ts_node_builtins_resolve() {
    let dir = project();
    write_package_json(dir.path(), r#"{ "dependencies": {} }"#);
    let found = findings(
        dir.path(),
        "a.ts",
        "import fs from \"fs\";\nimport { join } from \"node:path\";\nconst os = require(\"os\");\n",
        Language::TypeScript,
        &ts_rule(),
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}

// Row 5 (negative): a statement-level type-only import is never a finding, even
// when the module is absent from package.json.
#[test]
fn ts_type_only_import_is_ignored() {
    let dir = project();
    write_package_json(dir.path(), r#"{ "dependencies": {} }"#);
    let found = findings(
        dir.path(),
        "a.ts",
        "import type { Foo } from \"missing-types-pkg\";\n",
        Language::TypeScript,
        &ts_rule(),
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}

// Row 6 (negative): a specifier matching a tsconfig `paths` alias resolves.
#[test]
fn ts_tsconfig_alias_resolves() {
    let dir = project();
    write_package_json(dir.path(), r#"{ "dependencies": {} }"#);
    fs::write(
        dir.path().join("tsconfig.json"),
        r#"{
  // aliases
  "compilerOptions": {
    "paths": {
      "@app/*": ["src/*"],
      "@config": ["src/config.ts"],
    },
  },
}"#,
    )
    .unwrap();
    let found = findings(
        dir.path(),
        "a.ts",
        "import { Button } from \"@app/ui/button\";\nimport cfg from \"@config\";\n",
        Language::TypeScript,
        &ts_rule(),
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}

// Row 7: a Rust `use` of a crate absent from Cargo.toml is a finding.
#[test]
fn rust_missing_crate_is_a_finding() {
    let dir = project();
    write_cargo(
        dir.path(),
        "[package]\nname = \"demo\"\n[dependencies]\nserde = \"1\"\n",
    );
    let found = findings(
        dir.path(),
        "a.rs",
        "use made_up_crate::Thing;\n",
        Language::Rust,
        &rust_rule(),
    );
    assert_eq!(found.len(), 1, "got: {:?}", specifiers(&found));
    assert_eq!(found[0].line, 1);
    assert!(found[0].message.contains("made_up_crate"));
}

// Row 8: std family, module keywords and a declared dependency all resolve.
// Hyphenated dependency keys are matched in their `use` form.
#[test]
fn rust_std_keywords_and_deps_resolve() {
    let dir = project();
    write_cargo(
        dir.path(),
        "[package]\nname = \"demo\"\n[dependencies]\nserde = \"1\"\nast-grep-core = \"0.45\"\n",
    );
    let found = findings(
        dir.path(),
        "a.rs",
        "use std::fmt;\nuse core::mem;\nuse alloc::vec::Vec;\nuse crate::foo::Bar;\nuse super::baz;\nuse serde::Serialize;\nuse ast_grep_core::AstGrep;\nuse demo::internal;\n",
        Language::Rust,
        &rust_rule(),
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}

// Row 9 (negative workspace): a dependency declared only in the root
// `[workspace.dependencies]` resolves for a member crate.
#[test]
fn rust_workspace_dependency_resolves() {
    let root = project();
    write_cargo(
        root.path(),
        "[workspace]\nmembers = [\"member\"]\n[workspace.dependencies]\ntokio = \"1\"\n",
    );
    let member = root.path().join("member");
    fs::create_dir(&member).unwrap();
    write_cargo(
        &member,
        "[package]\nname = \"member\"\n[dependencies]\ntokio = { workspace = true }\n",
    );
    let src = member.join("src");
    fs::create_dir(&src).unwrap();
    let found = findings(
        &src,
        "lib.rs",
        "use tokio::spawn;\n",
        Language::Rust,
        &rust_rule(),
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}

// Row 10: a bare `use foo::...` referencing a module declared in the same file
// resolves.
#[test]
fn rust_local_module_resolves() {
    let dir = project();
    write_cargo(dir.path(), "[package]\nname = \"demo\"\n[dependencies]\n");
    let found = findings(
        dir.path(),
        "a.rs",
        "mod helpers;\nuse helpers::compute;\n",
        Language::Rust,
        &rust_rule(),
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}

/// Resolve `source` as `a.rs` in a crate that declares no dependency.
fn rust_findings_without_deps(source: &str) -> Vec<Finding> {
    let dir = project();
    write_cargo(dir.path(), "[package]\nname = \"demo\"\n[dependencies]\n");
    findings(dir.path(), "a.rs", source, Language::Rust, &rust_rule())
}

// Benchmark regression (tokio, cargo): a root bound by another `use` of the
// same file, by its last segment, a `self` in a list, or an alias.
#[test]
fn rust_name_bound_by_another_use_resolves() {
    let found = rust_findings_without_deps(
        "use crate::runtime::scheduler::{self, Defer};\n\
         use crate::sync::mpsc::chan;\n\
         use crate::a::b as renamed;\n\
         fn f() {\n    use scheduler::Context;\n    use chan::Semaphore;\n    use renamed::X;\n}\n",
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}

// Benchmark regression (cargo, serde): an uppercase root names an item in
// scope, typically an enum whose variants are imported, never a crate.
#[test]
fn rust_uppercase_root_resolves() {
    let found = rust_findings_without_deps(
        "enum Edition { A, B }\nfn f() {\n    use Edition::*;\n    use Imported::Variant;\n}\n",
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}

// Benchmark regression (tokio): a module declared inside a macro body is a
// token tree for tree-sitter, not a `mod_item`, but still binds the name.
#[test]
fn rust_module_declared_in_macro_resolves() {
    let found = rust_findings_without_deps(
        "cfg_if! {\n    if #[cfg(unix)] {\n        mod imp;\n    }\n}\npub(crate) use imp::AtomicU64;\n",
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}

// Benchmark regression (axum): a non-std glob may bring the root into scope,
// so the imports of that module are not asserted. The glob stays scoped: the
// file root, which has none, is still checked.
#[test]
fn rust_local_glob_silences_only_its_module() {
    let found = rust_findings_without_deps(
        "use made_up_crate::Thing;\n\
         mod tests {\n    use super::*;\n    use body::Body;\n}\n",
    );
    assert_eq!(specifiers(&found), vec!["use made_up_crate::Thing;"]);
}

// A std glob only brings standard items into scope: it does not silence the
// module, so an invented crate next to it is still reported.
#[test]
fn rust_std_glob_does_not_silence() {
    let found = rust_findings_without_deps("use std::io::prelude::*;\nuse made_up_crate::Thing;\n");
    assert_eq!(specifiers(&found), vec!["use made_up_crate::Thing;"]);
}

// A single-segment `use` binds the very name it imports: that name must not
// vouch for itself, and a later path from it is still checked.
#[test]
fn rust_name_does_not_vouch_for_its_own_root() {
    let found = rust_findings_without_deps("use made_up_crate;\nuse made_up_crate::Thing;\n");
    assert_eq!(found.len(), 2, "got: {:?}", specifiers(&found));
}

// No manifest reachable: nothing can be asserted, so no finding.
#[test]
fn no_manifest_reports_nothing() {
    let dir = project();
    let found = findings(
        dir.path(),
        "a.ts",
        "import x from \"whatever\";\n",
        Language::TypeScript,
        &ts_rule(),
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}

// A file with no imports produces no findings.
#[test]
fn empty_file_reports_nothing() {
    let dir = project();
    write_package_json(dir.path(), r#"{ "dependencies": {} }"#);
    let found = findings(
        dir.path(),
        "a.ts",
        "export const x = 1;\n",
        Language::TypeScript,
        &ts_rule(),
    );
    assert!(found.is_empty(), "got: {:?}", specifiers(&found));
}