scryer-engine 0.3.0

Tree-sitter AST indexing and reference resolution engine for Scryer code intelligence
use std::path::Path;

use crate::payload::ParsedFilePayload;

pub mod python;
pub mod rust;
pub mod typescript;

pub use python::PythonAstParser;
pub use rust::{
    ExtractedUseItem, RustAstParser, crate_root_for, dependency_module_path_for,
    extract_type_members, extract_use_items, module_path_for,
};
pub use typescript::TypeScriptAstParser;

/// Parsing mode distinguishing full workspace parsing from public surface external dependency extraction.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub enum ParseMode {
    #[default]
    Workspace,
    DependencyPublicSurface {
        crate_name: String,
    },
}

impl ParseMode {
    pub fn is_dependency_public_surface(&self) -> bool {
        matches!(self, Self::DependencyPublicSurface { .. })
    }

    pub fn crate_name(&self) -> Option<&str> {
        match self {
            Self::Workspace => None,
            Self::DependencyPublicSurface { crate_name } => Some(crate_name.as_str()),
        }
    }
}

/// Automatically detect language from file extension and parse into `ParsedFilePayload`.
pub fn parse_file(
    relative_path: &Path,
    content: &[u8],
    content_hash: &str,
    mode: ParseMode,
) -> anyhow::Result<ParsedFilePayload> {
    let ext = relative_path
        .extension()
        .and_then(|e| e.to_str())
        .unwrap_or("")
        .to_ascii_lowercase();

    match ext.as_str() {
        "rs" => RustAstParser::parse(relative_path, content, content_hash, mode),
        "py" => PythonAstParser::parse(relative_path, content, content_hash),
        "ts" | "tsx" | "js" | "jsx" => {
            TypeScriptAstParser::parse(relative_path, content, content_hash)
        }
        other => anyhow::bail!(
            "Unsupported file extension '{other}' for {}",
            relative_path.display()
        ),
    }
}

/// The module a file defines, named as its symbols' qualified names are prefixed
/// (`crate_name::a::b`, `pkg.mod`, `src/components/Button`). `None` for other file types.
/// Code that sits outside every symbol (a script's top-level statements) belongs to it.
pub fn module_name_for(rel_path: &Path) -> Option<String> {
    match rel_path.extension()?.to_str()? {
        "rs" => Some(module_path_for(rel_path)),
        "py" => Some(python::compute_base_module(rel_path)),
        "ts" | "tsx" | "js" | "jsx" => Some(typescript::compute_base_module(rel_path)),
        _ => None,
    }
}

/// Language family of a source file, by extension (JavaScript shares TypeScript's).
fn language_family(path: &Path) -> Option<&'static str> {
    // No allocation: this runs for every candidate of every reference.
    let ext = path.extension()?.to_str()?;
    let is = |candidates: &[&str]| candidates.iter().any(|c| ext.eq_ignore_ascii_case(c));
    if is(&["rs"]) {
        Some("rust")
    } else if is(&["py"]) {
        Some("python")
    } else if is(&["ts", "tsx", "js", "jsx"]) {
        Some("typescript")
    } else {
        None
    }
}

/// Whether two files are written in the same language. A reference never means a definition
/// in another language. Files of an unknown type match anything.
pub(crate) fn same_language(a: &Path, b: &Path) -> bool {
    match (language_family(a), language_family(b)) {
        (Some(x), Some(y)) => x == y,
        _ => true,
    }
}