devscout-rs 0.10.0

Fast code index for C# and TypeScript codebases -- map, find, refs, impact from the CLI.
use std::collections::HashSet;

use super::config::ModuleConfig;
use super::disclosure::Reason;
use super::modes::Mode;
use super::package_map::{self, MapMatch};
use super::packages::Package;
use super::{
    alias_targets, dir_of, extension_of, join_rel, resolve_specifier, scope_for, TsAliasScopes,
};

#[derive(Clone, Copy)]
pub(super) enum Usage {
    Import,
    Require,
}

impl Usage {
    fn condition(self) -> &'static str {
        match self {
            Self::Import => "import",
            Self::Require => "require",
        }
    }
}

pub(super) enum Resolution {
    Resolved(String),
    Unresolved(Reason),
    LegacyExternal,
}

pub(super) struct Resolver<'a> {
    pub files: &'a HashSet<&'a str>,
    pub alias: &'a TsAliasScopes,
    pub config: &'a ModuleConfig,
}

impl Resolver<'_> {
    pub fn static_usage(&self, file: &str) -> Usage {
        let Some(scope) = self.config.mode(file) else {
            return Usage::Import;
        };
        match scope.mode {
            Mode::Node16 | Mode::NodeNext => self.file_format(file),
            Mode::Bundler if file.ends_with(".cts") || file.ends_with(".cjs") => Usage::Require,
            _ => Usage::Import,
        }
    }

    fn file_format(&self, file: &str) -> Usage {
        if file.ends_with(".mts") || file.ends_with(".mjs") {
            return Usage::Import;
        }
        if file.ends_with(".cts") || file.ends_with(".cjs") {
            return Usage::Require;
        }
        let package = self
            .config
            .packages
            .as_ref()
            .and_then(|packages| packages.nearest(file));
        if package.and_then(|pkg| pkg.module_type.as_deref()) == Some("module") {
            Usage::Import
        } else {
            Usage::Require
        }
    }

    pub fn resolve(&self, file: &str, spec: &str, usage: Usage) -> Resolution {
        let Some(scope) = self.config.mode(file) else {
            return resolve_specifier(file, spec, self.files, self.alias)
                .map_or(Resolution::LegacyExternal, Resolution::Resolved);
        };
        if scope.mode == Mode::Classic {
            return resolve_specifier(file, spec, self.files, self.alias)
                .map_or(Resolution::LegacyExternal, Resolution::Resolved);
        }
        if matches!(scope.mode, Mode::Node16 | Mode::NodeNext)
            && self
                .config
                .packages
                .as_ref()
                .is_some_and(|packages| packages.nearest_refused(file))
        {
            return Resolution::Unresolved(Reason::ConfigurationRefused);
        }
        if spec.starts_with('.') {
            return self.relative(file, spec, scope.mode, usage);
        }
        let aliases = scope_for(file, self.alias);
        let targets = alias_targets(spec, &aliases.aliases);
        let matched = !targets.is_empty();
        let strict =
            matches!(scope.mode, Mode::Node16 | Mode::NodeNext) && matches!(usage, Usage::Import);
        for candidate in targets.into_iter().flatten() {
            if let Some(target) = self.probe(&candidate, !strict, strict) {
                return Resolution::Resolved(target);
            }
        }
        if !matched {
            if let Some(base) = &aliases.base_url_dir {
                if let Some(path) = join_rel(base, spec) {
                    if let Some(target) = self.probe(&path, !strict, strict) {
                        return Resolution::Resolved(target);
                    }
                }
            }
        }
        if spec.starts_with('#') && scope.mode != Mode::Node10 {
            return self.package_import(file, spec, scope.mode, usage);
        }
        if spec.contains(':') {
            return Resolution::Unresolved(Reason::BarePackageOutsideWorkspace);
        }
        let (name, subpath) = package_name(spec);
        if scope.mode != Mode::Node10 {
            if let Some(own) = self
                .config
                .packages
                .as_ref()
                .and_then(|packages| packages.nearest(file))
            {
                if own.name.as_deref() == Some(name.as_str()) && own.exports.is_some() {
                    return self.package_target(
                        own,
                        &subpath,
                        scope.mode,
                        usage,
                        &scope.custom_conditions,
                    );
                }
            }
        }
        let Some(workspace) = self
            .config
            .workspaces
            .as_ref()
            .and_then(|workspaces| workspaces.nearest(file))
        else {
            return Resolution::Unresolved(Reason::BarePackageOutsideWorkspace);
        };
        if workspace.refused {
            return Resolution::Unresolved(Reason::ConfigurationRefused);
        }
        let Some(members) = workspace.members.get(&name) else {
            return Resolution::Unresolved(Reason::BarePackageOutsideWorkspace);
        };
        if members.len() > 1 {
            return Resolution::Unresolved(Reason::AmbiguousWorkspaceName);
        }
        let Some(package) = self
            .config
            .packages
            .as_ref()
            .and_then(|packages| packages.by_dir.get(&members[0]))
        else {
            return Resolution::Unresolved(Reason::ConfigurationRefused);
        };
        self.package_target(
            package,
            &subpath,
            scope.mode,
            usage,
            &scope.custom_conditions,
        )
    }

    fn relative(&self, file: &str, spec: &str, mode: Mode, usage: Usage) -> Resolution {
        let Some(path) = join_rel(dir_of(file), spec) else {
            return Resolution::Unresolved(Reason::TargetNotIndexed);
        };
        let strict =
            matches!(mode, Mode::Node16 | Mode::NodeNext) && matches!(usage, Usage::Import);
        if !strict {
            return self.probe(&path, true, false).map_or(
                Resolution::Unresolved(Reason::TargetNotIndexed),
                Resolution::Resolved,
            );
        }
        if !extension_of(&path).is_empty() {
            return self.probe(&path, false, true).map_or(
                Resolution::Unresolved(Reason::TargetNotIndexed),
                Resolution::Resolved,
            );
        }
        if self.probe_file(&path, false).is_some() {
            return Resolution::Unresolved(Reason::EsmExtensionRequired);
        }
        if self.probe_directory(&path).is_some() {
            return Resolution::Unresolved(Reason::DirectoryImportNotAllowed);
        }
        Resolution::Unresolved(Reason::TargetNotIndexed)
    }

    fn package_import(&self, file: &str, spec: &str, mode: Mode, usage: Usage) -> Resolution {
        let Some(package) = self
            .config
            .packages
            .as_ref()
            .and_then(|packages| packages.nearest(file))
        else {
            return Resolution::Unresolved(Reason::ImportsSpecifierNotMatched);
        };
        let Some(map) = &package.imports else {
            return Resolution::Unresolved(Reason::ImportsSpecifierNotMatched);
        };
        let conditions = &self
            .config
            .mode(file)
            .expect("declared file")
            .custom_conditions;
        self.map_target(
            package,
            package_map::imports(map, spec, usage.condition(), mode, conditions),
            true,
            matches!(mode, Mode::Node16 | Mode::NodeNext) && matches!(usage, Usage::Import),
        )
    }

    fn package_target(
        &self,
        package: &Package,
        subpath: &str,
        mode: Mode,
        usage: Usage,
        conditions: &[String],
    ) -> Resolution {
        if mode != Mode::Node10 {
            if let Some(map) = &package.exports {
                return self.map_target(
                    package,
                    package_map::exports(map, subpath, usage.condition(), mode, conditions),
                    false,
                    matches!(mode, Mode::Node16 | Mode::NodeNext) && matches!(usage, Usage::Import),
                );
            }
        }
        let path = if subpath == "." {
            package.main.as_deref().unwrap_or("index")
        } else {
            subpath.strip_prefix("./").unwrap_or(subpath)
        };
        let Some(target) = join_rel(&package.dir, path) else {
            return Resolution::Unresolved(Reason::TargetNotIndexed);
        };
        self.package_file(package, &target, true, false)
    }

    fn map_target(
        &self,
        package: &Package,
        selected: MapMatch,
        imports: bool,
        strict: bool,
    ) -> Resolution {
        match selected {
            MapMatch::NotMatched => Resolution::Unresolved(if imports {
                Reason::ImportsSpecifierNotMatched
            } else {
                Reason::ExportsSubpathNotMatched
            }),
            MapMatch::NoCondition => Resolution::Unresolved(Reason::NoMatchingCondition),
            MapMatch::Targets(targets) => {
                let mut last = Reason::TargetNotIndexed;
                for target in targets {
                    if !target.starts_with("./") {
                        continue;
                    }
                    let Some(path) = join_rel(&package.dir, &target) else {
                        continue;
                    };
                    match self.package_file(package, &path, false, strict) {
                        Resolution::Resolved(hit) => return Resolution::Resolved(hit),
                        Resolution::Unresolved(reason) => last = reason,
                        Resolution::LegacyExternal => {}
                    }
                }
                Resolution::Unresolved(last)
            }
        }
    }

    fn package_file(
        &self,
        package: &Package,
        target: &str,
        directory: bool,
        strict: bool,
    ) -> Resolution {
        if let Some(hit) = self
            .config
            .build_outputs
            .as_ref()
            .and_then(|outputs| outputs.source(&package.dir, target, self.files))
        {
            return Resolution::Resolved(hit);
        }
        if let Some(hit) = self.probe(target, directory, strict) {
            return Resolution::Resolved(hit);
        }
        if [".d.ts", ".d.mts", ".d.cts"]
            .iter()
            .any(|suffix| target.ends_with(suffix))
        {
            return Resolution::Unresolved(Reason::DeclarationOnlyTarget);
        }
        if self
            .config
            .build_outputs
            .as_ref()
            .is_some_and(|outputs| outputs.covers(&package.dir, target))
        {
            return Resolution::Unresolved(Reason::BuildOutputWithoutSourceMapping);
        }
        Resolution::Unresolved(Reason::TargetNotIndexed)
    }

    fn probe(&self, path: &str, directory: bool, strict: bool) -> Option<String> {
        self.probe_file(path, strict)
            .or_else(|| directory.then(|| self.probe_directory(path)).flatten())
    }

    fn probe_file(&self, path: &str, strict: bool) -> Option<String> {
        let extension = extension_of(path);
        if (strict || matches!(extension, ".ts" | ".tsx" | ".mts" | ".cts"))
            && !matches!(extension, ".js" | ".jsx" | ".mjs" | ".cjs")
        {
            return (![".d.ts", ".d.mts", ".d.cts"]
                .iter()
                .any(|declaration| path.ends_with(declaration))
                && self.files.contains(path))
            .then(|| path.to_string());
        }
        let candidates: &[&str] = match extension {
            ".js" => &[".ts", ".tsx", ".js", ".jsx"],
            ".jsx" => &[".tsx", ".ts", ".jsx", ".js"],
            ".mjs" => &[".mts", ".mjs"],
            ".cjs" => &[".cts", ".cjs"],
            "" => &[".ts", ".tsx", ".js", ".jsx"],
            _ => &[".ts", ".tsx", ".js", ".jsx", extension],
        };
        let stem = if extension.is_empty() || !matches!(extension, ".js" | ".jsx" | ".mjs" | ".cjs")
        {
            path
        } else {
            &path[..path.len() - extension.len()]
        };
        for suffix in candidates {
            let candidate = if *suffix == extension && !extension.is_empty() && stem == path {
                path.to_string()
            } else {
                format!("{stem}{suffix}")
            };
            if ![".d.ts", ".d.mts", ".d.cts"]
                .iter()
                .any(|declaration| candidate.ends_with(declaration))
                && self.files.contains(candidate.as_str())
            {
                return Some(candidate);
            }
        }
        None
    }

    fn probe_directory(&self, path: &str) -> Option<String> {
        if let Some(package) = self
            .config
            .packages
            .as_ref()
            .and_then(|packages| packages.by_dir.get(path))
        {
            if let Some(main) = &package.main {
                if let Some(candidate) = join_rel(path, main) {
                    if let Some(hit) = self.probe_file(&candidate, false) {
                        return Some(hit);
                    }
                }
            }
        }
        self.probe_file(&format!("{path}/index"), false)
    }
}

fn package_name(spec: &str) -> (String, String) {
    let count = if spec.starts_with('@') { 2 } else { 1 };
    let mut parts = spec.split('/');
    let name = parts.by_ref().take(count).collect::<Vec<_>>().join("/");
    let rest = parts.collect::<Vec<_>>().join("/");
    let subpath = if rest.is_empty() {
        ".".to_string()
    } else {
        format!("./{rest}")
    };
    (name, subpath)
}

#[cfg(test)]
mod tests;