spinne_core/traverse/
project.rs

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
18/// Represents a project and its components.
19/// A Project is typically a repository with a package.json file.
20pub 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    /// Creates a new Project instance from a given path.
30    /// The path is expected to be the root of the project and should be a directory.
31    ///
32    /// # Panics
33    ///
34    /// - If the project root does not exist.
35    /// - If the project root is a file.
36    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    /// Traverses the project and tries to find components.
72    ///
73    /// # Arguments
74    ///
75    /// - `exclude`: A list of patterns to exclude from the traversal.
76    /// - `include`: A list of patterns to include in the traversal.
77    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        // merge the config with the exclude and include patterns
87        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        // we have to use a RwLock here because we are traversing the project in parallel
108        let project = RwLock::new(self);
109
110        // starting the traversal
111        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    /// Builds a walker with correct overrides and patterns.
131    ///
132    /// # Arguments
133    ///
134    /// - `exclude`: A list of patterns to exclude from the traversal.
135    /// - `include`: A list of patterns to include in the traversal.
136    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)) // Add '!' to each pattern
140            .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    /// Analyzes a file and adds the found components to the component graph.
162    fn analyze_file(&mut self, path: &Path) {
163        // if a file has no extension, we skip it
164        let extension = if let Some(ext) = path.extension() {
165            ext.to_string_lossy().to_string()
166        } else {
167            return;
168        };
169
170        // currently we only support tsx and ts files
171        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        // App has edge to Button
289        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        // App has edge to Button
342        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        // ButtonGroup has edge to Button
396        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}