blast-radius 0.7.0

Analyze the transitive blast radius of code changes.
Documentation
use std::fs;
use std::path::Path;

use anyhow::{Context, Result};
mod facts;
pub use facts::*;

mod javascript;
pub(crate) use javascript::parse_javascript_module;

#[cfg(any(feature = "vue", feature = "svelte"))]
mod component;
#[cfg(any(feature = "vue", feature = "svelte"))]
pub(crate) use component::parse_component_module;

#[cfg(feature = "python")]
mod python;
#[cfg(feature = "python")]
pub(crate) use python::parse_python_module;

#[cfg(feature = "rust")]
mod rust_lang;
#[cfg(feature = "rust")]
pub(crate) use rust_lang::parse_rust_module;

pub fn parse_module(path: &Path) -> Result<ModuleFacts> {
    let source = fs::read_to_string(path)
        .with_context(|| format!("failed to read source file {}", path.display()))?;

    crate::language::adapter_for(path).parse(path, &source)
}

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

    use tempfile::tempdir;

    use super::parse_module;

    #[test]
    fn parses_js_files_with_jsx() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("renderAvatar.js");
        fs::write(
            &path,
            r#"
import Avatar from '@mui/material/Avatar';

export function renderAvatar(params) {
  if (params.value == null) {
    return '';
  }

  return <Avatar>{params.value.name}</Avatar>;
}
"#,
        )
        .unwrap();

        let facts = parse_module(&path).unwrap();
        assert_eq!(facts.imports.len(), 1);
        assert!(
            facts
                .exports
                .iter()
                .any(|export| export.exported == "renderAvatar")
        );
    }

    #[test]
    fn parses_dynamic_imports_as_used_namespace_imports() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("routes.tsx");
        fs::write(
            &path,
            r#"
import { lazy } from 'react';

const ChatsPage = lazy(() =>
  import("@/pages/chats-page").then((m) => ({ default: m.ChatsPage }))
);
const Settings = lazy(() => import(`./settings-page`));

export const routes = [ChatsPage, Settings];
"#,
        )
        .unwrap();

        let facts = parse_module(&path).unwrap();
        for source in ["@/pages/chats-page", "./settings-page"] {
            let import = facts
                .imports
                .iter()
                .find(|import| import.source == source)
                .expect("dynamic import should be collected");
            assert_eq!(import.kind, super::ImportKind::Dynamic);
            assert_eq!(import.imported, super::ImportTarget::Namespace);
            // The call site is the usage, so the edge must count in the walk.
            assert!(facts.used_locals.contains(&import.local));
        }
    }

    #[test]
    fn parses_modern_module_extensions() {
        let dir = tempdir().unwrap();

        let mjs_path = dir.path().join("widget.mjs");
        fs::write(&mjs_path, "export const widget = <div />;").unwrap();
        parse_module(&mjs_path).unwrap();

        let cts_path = dir.path().join("server.cts");
        fs::write(&cts_path, "export const server = 1;").unwrap();
        parse_module(&cts_path).unwrap();
    }

    #[cfg(feature = "python")]
    #[test]
    fn parses_python_imports_and_exports() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("email.py");
        fs::write(
            &path,
            r#"
from ..models import User
from . import formatting

DEFAULT_TEMPLATE = "welcome"

def send_email(user: User) -> str:
    return formatting.format_subject(user.email, DEFAULT_TEMPLATE)
"#,
        )
        .unwrap();

        let facts = parse_module(&path).unwrap();
        assert!(
            facts
                .exports
                .iter()
                .any(|export| export.exported == "send_email")
        );
        assert!(
            facts
                .imports
                .iter()
                .any(|import| import.source == "..models" && import.local == "User")
        );
        assert!(
            facts
                .imports
                .iter()
                .any(|import| import.source == ".formatting" && import.local == "formatting")
        );
    }

    #[cfg(feature = "rust")]
    #[test]
    fn parses_rust_imports_exports_and_reexports() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("lib.rs");
        fs::write(
            &path,
            r#"
pub mod services;

use crate::models::User;
pub use crate::services::email::send_email;

pub struct App;
"#,
        )
        .unwrap();

        let facts = parse_module(&path).unwrap();
        assert!(facts.exports.iter().any(|export| export.exported == "App"));
        assert!(
            facts
                .imports
                .iter()
                .any(|import| import.source == "mod:services")
        );
        assert!(
            facts
                .imports
                .iter()
                .any(|import| import.source == "crate::models" && import.local == "User")
        );
        assert!(
            facts
                .reexports
                .iter()
                .any(|reexport| reexport.source == "crate::services::email"
                    && reexport.exported == "send_email")
        );
    }

    #[cfg(feature = "vue")]
    #[test]
    fn parses_vue_script_imports_and_default_export() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("Button.vue");
        fs::write(
            &path,
            r#"
<script setup lang="ts">
import { formatLabel } from './shared'
const label = formatLabel('save')
</script>
<template><button>{{ label }}</button></template>
"#,
        )
        .unwrap();

        let facts = parse_module(&path).unwrap();
        assert!(
            facts
                .imports
                .iter()
                .any(|import| import.source == "./shared" && import.local == "formatLabel")
        );
        assert!(
            facts
                .exports
                .iter()
                .any(|export| export.exported == "default")
        );
    }

    #[cfg(feature = "svelte")]
    #[test]
    fn parses_svelte_script_imports_and_default_export() {
        let dir = tempdir().unwrap();
        let path = dir.path().join("Card.svelte");
        fs::write(
            &path,
            r#"
<script lang="ts">
  import Button from './Button.vue'
  export let title = 'Settings'
</script>
<Button />
"#,
        )
        .unwrap();

        let facts = parse_module(&path).unwrap();
        assert!(
            facts
                .imports
                .iter()
                .any(|import| import.source == "./Button.vue" && import.local == "Button")
        );
        assert!(
            facts
                .exports
                .iter()
                .any(|export| export.exported == "default")
        );
    }
}