monoripple 0.0.1

Symbol-aware affected target detection for JavaScript and TypeScript monorepos
Documentation
use std::collections::BTreeSet;
use std::path::Path;

use anyhow::Result;

use crate::git::{ChangeKind, ChangedFile, file_at};
use crate::graph::{DependencyGraph, Node, normalize_path};
use crate::parser::{MODULE_INIT, ParsedModule, is_source_file, parse_module};
use crate::workspace::{Package, package_for_path};

#[derive(Clone, Debug, Default)]
pub struct ChangeSeeds {
    pub nodes: BTreeSet<Node>,
    pub direct_packages: BTreeSet<String>,
}

pub fn find_change_seeds(
    root: &Path,
    base: &str,
    changes: &[ChangedFile],
    packages: &[Package],
    graph: &DependencyGraph,
) -> Result<ChangeSeeds> {
    let mut result = ChangeSeeds::default();

    for change in changes {
        if !is_source_file(&change.path) {
            if let Some(package) = package_for_path(packages, &change.path) {
                let relative = change
                    .path
                    .strip_prefix(&package.dir)
                    .unwrap_or(&change.path);
                let file_name = change
                    .path
                    .file_name()
                    .and_then(|name| name.to_str())
                    .unwrap_or_default();
                let is_rust_source = change
                    .path
                    .extension()
                    .is_some_and(|extension| extension == "rs")
                    && relative
                        .components()
                        .any(|component| component.as_os_str() == "src");
                let is_build_input = matches!(
                    file_name,
                    "package.json"
                        | "wrangler.json"
                        | "wrangler.jsonc"
                        | "tsconfig.json"
                        | "Cargo.toml"
                        | "build.rs"
                ) || file_name.starts_with("vite.config.");

                if is_rust_source || is_build_input {
                    result.direct_packages.insert(package.name.clone());
                }
            }
            continue;
        }

        if let Some(package) = package_for_path(packages, &change.path) {
            let target_node = DependencyGraph::target_node(&package.name);
            let relative = change
                .path
                .strip_prefix(&package.dir)
                .unwrap_or(&change.path);
            let is_runtime_source = relative
                .components()
                .any(|component| component.as_os_str() == "src")
                || package.entrypoint.as_ref() == Some(&change.path);
            let is_unlinked_target = graph
                .targets
                .iter()
                .any(|target| target.package == package.name)
                && !graph.edges.contains_key(&target_node);
            if is_runtime_source && is_unlinked_target {
                result.direct_packages.insert(package.name.clone());
            }
        }

        let current_path = normalize_path(&change.path);
        let current = graph.modules.get(&current_path);
        let old_relative = match &change.kind {
            ChangeKind::Renamed { old_path } => old_path.as_path(),
            _ => change.path.strip_prefix(root).unwrap_or(&change.path),
        };
        let old_path = root.join(old_relative);
        let old_source = match change.kind {
            ChangeKind::Added => None,
            _ => file_at(root, base, &old_path)?,
        };
        let old = old_source
            .as_deref()
            .map(|source| parse_module(&old_path, source))
            .transpose()?;

        match (old.as_ref(), current) {
            (None, Some(current)) => {
                add_all_runtime_symbols(&mut result.nodes, &current_path, current)
            }
            (Some(old), Some(current)) => {
                add_changed_symbols(&mut result.nodes, &current_path, old, current);
            }
            (Some(_), None) => {
                if let Some(package) = package_for_path(packages, &change.path) {
                    result.direct_packages.insert(package.name.clone());
                }
            }
            (None, None) => {}
        }
    }

    Ok(result)
}

fn add_changed_symbols(
    seeds: &mut BTreeSet<Node>,
    path: &Path,
    old: &ParsedModule,
    current: &ParsedModule,
) {
    let removed_runtime_symbol = old.symbols.iter().any(|(name, old_symbol)| {
        name.as_str() != MODULE_INIT && old_symbol.runtime && !current.symbols.contains_key(name)
    });

    for (name, current_symbol) in &current.symbols {
        if !current_symbol.runtime {
            continue;
        }

        let changed = old
            .symbols
            .get(name)
            .is_none_or(|old_symbol| old_symbol.fingerprint != current_symbol.fingerprint);
        if changed || (removed_runtime_symbol && name.as_str() != MODULE_INIT) {
            seeds.insert(Node::new(path, name));
        }
    }
}

fn add_all_runtime_symbols(seeds: &mut BTreeSet<Node>, path: &Path, module: &ParsedModule) {
    for (name, symbol) in &module.symbols {
        if symbol.runtime {
            seeds.insert(Node::new(path, name));
        }
    }
}

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

    use super::*;

    #[test]
    fn seeds_only_changed_declaration() {
        let old = parse_module(
            Path::new("shared.ts"),
            "export const used = 1; export const untouched = 2;",
        )
        .unwrap();
        let current = parse_module(
            Path::new("shared.ts"),
            "export const used = 3; export const untouched = 2;",
        )
        .unwrap();
        let mut seeds = BTreeSet::new();
        add_changed_symbols(&mut seeds, Path::new("shared.ts"), &old, &current);

        assert!(seeds.contains(&Node::new("shared.ts", "used")));
        assert!(!seeds.contains(&Node::new("shared.ts", "untouched")));
    }
}