skif-js 0.4.0

JavaScript and TypeScript module graph and dependency rules for skif, built on oxc.
Documentation
//! Module specifiers: builtins, package names and `@types` pairing.

const BUILTINS: &[&str] = &[
    "assert",
    "async_hooks",
    "buffer",
    "child_process",
    "cluster",
    "console",
    "constants",
    "crypto",
    "dgram",
    "diagnostics_channel",
    "dns",
    "domain",
    "events",
    "fs",
    "http",
    "http2",
    "https",
    "inspector",
    "module",
    "net",
    "os",
    "path",
    "perf_hooks",
    "process",
    "punycode",
    "querystring",
    "readline",
    "repl",
    "stream",
    "string_decoder",
    "sys",
    "timers",
    "tls",
    "trace_events",
    "tty",
    "url",
    "util",
    "v8",
    "vm",
    "wasi",
    "worker_threads",
    "zlib",
];

/// Builtins that live under a subpath (`fs/promises`); every other `name/...` is an npm package.
const BUILTIN_SUBPATHS: &[&str] = &[
    "assert/strict",
    "dns/promises",
    "fs/promises",
    "inspector/promises",
    "path/posix",
    "path/win32",
    "readline/promises",
    "stream/consumers",
    "stream/promises",
    "stream/web",
    "timers/promises",
    "util/types",
];

/// Runtimes that are provided by the environment and never listed as dependencies.
pub(crate) const RUNTIME_PACKAGES: &[&str] = &["node", "bun", "deno"];

/// Whether `specifier` names a Node.js builtin, with or without the `node:` prefix.
pub(crate) fn is_builtin(specifier: &str) -> bool {
    if specifier.starts_with("node:") {
        return true;
    }
    BUILTINS.contains(&specifier) || BUILTIN_SUBPATHS.contains(&specifier)
}

/// Whether the specifier carries a URL-like scheme that is not a module on disk (`data:`, `https:`, `virtual:`).
pub(crate) fn has_foreign_scheme(specifier: &str) -> bool {
    let Some((scheme, _)) = specifier.split_once(':') else {
        return false;
    };
    scheme.len() > 1 && scheme.bytes().all(|b| b.is_ascii_lowercase())
}

/// Whether a specifier starts the way a package name does (`lodash`, `@scope/pkg`), whether or not such a
/// package exists. `pkg:x` schemes and a leading `~` (`~lodash/x`, which knip does not take for a package either)
/// do not.
pub(crate) fn starts_like_package_name(specifier: &str) -> bool {
    let bytes = specifier.as_bytes();
    let name_start = |b: u8| b.is_ascii_alphanumeric();
    match (bytes.first().copied(), bytes.get(1).copied()) {
        (Some(b'@'), Some(second)) => name_start(second) || matches!(second, b'.' | b'_' | b'~'),
        (Some(first), second) => name_start(first) && second != Some(b':'),
        _ => false,
    }
}

/// The package an unresolvable specifier that starts like a package name is taken to name, query and fragment
/// dropped. `None` when it does not start like one.
pub(crate) fn assumed_package(specifier: &str) -> Option<String> {
    let sanitized = specifier.split(['?', '#']).next()?;
    if !starts_like_package_name(sanitized) {
        return None;
    }
    package_name(sanitized).or_else(|| {
        let mut parts = sanitized.split('/');
        let first = parts.next()?;
        if first.starts_with('@') {
            Some(format!("{first}/{}", parts.next()?))
        } else {
            Some(first.to_owned())
        }
    })
}

/// The package a bare specifier points into: `lodash/fp` is `lodash`, `@a/b/c` is `@a/b`.
///
/// Returns `None` for relative, absolute and `#` specifiers and for strings that cannot be a package name.
pub(crate) fn package_name(specifier: &str) -> Option<String> {
    let specifier = specifier.split(['?', '#']).next()?;
    if specifier.is_empty() || specifier.starts_with(['.', '/', '#', '~']) {
        return None;
    }
    let name = if let Some(rest) = specifier.strip_prefix('@') {
        let mut parts = rest.split('/');
        let scope = parts.next().filter(|part| !part.is_empty())?;
        let name = parts.next().filter(|part| !part.is_empty())?;
        format!("@{scope}/{name}")
    } else {
        specifier.split('/').next().filter(|part| !part.is_empty())?.to_owned()
    };
    let valid = name
        .bytes()
        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.' | b'@' | b'/'));
    valid.then_some(name)
}

/// The package a specifier reaches through a `node_modules/` segment, using the last one:
/// `mylib/node_modules/express` and `../node_modules/lodash/fp` are `express` and `lodash`.
///
/// Returns `None` when the specifier has no `node_modules` segment followed by a package name.
pub(crate) fn node_modules_specifier_package(specifier: &str) -> Option<String> {
    let specifier = specifier.split(['?', '#']).next()?;
    let segments: Vec<&str> = specifier.split(['/', '\\']).collect();
    let position = segments.iter().rposition(|segment| *segment == "node_modules")?;
    let rest = segments.get(position + 1..)?;
    let first = rest.first().copied().filter(|part| !part.is_empty())?;
    let joined = if first.starts_with('@') {
        let second = rest.get(1).copied().filter(|part| !part.is_empty())?;
        format!("{first}/{second}")
    } else {
        first.to_owned()
    };
    package_name(&joined)
}

/// The binary a specifier names in a package manager's `node_modules/.bin` directory: `.bin/tsc`,
/// `node_modules/.bin/tsc` and `./node_modules/.bin/tsc` are `tsc`. Node.js reads `.bin/tsc` as a lookup below the
/// `node_modules` directories, so `require.resolve('.bin/tsc')` finds the link the binary's package installs, not a
/// module. The other two forms are read the same way as a heuristic for what the author means: Node.js looks the bare
/// `node_modules/.bin/tsc` up below `node_modules/node_modules`, and finds `./node_modules/.bin/tsc` only from the
/// directory that holds `node_modules`. `None` for any other specifier.
pub(crate) fn bin_link(specifier: &str) -> Option<&str> {
    let name = specifier
        .strip_prefix("./")
        .unwrap_or(specifier)
        .strip_prefix("node_modules/")
        .unwrap_or(specifier)
        .strip_prefix(".bin/")?;
    (!name.is_empty() && !name.contains(['/', '\\', '?', '#'])).then_some(name)
}

/// The part of a bare specifier after the package name, without a leading slash.
pub(crate) fn subpath<'a>(specifier: &'a str, package: &str) -> Option<&'a str> {
    let rest = specifier.strip_prefix(package)?.strip_prefix('/')?;
    let rest = rest.split(['?', '#']).next().unwrap_or(rest);
    (!rest.is_empty()).then_some(rest)
}

/// The `DefinitelyTyped` package for `name`: `react` is `@types/react`, `@a/b` is `@types/a__b`.
/// `None` for packages that already are `DefinitelyTyped` packages.
pub(crate) fn types_package(name: &str) -> Option<String> {
    if name.starts_with("@types/") {
        return None;
    }
    Some(match name.strip_prefix('@').and_then(|rest| rest.split_once('/')) {
        Some((scope, rest)) => format!("@types/{scope}__{rest}"),
        None => format!("@types/{name}"),
    })
}

/// The package a `DefinitelyTyped` package describes: `@types/a__b` is `@a/b`.
pub(crate) fn typed_package(types: &str) -> Option<String> {
    let rest = types.strip_prefix("@types/")?;
    Some(match rest.split_once("__") {
        Some((scope, name)) => format!("@{scope}/{name}"),
        None => rest.to_owned(),
    })
}

/// The package name found in a path below `node_modules`, using the last `node_modules` component.
pub(crate) fn node_modules_package(path: &std::path::Path) -> Option<String> {
    // Most resolved paths are sources: a byte scan rules them out without splitting the path into components.
    const SEGMENT: &[u8] = b"node_modules";
    if !path
        .as_os_str()
        .as_encoded_bytes()
        .windows(SEGMENT.len())
        .any(|window| window == SEGMENT)
    {
        return None;
    }
    let components: Vec<_> = path.components().map(|c| c.as_os_str().to_string_lossy()).collect();
    let position = components.iter().rposition(|c| c == "node_modules")?;
    let first = components.get(position + 1)?;
    // A package name never starts with a dot: `.bin` holds the package managers' binary links and `.vite` or `.cache`
    // the files tools write, none of them a package.
    if first.starts_with('.') {
        return None;
    }
    if first.starts_with('@') {
        let second = components.get(position + 2)?;
        Some(format!("{first}/{second}"))
    } else {
        Some(first.to_string())
    }
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use super::*;

    #[test]
    fn package_names() {
        let cases = [
            ("lodash", Some("lodash")),
            ("lodash/fp", Some("lodash")),
            ("@scope/pkg", Some("@scope/pkg")),
            ("@scope/pkg/deep/file.js", Some("@scope/pkg")),
            ("@scope", None),
            ("@/alias/x", None),
            ("./local", None),
            ("../up", None),
            ("/abs", None),
            ("#internal", None),
            ("~/x", None),
            ("pkg?raw", Some("pkg")),
            ("", None),
        ];
        for (specifier, expected) in cases {
            assert_eq!(package_name(specifier).as_deref(), expected, "{specifier}");
        }
    }

    #[test]
    fn node_modules_specifiers() {
        let cases = [
            ("mylib/node_modules/express", Some("express")),
            ("../node_modules/lodash/fp", Some("lodash")),
            ("/abs/node_modules/@scope/pkg/x", Some("@scope/pkg")),
            ("./a/node_modules/b/node_modules/c/d.js", Some("c")),
            ("node_modules/@scope/pkg", Some("@scope/pkg")),
            ("/abs/node_modules/@scope", None),
            ("/abs/node_modules/", None),
            ("my_node_modules/x", None),
            ("lodash", None),
            ("..\\node_modules\\lodash\\fp", Some("lodash")),
        ];
        for (specifier, expected) in cases {
            assert_eq!(
                node_modules_specifier_package(specifier).as_deref(),
                expected,
                "{specifier}"
            );
        }
    }

    #[test]
    fn bin_link_specifiers() {
        let cases = [
            (".bin/jscodeshift", Some("jscodeshift")),
            ("node_modules/.bin/tsc", Some("tsc")),
            ("./node_modules/.bin/tsc", Some("tsc")),
            (".bin/n8n-node", Some("n8n-node")),
            (".bin/", None),
            (".bin/a/b", None),
            (".bin/tool?x", None),
            ("./.bin/tool", None),
            ("../node_modules/.bin/tool", None),
            (".binary/tool", None),
            ("tool", None),
        ];
        for (specifier, expected) in cases {
            assert_eq!(bin_link(specifier), expected, "{specifier}");
        }
    }

    #[test]
    fn builtins_and_schemes() {
        assert!(is_builtin("node:fs"));
        assert!(is_builtin("fs"));
        assert!(is_builtin("fs/promises"));
        assert!(!is_builtin("fs-extra"));
        assert!(!is_builtin("buffer/"));
        assert!(!is_builtin("events/"));
        assert!(!is_builtin("fs/extra"));
        assert!(is_builtin("stream/web"));
        assert!(has_foreign_scheme("https://x.dev/a.js"));
        assert!(has_foreign_scheme("virtual:pwa"));
        assert!(!has_foreign_scheme("lodash"));
    }

    #[test]
    fn types_pairing() {
        assert_eq!(types_package("react").as_deref(), Some("@types/react"));
        assert_eq!(types_package("@a/b").as_deref(), Some("@types/a__b"));
        assert_eq!(types_package("@types/react"), None);
        assert_eq!(typed_package("@types/a__b").as_deref(), Some("@a/b"));
        assert_eq!(typed_package("@types/node").as_deref(), Some("node"));
        assert_eq!(typed_package("react"), None);
    }

    #[test]
    fn subpaths() {
        assert_eq!(subpath("@a/b/c/d", "@a/b"), Some("c/d"));
        assert_eq!(subpath("@a/b", "@a/b"), None);
    }

    #[test]
    fn node_modules_packages() {
        assert_eq!(
            node_modules_package(Path::new("/r/node_modules/.pnpm/x/node_modules/@a/b/index.js")).as_deref(),
            Some("@a/b")
        );
        assert_eq!(node_modules_package(Path::new("/r/src/a.ts")), None);
        assert_eq!(node_modules_package(Path::new("/r/node_modules/.bin/tool")), None);
        assert_eq!(
            node_modules_package(Path::new("/r/node_modules/.vite/deps/react.js")),
            None
        );
    }
}