monoripple 0.0.6

Symbol-aware affected target detection for JavaScript and TypeScript monorepos
use std::collections::{BTreeMap, BTreeSet};
use std::env;
use std::fs;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, bail};
use oxc_allocator::Allocator;
use oxc_ast::{
    AstKind,
    ast::{Argument, Expression, IdentifierReference},
};
use oxc_parser::Parser;
use oxc_semantic::SemanticBuilder;
use oxc_span::{GetSpan, SourceType, Span};

use crate::parser::{MODULE_INIT, parse_module};
use crate::tsgo::TsgoClient;

#[derive(Clone, Debug)]
pub struct TypeScriptFact {
    pub file: PathBuf,
    pub consumer: String,
    pub registry_file: PathBuf,
    pub registry_symbol: String,
    pub keys: Option<Vec<String>>,
    pub line: usize,
    pub column: usize,
}

#[derive(Clone, Debug)]
pub struct TypeScriptRegistry {
    pub file: PathBuf,
    pub symbol: String,
}

#[derive(Clone, Debug, Default)]
pub struct TypeScriptFacts {
    pub indexed_registries: Vec<TypeScriptRegistry>,
    pub facts: Vec<TypeScriptFact>,
}

struct Contract {
    owner: String,
    method: String,
}

struct Callsite {
    file: PathBuf,
    consumer: String,
    method: String,
    keys: Option<Vec<String>>,
    offset: usize,
}

pub fn analyze(
    source_root: &Path,
    compiler_root: &Path,
    files: &[PathBuf],
) -> Result<Option<TypeScriptFacts>> {
    let tsgo = find_tsgo();
    if tsgo.is_none() && !TsgoClient::has_embedded_tsgo() {
        return Ok(None);
    }
    link_node_modules(source_root, compiler_root);
    let mut contracts = Vec::new();
    let mut calls = Vec::new();
    let mut registries = BTreeMap::new();
    let mut sources = BTreeMap::new();

    for file in files {
        let source = fs::read_to_string(file)
            .with_context(|| format!("failed to read {} for tsgo", file.display()))?;
        let parsed = parse_module(file, &source)?;
        for (symbol, registry) in parsed.registries {
            registries.insert(
                (file.clone(), symbol),
                registry.entries.keys().cloned().collect::<BTreeSet<_>>(),
            );
        }
        collect_candidates(file, &source, &mut contracts, &mut calls)?;
        sources.insert(file.clone(), source);
    }
    let method_names: BTreeSet<_> = contracts.iter().map(|contract| &contract.method).collect();
    calls.retain(|call| method_names.contains(&call.method));
    if calls.is_empty() || registries.is_empty() {
        return Ok(None);
    }

    let mut client = TsgoClient::new(tsgo.as_deref())
        .map_err(|error| anyhow::anyhow!("failed to start tsgo: {error}"))?;
    client
        .initialize(&file_uri(source_root)?)
        .map_err(|error| anyhow::anyhow!("failed to initialize tsgo: {error}"))?;
    for (file, source) in &sources {
        client
            .open_file(&file_uri(file)?, source)
            .map_err(|error| {
                anyhow::anyhow!("failed to open {} in tsgo: {error}", file.display())
            })?;
    }

    let mut facts = Vec::new();
    for call in calls {
        let source = &sources[&call.file];
        let (line, character) = offset_to_position(source, call.offset);
        let registry_matches: Vec<_> = registries
            .iter()
            .filter(|(_, keys)| {
                call.keys
                    .as_ref()
                    .is_none_or(|call_keys| call_keys.iter().all(|key| keys.contains(key)))
            })
            .collect();
        if registry_matches.is_empty() {
            continue;
        }
        let hover = client
            .get_type_at_position(&file_uri(&call.file)?, line, character)
            .map_err(|error| {
                anyhow::anyhow!("tsgo hover failed for {}: {error}", call.file.display())
            })?;
        let proven = hover.as_ref().is_some_and(|hover| {
            contracts.iter().any(|contract| {
                contract.method == call.method
                    && hover.contains(&contract.owner)
                    && hover.contains(&format!(".{}", contract.method))
            })
        });
        let unresolved = hover.as_ref().is_none_or(|hover| hover.trim() == "any");
        if !proven && !unresolved {
            continue;
        }
        let location = offset_to_source_location(source, call.offset);
        for ((registry_file, registry_symbol), _) in registry_matches {
            facts.push(TypeScriptFact {
                file: call.file.clone(),
                consumer: call.consumer.clone(),
                registry_file: registry_file.clone(),
                registry_symbol: registry_symbol.clone(),
                keys: proven.then(|| call.keys.clone()).flatten(),
                line: location.0,
                column: location.1,
            });
        }
    }

    if facts.is_empty() {
        return Ok(None);
    }

    Ok(Some(TypeScriptFacts {
        indexed_registries: registries
            .keys()
            .map(|(file, symbol)| TypeScriptRegistry {
                file: file.clone(),
                symbol: symbol.clone(),
            })
            .collect(),
        facts,
    }))
}

fn collect_candidates(
    path: &Path,
    source: &str,
    contracts: &mut Vec<Contract>,
    calls: &mut Vec<Callsite>,
) -> Result<()> {
    let source_type = SourceType::from_path(path)
        .with_context(|| format!("unsupported TypeScript file {}", path.display()))?;
    let allocator = Allocator::default();
    let parsed = Parser::new(&allocator, source, source_type).parse();
    if !parsed.errors.is_empty() {
        bail!("failed to parse {} for tsgo", path.display());
    }
    let semantic = SemanticBuilder::new().build(&parsed.program).semantic;
    let scoping = semantic.scoping();
    let root_scope = scoping.root_scope_id();
    let top_level: Vec<_> = scoping
        .symbol_ids()
        .filter(|symbol| scoping.symbol_scope_id(*symbol) == root_scope)
        .map(|symbol| {
            (
                scoping.symbol_name(symbol).to_string(),
                semantic.symbol_declaration(symbol).kind().span(),
                scoping.symbol_flags(symbol).is_value(),
            )
        })
        .collect();

    for node in semantic.nodes().iter() {
        if let AstKind::TSMethodSignature(method) = node.kind()
            && !method.computed
            && method.type_parameters.as_ref().is_some_and(|params| {
                params
                    .params
                    .iter()
                    .any(|parameter| parameter.constraint.is_some())
            })
            && let Some(name) = method.key.static_name()
            && let Some(owner) = enclosing_symbol(&top_level, method.span, false)
        {
            contracts.push(Contract {
                owner,
                method: name.into_owned(),
            });
        }
        let AstKind::CallExpression(call) = node.kind() else {
            continue;
        };
        let callee = call.callee.get_inner_expression();
        let Some(member) = callee.as_member_expression() else {
            continue;
        };
        let Some((property_span, method)) = member.static_property_info() else {
            continue;
        };
        let keys = match call.arguments.first() {
            Some(Argument::StringLiteral(literal)) => Some(vec![literal.value.to_string()]),
            Some(Argument::TemplateLiteral(template)) if template.expressions.is_empty() => {
                template
                    .quasis
                    .first()
                    .and_then(|quasi| quasi.value.cooked)
                    .map(|value| vec![value.to_string()])
            }
            Some(Argument::ObjectExpression(_)) => continue,
            Some(Argument::Identifier(identifier))
                if identifier_is_object_value(&semantic, identifier) =>
            {
                continue;
            }
            Some(_) | None => None,
        };
        calls.push(Callsite {
            file: path.to_path_buf(),
            consumer: enclosing_symbol(&top_level, call.span, true)
                .unwrap_or_else(|| MODULE_INIT.to_string()),
            method: method.to_string(),
            keys,
            offset: property_span.start as usize,
        });
    }
    Ok(())
}

fn identifier_is_object_value(
    semantic: &oxc_semantic::Semantic<'_>,
    identifier: &IdentifierReference<'_>,
) -> bool {
    let Some(reference) = identifier.reference_id.get() else {
        return false;
    };
    let Some(symbol) = semantic.scoping().get_reference(reference).symbol_id() else {
        return false;
    };
    let declaration = semantic.symbol_declaration(symbol).kind().span();
    semantic.nodes().iter().any(|node| {
        let AstKind::VariableDeclarator(declarator) = node.kind() else {
            return false;
        };
        declarator.span.contains_inclusive(declaration)
            && declarator.init.as_ref().is_some_and(|value| {
                matches!(
                    value.get_inner_expression(),
                    Expression::ObjectExpression(_)
                )
            })
    })
}

fn enclosing_symbol(
    symbols: &[(String, Span, bool)],
    span: Span,
    runtime_only: bool,
) -> Option<String> {
    symbols
        .iter()
        .filter(|(_, declaration, runtime)| {
            (!runtime_only || *runtime) && declaration.contains_inclusive(span)
        })
        .min_by_key(|(_, declaration, _)| declaration.size())
        .map(|(name, _, _)| name.clone())
}

fn offset_to_position(source: &str, offset: usize) -> (u32, u32) {
    let prefix = source.get(..offset).unwrap_or(source);
    let line = prefix.bytes().filter(|byte| *byte == b'\n').count() as u32;
    let line_prefix = prefix.rsplit('\n').next().unwrap_or_default();
    let character = line_prefix.encode_utf16().count() as u32;
    (line, character)
}

fn offset_to_source_location(source: &str, offset: usize) -> (usize, usize) {
    let (line, _) = offset_to_position(source, offset);
    let prefix = source.get(..offset).unwrap_or(source);
    let column = prefix
        .rsplit('\n')
        .next()
        .unwrap_or_default()
        .chars()
        .count()
        + 1;
    (line as usize + 1, column)
}

fn file_uri(path: &Path) -> Result<String> {
    let path = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
    url::Url::from_file_path(&path)
        .map(|url| url.to_string())
        .map_err(|_| anyhow::anyhow!("cannot convert {} to a file URI", path.display()))
}

fn link_node_modules(source_root: &Path, compiler_root: &Path) {
    if source_root == compiler_root || source_root.join("node_modules").exists() {
        return;
    }
    let modules = compiler_root.join("node_modules");
    if !modules.exists() {
        return;
    }
    #[cfg(unix)]
    let _ = std::os::unix::fs::symlink(modules, source_root.join("node_modules"));
    #[cfg(windows)]
    let _ = std::os::windows::fs::symlink_dir(modules, source_root.join("node_modules"));
}

fn find_tsgo() -> Option<PathBuf> {
    if let Some(path) = env::var_os("MONORIPPLE_TSGO") {
        let path = PathBuf::from(path);
        if path.is_file() {
            return Some(path);
        }
    }
    if let Ok(path) = which::which("tsgo") {
        return Some(path);
    }
    let cache = env::var_os("HOME")
        .map(PathBuf::from)?
        .join(".bun/install/cache");
    let mut binaries: Vec<_> = fs::read_dir(cache)
        .ok()?
        .filter_map(|entry| entry.ok())
        .filter(|entry| {
            entry
                .file_name()
                .to_string_lossy()
                .starts_with("@typescript/native-preview-")
        })
        .map(|entry| entry.path().join("lib/tsgo"))
        .filter(|path| path.is_file())
        .collect();
    binaries.sort();
    binaries.pop()
}