devscout-rs 0.10.0

Fast code index for C# and TypeScript codebases -- map, find, refs, impact from the CLI.
use super::*;
use crate::tsgraph::read_ts_resolution;
use std::fs;
use std::path::PathBuf;
use std::sync::atomic::{AtomicU64, Ordering};

static NEXT: AtomicU64 = AtomicU64::new(0);

struct Temp(PathBuf);

impl Temp {
    fn new() -> Self {
        let path = std::env::temp_dir().join(format!(
            "devscout-declared-resolution-{}-{}",
            std::process::id(),
            NEXT.fetch_add(1, Ordering::Relaxed)
        ));
        fs::create_dir_all(&path).unwrap();
        Self(path)
    }
    fn write(&self, name: &str, content: &str) {
        let path = self.0.join(name);
        fs::create_dir_all(path.parent().unwrap()).unwrap();
        fs::write(path, content).unwrap();
    }
}

impl Drop for Temp {
    fn drop(&mut self) {
        fs::remove_dir_all(&self.0).unwrap();
    }
}

fn resolve(root: &Temp, files: &[&str], spec: &str, usage: Usage) -> Resolution {
    let (alias, config) = read_ts_resolution(&root.0, files.iter().copied());
    let file_set: HashSet<&str> = files.iter().copied().collect();
    Resolver {
        files: &file_set,
        alias: &alias,
        config: &config,
    }
    .resolve(files[0], spec, usage)
}

fn target(answer: Resolution) -> Option<String> {
    if let Resolution::Resolved(target) = answer {
        Some(target)
    } else {
        None
    }
}

#[test]
fn module_resolution_ac_1_node16_esm_requires_extension() {
    let root = Temp::new();
    root.write(
        "tsconfig.json",
        r#"{"compilerOptions":{"moduleResolution":"node16"}}"#,
    );
    root.write("package.json", r#"{"type":"module"}"#);
    let files = ["src/use.ts", "src/x.ts"];
    assert!(matches!(
        resolve(&root, &files, "./x", Usage::Import),
        Resolution::Unresolved(Reason::EsmExtensionRequired)
    ));
    assert_eq!(
        target(resolve(&root, &files, "./x.js", Usage::Import)).as_deref(),
        Some("src/x.ts")
    );
}

#[test]
fn module_resolution_ac_1_bundler_allows_extensionless_relative_import() {
    let root = Temp::new();
    root.write(
        "tsconfig.json",
        r#"{"compilerOptions":{"moduleResolution":"bundler"}}"#,
    );
    let files = ["src/use.ts", "src/x.ts"];
    assert_eq!(
        target(resolve(&root, &files, "./x", Usage::Import)).as_deref(),
        Some("src/x.ts")
    );
}

#[test]
fn module_resolution_ac_1_non_esm_modes_probe_dotted_basenames() {
    for (mode, package_type, usage) in [
        ("node10", "module", Usage::Import),
        ("bundler", "module", Usage::Import),
        ("node16", "commonjs", Usage::Require),
    ] {
        let root = Temp::new();
        root.write(
            "tsconfig.json",
            &format!(r#"{{"compilerOptions":{{"moduleResolution":"{mode}"}}}}"#),
        );
        root.write("package.json", &format!(r#"{{"type":"{package_type}"}}"#));
        let files = ["src/use.ts", "src/user.service.ts"];
        assert_eq!(
            target(resolve(&root, &files, "./user.service", usage)).as_deref(),
            Some("src/user.service.ts"),
            "{mode}"
        );
    }
}

#[test]
fn module_resolution_ac_1_esm_alias_does_not_append_extensions_or_probe_directories() {
    let root = Temp::new();
    root.write(
        "tsconfig.json",
        r#"{"compilerOptions":{"moduleResolution":"node16","baseUrl":".","paths":{"@lib/*":["lib/*"]}}}"#,
    );
    root.write("package.json", r#"{"type":"module"}"#);
    let files = ["src/use.ts", "lib/bar.ts", "lib/foo/index.ts", "lib/qux.js"];
    assert!(target(resolve(&root, &files, "@lib/bar", Usage::Import)).is_none());
    assert!(target(resolve(&root, &files, "@lib/foo", Usage::Import)).is_none());
    assert!(target(resolve(&root, &files, "lib/bar", Usage::Import)).is_none());
    assert_eq!(
        target(resolve(&root, &files, "@lib/qux.js", Usage::Import)).as_deref(),
        Some("lib/qux.js")
    );
}

#[test]
fn module_resolution_ac_2_non_esm_paths_probe_directory_indexes() {
    for mode in ["node10", "bundler"] {
        let root = Temp::new();
        root.write(
            "tsconfig.json",
            &format!(
                r#"{{"compilerOptions":{{"moduleResolution":"{mode}","baseUrl":".","paths":{{"@lib/*":["lib/*"]}}}}}}"#
            ),
        );
        let files = ["src/use.ts", "lib/foo/index.ts"];
        assert_eq!(
            target(resolve(&root, &files, "@lib/foo", Usage::Import)).as_deref(),
            Some("lib/foo/index.ts"),
            "{mode}"
        );
        assert_eq!(
            target(resolve(&root, &files, "lib/foo", Usage::Import)).as_deref(),
            Some("lib/foo/index.ts"),
            "{mode}"
        );
    }
}

#[test]
fn module_resolution_ac_2_dotted_alias_and_package_main_probe_sources() {
    let root = Temp::new();
    root.write(
        "tsconfig.json",
        r#"{"compilerOptions":{"moduleResolution":"node10","baseUrl":".","paths":{"@app/*":["lib/*"]}}}"#,
    );
    root.write("package.json", r#"{"workspaces":["packages/*"]}"#);
    root.write(
        "packages/cfg/package.json",
        r#"{"name":"cfg","main":"lib/config.prod"}"#,
    );
    let files = [
        "src/use.ts",
        "lib/user.service.ts",
        "packages/cfg/lib/config.prod.ts",
    ];
    assert_eq!(
        target(resolve(&root, &files, "@app/user.service", Usage::Import)).as_deref(),
        Some("lib/user.service.ts")
    );
    assert_eq!(
        target(resolve(&root, &files, "lib/user.service", Usage::Import)).as_deref(),
        Some("lib/user.service.ts")
    );
    assert_eq!(
        target(resolve(&root, &files, "cfg", Usage::Import)).as_deref(),
        Some("packages/cfg/lib/config.prod.ts")
    );
}

#[test]
fn module_resolution_ac_3_esm_maps_do_not_append_extensions_to_dotted_targets() {
    let root = Temp::new();
    root.write(
        "tsconfig.json",
        r#"{"compilerOptions":{"moduleResolution":"node16"}}"#,
    );
    root.write(
        "package.json",
        r##"{"type":"module","workspaces":["packages/*"],"imports":{"#own":"./src/own.service"}}"##,
    );
    root.write(
        "packages/pkg/package.json",
        r#"{"name":"pkg","type":"module","exports":"./src/user.service"}"#,
    );
    let files = [
        "src/use.ts",
        "src/own.service.ts",
        "packages/pkg/src/user.service.ts",
    ];
    assert!(target(resolve(&root, &files, "#own", Usage::Import)).is_none());
    assert!(target(resolve(&root, &files, "pkg", Usage::Import)).is_none());
}

#[test]
fn module_resolution_ac_3_esm_package_main_can_probe_dotted_targets() {
    let root = Temp::new();
    root.write(
        "tsconfig.json",
        r#"{"compilerOptions":{"moduleResolution":"node16"}}"#,
    );
    root.write(
        "package.json",
        r#"{"type":"module","workspaces":["packages/*"]}"#,
    );
    root.write(
        "packages/pkg/package.json",
        r#"{"name":"pkg","type":"module","main":"./src/user.service"}"#,
    );
    let files = ["src/use.ts", "packages/pkg/src/user.service.ts"];
    assert_eq!(
        target(resolve(&root, &files, "pkg", Usage::Import)).as_deref(),
        Some("packages/pkg/src/user.service.ts")
    );
}

#[test]
fn module_resolution_ac_2_workspace_main_maps_declared_build_output() {
    let root = Temp::new();
    root.write(
        "tsconfig.json",
        r#"{"compilerOptions":{"moduleResolution":"node16"}}"#,
    );
    root.write("package.json", r#"{"workspaces":["packages/*"]}"#);
    root.write(
        "packages/pkg/package.json",
        r#"{"name":"@scope/pkg","main":"dist/index.js"}"#,
    );
    root.write(
        "packages/pkg/tsconfig.json",
        r#"{"compilerOptions":{"outDir":"dist","rootDir":"src"}}"#,
    );
    let files = ["app/use.ts", "packages/pkg/src/index.ts"];
    assert_eq!(
        target(resolve(&root, &files, "@scope/pkg", Usage::Require)).as_deref(),
        Some("packages/pkg/src/index.ts")
    );
}

#[test]
fn module_resolution_ac_3_exports_and_imports_choose_declared_targets() {
    let root = Temp::new();
    root.write(
        "tsconfig.json",
        r#"{"compilerOptions":{"moduleResolution":"node16"}}"#,
    );
    root.write(
        "package.json",
        r##"{"type":"module","workspaces":["packages/*"],"imports":{"#own":"./src/own.js"}}"##,
    );
    root.write(
        "packages/pkg/package.json",
        r#"{"name":"pkg","exports":{".":{"import":"./src/esm.js","require":"./src/cjs.js"}}}"#,
    );
    let files = [
        "src/use.ts",
        "src/own.ts",
        "packages/pkg/src/esm.ts",
        "packages/pkg/src/cjs.ts",
    ];
    assert_eq!(
        target(resolve(&root, &files, "#own", Usage::Import)).as_deref(),
        Some("src/own.ts")
    );
    assert_eq!(
        target(resolve(&root, &files, "pkg", Usage::Import)).as_deref(),
        Some("packages/pkg/src/esm.ts")
    );
    assert_eq!(
        target(resolve(&root, &files, "pkg", Usage::Require)).as_deref(),
        Some("packages/pkg/src/cjs.ts")
    );
}