1use std::{
2 fs,
3 path::{Path, PathBuf},
4 sync::RwLock,
5};
6
7use ignore::{overrides::OverrideBuilder, DirEntry, Error, WalkBuilder, WalkParallel, WalkState};
8use oxc_allocator::Allocator;
9use spinne_logger::Logger;
10
11use crate::{
12 analyze::react::analyzer::ReactAnalyzer, config::ConfigValues, parse::parse_tsx,
13 util::replace_absolute_path_with_project_name, ComponentGraph, Config, PackageJson,
14};
15
16use super::ProjectResolver;
17
18pub struct Project {
21 pub project_root: PathBuf,
22 pub project_name: String,
23 pub component_graph: ComponentGraph,
24 resolver: ProjectResolver,
25 config: Option<ConfigValues>,
26}
27
28impl Project {
29 pub fn new(project_root: PathBuf) -> Self {
37 if !project_root.exists() {
38 panic!("Project root does not exist");
39 }
40
41 if project_root.is_file() {
42 panic!("Project root is a file");
43 }
44
45 let package_json = PackageJson::read(project_root.join("package.json"))
46 .expect("Failed to read package.json");
47
48 let project_name = package_json.name.unwrap_or_else(|| {
49 Logger::warn(&format!("No project name found in package.json"));
50 project_root.to_string_lossy().to_string()
51 });
52
53 let tsconfig_path = project_root.join("tsconfig.json");
54 let resolver = if tsconfig_path.exists() {
55 ProjectResolver::new(Some(tsconfig_path))
56 } else {
57 ProjectResolver::new(None)
58 };
59
60 let config = Config::read(project_root.join("spinne.json"));
61
62 Self {
63 project_root,
64 project_name,
65 component_graph: ComponentGraph::new(),
66 resolver,
67 config,
68 }
69 }
70
71 pub fn traverse(&mut self, exclude_params: &Vec<String>, include_params: &Vec<String>) {
78 Logger::info(&format!(
79 "Starting traversal of project: {}",
80 self.project_name
81 ));
82
83 let mut exclude = exclude_params.clone();
84 let mut include = include_params.clone();
85
86 if let Some(config) = &self.config {
88 exclude = match &config.exclude {
89 Some(exclude) => {
90 let mut exclude_vec = exclude.clone();
91 exclude_vec.extend(exclude_params.iter().map(|e| e.to_string()));
92 exclude_vec
93 }
94 None => exclude.clone(),
95 };
96 include = match &config.include {
97 Some(include) => {
98 let mut include_vec = include.clone();
99 include_vec.extend(include_params.iter().map(|e| e.to_string()));
100 include_vec
101 }
102 None => include.clone(),
103 };
104 }
105
106 let walker = self.build_walker(&exclude, &include);
107 let project = RwLock::new(self);
109
110 walker.run(|| {
112 Box::new(|result: Result<DirEntry, Error>| {
113 match result {
114 Ok(entry) => {
115 let path = entry.path();
116
117 if path.is_file() {
118 Logger::debug(&format!("Analyzing file: {}", path.display()), 2);
119 project.write().unwrap().analyze_file(&path);
120 }
121 }
122 Err(e) => Logger::error(&format!("Error while walking file: {}", e)),
123 }
124
125 WalkState::Continue
126 })
127 });
128 }
129
130 fn build_walker(&self, exclude: &Vec<String>, include: &Vec<String>) -> WalkParallel {
137 let exclude_patterns: Vec<String> = exclude
138 .iter()
139 .map(|pattern| format!("!{}", pattern)) .collect();
141
142 let mut override_builder = OverrideBuilder::new(&self.project_root);
143
144 for pattern in include {
145 override_builder.add(pattern).unwrap();
146 }
147 for pattern in &exclude_patterns {
148 override_builder.add(pattern).unwrap();
149 }
150 let overrides = override_builder.build().unwrap();
151
152 Logger::debug(&format!("Walking using include patterns: {:?}", include), 1);
153 Logger::debug(&format!("Walking using exclude patterns: {:?}", exclude), 1);
154
155 WalkBuilder::new(&self.project_root)
156 .git_ignore(true)
157 .overrides(overrides)
158 .build_parallel()
159 }
160
161 fn analyze_file(&mut self, path: &Path) {
163 let extension = if let Some(ext) = path.extension() {
165 ext.to_string_lossy().to_string()
166 } else {
167 return;
168 };
169
170 if extension != "tsx" && extension != "ts" {
172 return;
173 }
174
175 let allocator = Allocator::default();
176 let path_buf = PathBuf::from(path);
177 let source_code = fs::read_to_string(&path_buf).unwrap();
178
179 Logger::debug(&format!("Parsing file: {}", path.display()), 2);
180 let result = parse_tsx(&allocator, &path_buf, &source_code);
181
182 if result.is_err() {
183 Logger::error(&format!("Failed to parse file: {}", path.display()));
184 return;
185 }
186
187 let (_parser_ret, semantic_ret) = result.unwrap();
188
189 let react_analyzer = ReactAnalyzer::new(&semantic_ret.semantic, path_buf, &self.resolver);
190 let components = react_analyzer.analyze();
191
192 for component in components {
193 let path_relative = replace_absolute_path_with_project_name(
194 self.project_root.clone(),
195 component.file_path.clone(),
196 &self.project_name,
197 );
198
199 self.component_graph
200 .add_component(component.name.clone(), path_relative.clone());
201
202 for child in component.children {
203 let child_path_relative = replace_absolute_path_with_project_name(
204 self.project_root.clone(),
205 child.origin_file_path.clone(),
206 &self.project_name,
207 );
208
209 self.component_graph.add_child(
210 (&component.name, &path_relative),
211 (&child.name, &child_path_relative),
212 );
213 }
214 }
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use crate::util::test_utils;
221
222 use super::*;
223
224 #[test]
225 fn test_project() {
226 let temp_dir = test_utils::create_mock_project(&vec![
227 ("package.json", r#"{"name": "test"}"#),
228 ("tsconfig.json", "{}"),
229 (
230 "src/index.tsx",
231 r#"
232 import React from 'react';
233
234 const App: React.FC = () => { return <div>Hello World</div>; }
235 "#,
236 ),
237 ]);
238
239 let mut project = Project::new(temp_dir.path().to_path_buf());
240 project.traverse(
241 &vec![],
242 &vec!["**/*.tsx".to_string(), "**/*.ts".to_string()],
243 );
244
245 assert_eq!(project.component_graph.graph.node_count(), 1);
246 assert!(project
247 .component_graph
248 .has_component("App", &PathBuf::from("test/src/index.tsx")));
249 }
250
251 #[test]
252 fn test_component_graph() {
253 let temp_dir = test_utils::create_mock_project(&vec![
254 ("package.json", r#"{"name": "test"}"#),
255 ("tsconfig.json", "{}"),
256 (
257 "src/index.tsx",
258 r#"
259 import React from 'react';
260 import { Button } from './components/Button';
261
262 export const App: React.FC = () => { return <div><Button /></div>; }
263 "#,
264 ),
265 (
266 "src/components/Button.tsx",
267 r#"
268 import React from 'react';
269 export const Button: React.FC = () => { return <button>Click me</button>; }
270 "#,
271 ),
272 ]);
273
274 let mut project = Project::new(temp_dir.path().to_path_buf());
275 project.traverse(
276 &vec![],
277 &vec!["**/*.tsx".to_string(), "**/*.ts".to_string()],
278 );
279
280 assert_eq!(project.component_graph.graph.node_count(), 2);
281 assert!(project
282 .component_graph
283 .has_component("App", &PathBuf::from("test/src/index.tsx")));
284 assert!(project
285 .component_graph
286 .has_component("Button", &PathBuf::from("test/src/components/Button.tsx")));
287
288 assert!(project.component_graph.has_edge(
290 project
291 .component_graph
292 .get_component("App", &PathBuf::from("test/src/index.tsx"))
293 .unwrap(),
294 project
295 .component_graph
296 .get_component("Button", &PathBuf::from("test/src/components/Button.tsx"))
297 .unwrap(),
298 ));
299 }
300
301 #[test]
302 fn test_component_graph_with_tsconfig() {
303 let temp_dir = test_utils::create_mock_project(&vec![
304 ("package.json", r#"{"name": "test"}"#),
305 (
306 "tsconfig.json",
307 r#"{"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}"#,
308 ),
309 (
310 "src/index.tsx",
311 r#"
312 import React from 'react';
313 import { Button } from '@/components/Button';
314
315 export const App: React.FC = () => { return <div><Button /></div>; }
316 "#,
317 ),
318 (
319 "src/components/Button.tsx",
320 r#"
321 import React from 'react';
322 export const Button: React.FC = () => { return <button>Click me</button>; }
323 "#,
324 ),
325 ]);
326
327 let mut project = Project::new(temp_dir.path().to_path_buf());
328 project.traverse(
329 &vec![],
330 &vec!["**/*.tsx".to_string(), "**/*.ts".to_string()],
331 );
332
333 assert_eq!(project.component_graph.graph.node_count(), 2);
334 assert!(project
335 .component_graph
336 .has_component("App", &PathBuf::from("test/src/index.tsx")));
337 assert!(project
338 .component_graph
339 .has_component("Button", &PathBuf::from("test/src/components/Button.tsx")));
340
341 assert!(project.component_graph.has_edge(
343 project
344 .component_graph
345 .get_component("App", &PathBuf::from("test/src/index.tsx"))
346 .unwrap(),
347 project
348 .component_graph
349 .get_component("Button", &PathBuf::from("test/src/components/Button.tsx"))
350 .unwrap(),
351 ));
352 }
353
354 #[test]
355 fn test_component_graph_with_tsconfig_and_tsx() {
356 let temp_dir = test_utils::create_mock_project(&vec![
357 ("package.json", r#"{"name": "test"}"#),
358 (
359 "tsconfig.json",
360 r#"{"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}"#,
361 ),
362 (
363 "src/components/Button/ButtonGroup.tsx",
364 r#"
365 import React from 'react';
366 import { Button } from '@/components/Button';
367
368 export const ButtonGroup: React.FC<React.PropsWithChildren> = ({ children }) => { return <Button>{children}</Button>; }
369 "#,
370 ),
371 (
372 "src/components/Button.tsx",
373 r#"
374 import React from 'react';
375 export const Button = () => { return "HI"; }
376 "#,
377 ),
378 ]);
379
380 let mut project = Project::new(temp_dir.path().to_path_buf());
381 project.traverse(
382 &vec![],
383 &vec!["**/*.tsx".to_string(), "**/*.ts".to_string()],
384 );
385
386 assert_eq!(project.component_graph.graph.node_count(), 2);
387 assert!(project.component_graph.has_component(
388 "ButtonGroup",
389 &PathBuf::from("test/src/components/Button/ButtonGroup.tsx")
390 ));
391 assert!(project
392 .component_graph
393 .has_component("Button", &PathBuf::from("test/src/components/Button.tsx")));
394
395 assert!(project.component_graph.has_edge(
397 project
398 .component_graph
399 .get_component(
400 "ButtonGroup",
401 &PathBuf::from("test/src/components/Button/ButtonGroup.tsx")
402 )
403 .unwrap(),
404 project
405 .component_graph
406 .get_component("Button", &PathBuf::from("test/src/components/Button.tsx"))
407 .unwrap(),
408 ));
409 }
410
411 #[test]
412 fn should_read_exclude_from_config() {
413 let temp_dir = test_utils::create_mock_project(&vec![
414 ("package.json", r#"{"name": "test"}"#),
415 (
416 "spinne.json",
417 r#"{"exclude": ["src/components/Button/ButtonGroup.tsx"]}"#,
418 ),
419 (
420 "tsconfig.json",
421 r#"{"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}"#,
422 ),
423 (
424 "src/components/Button/ButtonGroup.tsx",
425 r#"
426 import React from 'react';
427 import { Button } from '@/components/Button';
428
429 export const ButtonGroup: React.FC<React.PropsWithChildren> = ({ children }) => { return <Button>{children}</Button>; }
430 "#,
431 ),
432 (
433 "src/pages/Button.tsx",
434 r#"
435 import React from 'react';
436 export const Button = () => { return "HI"; }
437 "#,
438 ),
439 ]);
440
441 let mut project = Project::new(temp_dir.path().to_path_buf());
442 project.traverse(
443 &vec![],
444 &vec!["**/*.tsx".to_string(), "**/*.ts".to_string()],
445 );
446
447 assert_eq!(project.component_graph.graph.node_count(), 1);
448 }
449
450 #[test]
451 fn should_merge_exclude_from_config() {
452 let temp_dir = test_utils::create_mock_project(&vec![
453 ("package.json", r#"{"name": "test"}"#),
454 (
455 "spinne.json",
456 r#"{"exclude": ["src/components/Button/ButtonGroup.tsx"]}"#,
457 ),
458 (
459 "tsconfig.json",
460 r#"{"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}"#,
461 ),
462 (
463 "src/components/Button/ButtonGroup.tsx",
464 r#"
465 import React from 'react';
466 import { Button } from '@/components/Button';
467
468 export const ButtonGroup: React.FC<React.PropsWithChildren> = ({ children }) => { return <Button>{children}</Button>; }
469 "#,
470 ),
471 (
472 "src/pages/Button.tsx",
473 r#"
474 import React from 'react';
475 export const Button = () => { return "HI"; }
476 "#,
477 ),
478 ]);
479
480 let mut project = Project::new(temp_dir.path().to_path_buf());
481 project.traverse(
482 &vec![String::from("src/pages/Button.tsx")],
483 &vec!["**/*.tsx".to_string(), "**/*.ts".to_string()],
484 );
485
486 assert_eq!(project.component_graph.graph.node_count(), 0);
487 }
488
489 #[test]
490 fn should_read_include_from_config() {
491 let temp_dir = test_utils::create_mock_project(&vec![
492 ("package.json", r#"{"name": "test"}"#),
493 (
494 "spinne.json",
495 r#"{"include": ["src/components/Button/ButtonGroup.tsx"]}"#,
496 ),
497 (
498 "tsconfig.json",
499 r#"{"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}"#,
500 ),
501 (
502 "src/components/Button/ButtonGroup.tsx",
503 r#"
504 import React from 'react';
505
506 export const ButtonGroup: React.FC<React.PropsWithChildren> = ({ children }) => { return <button>{children}</button>; }
507 "#,
508 ),
509 (
510 "src/pages/Button.tsx",
511 r#"
512 import React from 'react';
513 export const Button = () => { return "HI"; }
514 "#,
515 ),
516 ]);
517
518 let mut project = Project::new(temp_dir.path().to_path_buf());
519 project.traverse(&vec![], &vec![]);
520
521 assert_eq!(project.component_graph.graph.node_count(), 1);
522 }
523
524 #[test]
525 fn should_merge_include_from_config() {
526 let temp_dir = test_utils::create_mock_project(&vec![
527 ("package.json", r#"{"name": "test"}"#),
528 (
529 "spinne.json",
530 r#"{"include": ["src/components/Button/ButtonGroup.tsx"]}"#,
531 ),
532 (
533 "tsconfig.json",
534 r#"{"compilerOptions": {"baseUrl": ".", "paths": {"@/*": ["src/*"]}}}"#,
535 ),
536 (
537 "src/components/Button/ButtonGroup.tsx",
538 r#"
539 import React from 'react';
540
541 export const ButtonGroup: React.FC<React.PropsWithChildren> = ({ children }) => { return <button>{children}</button>; }
542 "#,
543 ),
544 (
545 "src/pages/Button.tsx",
546 r#"
547 import React from 'react';
548 export const Button = () => { return "HI"; }
549 "#,
550 ),
551 ]);
552
553 let mut project = Project::new(temp_dir.path().to_path_buf());
554 project.traverse(&vec![], &vec!["src/pages/Button.tsx".to_string()]);
555
556 assert_eq!(project.component_graph.graph.node_count(), 2);
557 }
558}