blast_radius/parse/
mod.rs1use std::fs;
2use std::path::Path;
3
4use anyhow::{Context, Result};
5mod facts;
6pub use facts::*;
7
8mod javascript;
9pub(crate) use javascript::parse_javascript_module;
10
11#[cfg(any(feature = "vue", feature = "svelte"))]
12mod component;
13#[cfg(any(feature = "vue", feature = "svelte"))]
14pub(crate) use component::parse_component_module;
15
16#[cfg(feature = "python")]
17mod python;
18#[cfg(feature = "python")]
19pub(crate) use python::parse_python_module;
20
21#[cfg(feature = "rust")]
22mod rust_lang;
23#[cfg(feature = "rust")]
24pub(crate) use rust_lang::parse_rust_module;
25
26pub fn parse_module(path: &Path) -> Result<ModuleFacts> {
27 let source = fs::read_to_string(path)
28 .with_context(|| format!("failed to read source file {}", path.display()))?;
29
30 crate::language::adapter_for(path).parse(path, &source)
31}
32
33#[cfg(test)]
34mod tests {
35 use std::fs;
36
37 use tempfile::tempdir;
38
39 use super::parse_module;
40
41 #[test]
42 fn parses_js_files_with_jsx() {
43 let dir = tempdir().unwrap();
44 let path = dir.path().join("renderAvatar.js");
45 fs::write(
46 &path,
47 r#"
48import Avatar from '@mui/material/Avatar';
49
50export function renderAvatar(params) {
51 if (params.value == null) {
52 return '';
53 }
54
55 return <Avatar>{params.value.name}</Avatar>;
56}
57"#,
58 )
59 .unwrap();
60
61 let facts = parse_module(&path).unwrap();
62 assert_eq!(facts.imports.len(), 1);
63 assert!(
64 facts
65 .exports
66 .iter()
67 .any(|export| export.exported == "renderAvatar")
68 );
69 }
70
71 #[test]
72 fn parses_dynamic_imports_as_used_namespace_imports() {
73 let dir = tempdir().unwrap();
74 let path = dir.path().join("routes.tsx");
75 fs::write(
76 &path,
77 r#"
78import { lazy } from 'react';
79
80const ChatsPage = lazy(() =>
81 import("@/pages/chats-page").then((m) => ({ default: m.ChatsPage }))
82);
83const Settings = lazy(() => import(`./settings-page`));
84
85export const routes = [ChatsPage, Settings];
86"#,
87 )
88 .unwrap();
89
90 let facts = parse_module(&path).unwrap();
91 for source in ["@/pages/chats-page", "./settings-page"] {
92 let import = facts
93 .imports
94 .iter()
95 .find(|import| import.source == source)
96 .expect("dynamic import should be collected");
97 assert_eq!(import.kind, super::ImportKind::Dynamic);
98 assert_eq!(import.imported, super::ImportTarget::Namespace);
99 assert!(facts.used_locals.contains(&import.local));
101 }
102 }
103
104 #[test]
105 fn parses_modern_module_extensions() {
106 let dir = tempdir().unwrap();
107
108 let mjs_path = dir.path().join("widget.mjs");
109 fs::write(&mjs_path, "export const widget = <div />;").unwrap();
110 parse_module(&mjs_path).unwrap();
111
112 let cts_path = dir.path().join("server.cts");
113 fs::write(&cts_path, "export const server = 1;").unwrap();
114 parse_module(&cts_path).unwrap();
115 }
116
117 #[cfg(feature = "python")]
118 #[test]
119 fn parses_python_imports_and_exports() {
120 let dir = tempdir().unwrap();
121 let path = dir.path().join("email.py");
122 fs::write(
123 &path,
124 r#"
125from ..models import User
126from . import formatting
127
128DEFAULT_TEMPLATE = "welcome"
129
130def send_email(user: User) -> str:
131 return formatting.format_subject(user.email, DEFAULT_TEMPLATE)
132"#,
133 )
134 .unwrap();
135
136 let facts = parse_module(&path).unwrap();
137 assert!(
138 facts
139 .exports
140 .iter()
141 .any(|export| export.exported == "send_email")
142 );
143 assert!(
144 facts
145 .imports
146 .iter()
147 .any(|import| import.source == "..models" && import.local == "User")
148 );
149 assert!(
150 facts
151 .imports
152 .iter()
153 .any(|import| import.source == ".formatting" && import.local == "formatting")
154 );
155 }
156
157 #[cfg(feature = "rust")]
158 #[test]
159 fn parses_rust_imports_exports_and_reexports() {
160 let dir = tempdir().unwrap();
161 let path = dir.path().join("lib.rs");
162 fs::write(
163 &path,
164 r#"
165pub mod services;
166
167use crate::models::User;
168pub use crate::services::email::send_email;
169
170pub struct App;
171"#,
172 )
173 .unwrap();
174
175 let facts = parse_module(&path).unwrap();
176 assert!(facts.exports.iter().any(|export| export.exported == "App"));
177 assert!(
178 facts
179 .imports
180 .iter()
181 .any(|import| import.source == "mod:services")
182 );
183 assert!(
184 facts
185 .imports
186 .iter()
187 .any(|import| import.source == "crate::models" && import.local == "User")
188 );
189 assert!(
190 facts
191 .reexports
192 .iter()
193 .any(|reexport| reexport.source == "crate::services::email"
194 && reexport.exported == "send_email")
195 );
196 }
197
198 #[cfg(feature = "vue")]
199 #[test]
200 fn parses_vue_script_imports_and_default_export() {
201 let dir = tempdir().unwrap();
202 let path = dir.path().join("Button.vue");
203 fs::write(
204 &path,
205 r#"
206<script setup lang="ts">
207import { formatLabel } from './shared'
208const label = formatLabel('save')
209</script>
210<template><button>{{ label }}</button></template>
211"#,
212 )
213 .unwrap();
214
215 let facts = parse_module(&path).unwrap();
216 assert!(
217 facts
218 .imports
219 .iter()
220 .any(|import| import.source == "./shared" && import.local == "formatLabel")
221 );
222 assert!(
223 facts
224 .exports
225 .iter()
226 .any(|export| export.exported == "default")
227 );
228 }
229
230 #[cfg(feature = "svelte")]
231 #[test]
232 fn parses_svelte_script_imports_and_default_export() {
233 let dir = tempdir().unwrap();
234 let path = dir.path().join("Card.svelte");
235 fs::write(
236 &path,
237 r#"
238<script lang="ts">
239 import Button from './Button.vue'
240 export let title = 'Settings'
241</script>
242<Button />
243"#,
244 )
245 .unwrap();
246
247 let facts = parse_module(&path).unwrap();
248 assert!(
249 facts
250 .imports
251 .iter()
252 .any(|import| import.source == "./Button.vue" && import.local == "Button")
253 );
254 assert!(
255 facts
256 .exports
257 .iter()
258 .any(|export| export.exported == "default")
259 );
260 }
261}