spinne_core/traverse/
resolver.rs

1use std::path::PathBuf;
2
3use oxc_resolver::{Resolution, ResolveOptions, Resolver, TsconfigOptions, TsconfigReferences};
4
5#[derive(Clone)]
6pub struct ProjectResolver {
7    tsconfig_path: Option<PathBuf>,
8}
9
10impl ProjectResolver {
11    pub fn new(tsconfig_path: Option<PathBuf>) -> Self {
12        Self { tsconfig_path }
13    }
14
15    /// resolve a relative file path to an absolute file path
16    ///
17    /// dir: is the directory of the file that has the import statement
18    /// specifier: is the relative file path that the import statement is importing
19    /// tsconfig: is the path to the tsconfig.json file that contains the tsconfig options and tsconfigPaths
20    ///
21    /// # Example
22    ///
23    /// ```
24    /// use std::path::PathBuf;
25    /// use spinne_core::ProjectResolver;
26    ///
27    /// let dir = PathBuf::from("/Users/tim/projects/spinne/src/index.ts");
28    /// let resolver = ProjectResolver::new(None);
29    /// let resolution = resolver.resolve(&dir, "./components/Button");
30    /// ```
31    pub fn resolve(&self, dir: &PathBuf, specifier: &str) -> Result<Resolution, String> {
32        let options = ResolveOptions {
33            tsconfig: self.tsconfig_path.as_ref().map(|tsconfig| TsconfigOptions {
34                config_file: tsconfig.to_path_buf(),
35                references: TsconfigReferences::Auto,
36            }),
37            condition_names: vec![
38                "default".to_string(),
39                "types".to_string(),
40                "import".to_string(),
41                "require".to_string(),
42                "node".to_string(),
43                "node-addons".to_string(),
44                "browser".to_string(),
45                "esm2020".to_string(),
46                "es2020".to_string(),
47                "es2015".to_string(),
48            ],
49            extensions: vec![
50                ".ts".to_string(),
51                ".tsx".to_string(),
52                ".d.ts".to_string(),
53                ".js".to_string(),
54                ".jsx".to_string(),
55                ".mjs".to_string(),
56                ".cjs".to_string(),
57                ".json".to_string(),
58                ".node".to_string(),
59            ],
60            extension_alias: vec![
61                (
62                    ".js".to_string(),
63                    vec![
64                        ".ts".to_string(),
65                        ".tsx".to_string(),
66                        ".d.ts".to_string(),
67                        ".js".to_string(),
68                    ],
69                ),
70                (
71                    ".jsx".to_string(),
72                    vec![".tsx".to_string(), ".d.ts".to_string(), ".jsx".to_string()],
73                ),
74                (
75                    ".mjs".to_string(),
76                    vec![".mts".to_string(), ".mjs".to_string()],
77                ),
78                (
79                    ".cjs".to_string(),
80                    vec![".cts".to_string(), ".cjs".to_string()],
81                ),
82            ],
83            main_fields: vec![
84                "types".to_string(),
85                "typings".to_string(),
86                "module".to_string(),
87                "main".to_string(),
88                "browser".to_string(),
89                "jsnext:main".to_string(),
90            ],
91            alias_fields: vec![vec!["browser".to_string()]],
92            ..ResolveOptions::default()
93        };
94
95        match Resolver::new(options).resolve(dir, &specifier) {
96            Ok(resolved_path) => Ok(resolved_path),
97            Err(e) => Err(e.to_string()),
98        }
99    }
100}
101
102#[cfg(test)]
103mod tests {
104    use crate::util::test_utils;
105
106    use super::*;
107
108    #[test]
109    fn test_resolve_file_path() {
110        let temp_dir = test_utils::create_mock_project(&vec![
111            (
112                "src/components/Button.tsx",
113                "export function Button() { return <div>Button</div>; }",
114            ),
115            (
116                "src/components/index.ts",
117                "import { Button } from './Button';",
118            ),
119        ]);
120
121        let specifier = "./components/Button";
122        let resolver = ProjectResolver::new(None);
123        let resolution = resolver.resolve(&temp_dir.path().join("src"), &specifier);
124
125        assert!(resolution.is_ok());
126        assert_eq!(
127            resolution.unwrap().path(),
128            temp_dir.path().join("src/components/Button.tsx")
129        );
130    }
131
132    #[test]
133    fn test_resolve_file_path_with_tsconfig_paths() {
134        let temp_dir = test_utils::create_mock_project(&vec![
135            (
136                "tsconfig.json",
137                r#"{
138                "compilerOptions": {
139                "baseUrl": ".",
140                "paths": {
141                    "@components/*": ["./src/components/*"]
142                }
143            }
144            }"#,
145            ),
146            (
147                "src/components/Button.tsx",
148                "export function Button() { return <div>Button</div>; }",
149            ),
150            (
151                "src/components/index.ts",
152                "import { Button } from './Button';",
153            ),
154        ]);
155
156        let specifier = "@components/Button";
157        let resolver = ProjectResolver::new(Some(temp_dir.path().join("tsconfig.json")));
158        let resolution = resolver.resolve(&temp_dir.path().join("src"), &specifier);
159
160        assert!(resolution.is_ok());
161        assert_eq!(
162            resolution.unwrap().path(),
163            temp_dir.path().join("src/components/Button.tsx")
164        );
165    }
166
167    #[test]
168    fn test_resolve_file_path_with_node_modules() {
169        let temp_dir = test_utils::create_mock_project(&vec![
170            (
171                "node_modules/framer-motion/index.js",
172                "module.exports = { motion: () => <div>Framer Motion</div> };",
173            ),
174            (
175                "src/components/Button.tsx",
176                "export function Button() { return <div>Button</div>; }",
177            ),
178            (
179                "src/components/index.ts",
180                "import { Button } from './Button';",
181            ),
182        ]);
183
184        let specifier = "framer-motion";
185        let resolver = ProjectResolver::new(None);
186        let resolution = resolver.resolve(&temp_dir.path().join("src"), &specifier);
187
188        assert!(resolution.is_ok());
189        assert_eq!(
190            resolution.unwrap().path(),
191            temp_dir.path().join("node_modules/framer-motion/index.js")
192        );
193    }
194
195    #[test]
196    fn test_resolve_file_path_with_node_modules_in_sub_directory() {
197        let temp_dir = test_utils::create_mock_project(&vec![
198            (
199                "node_modules/@radix-ui/react-accordion/package.json",
200                r#"{
201                    "name": "@radix-ui/react-accordion",
202                    "main": "dist/index.js"
203                }"#,
204            ),
205            (
206                "node_modules/@radix-ui/react-accordion/dist/index.js",
207                "module.exports = { Accordion: () => <div>Accordion</div> };",
208            ),
209            (
210                "src/components/Button.tsx",
211                "export function Button() { return <div>Button</div>; }",
212            ),
213            (
214                "src/components/index.ts",
215                "import { Button } from './Button';",
216            ),
217        ]);
218
219        let specifier = "@radix-ui/react-accordion";
220        let resolver = ProjectResolver::new(None);
221        let resolution = resolver.resolve(&temp_dir.path().join("src"), &specifier);
222
223        assert!(resolution.is_ok());
224        assert_eq!(
225            resolution.unwrap().path(),
226            temp_dir
227                .path()
228                .join("node_modules/@radix-ui/react-accordion/dist/index.js")
229        );
230    }
231
232    #[test]
233    fn test_resolve_node16_imports() {
234        let temp_dir = test_utils::create_mock_project(&vec![
235            (
236                "package.json",
237                r#"{
238                "type": "module"
239            }"#,
240            ),
241            (
242                "tsconfig.json",
243                r#"{
244                "compilerOptions": {
245                    "baseUrl": ".",
246                    "module": "NodeNext",
247                    "moduleResolution": "NodeNext",
248                    "paths": {
249                        "@components/*": ["./src/components/*"]
250                    }
251                }
252            }"#,
253            ),
254            (
255                "src/components/index.ts",
256                "export { Button } from './Button';",
257            ),
258            (
259                "src/components/Button/index.ts",
260                "export function Button() { return <div>Button</div>; }",
261            ),
262            (
263                "src/components/Button.tsx",
264                "export function Button() { return <div>Button</div>; }",
265            ),
266        ]);
267
268        let specifier = "./components/Button.js";
269        let resolver = ProjectResolver::new(Some(temp_dir.path().join("tsconfig.json")));
270        let resolution = resolver.resolve(&temp_dir.path().join("src"), &specifier);
271
272        assert!(resolution.is_ok());
273        assert_eq!(
274            resolution.unwrap().path(),
275            temp_dir.path().join("src/components/Button.tsx")
276        );
277
278        let specifier = "./components/index.js";
279        let resolver = ProjectResolver::new(Some(temp_dir.path().join("tsconfig.json")));
280        let resolution = resolver.resolve(&temp_dir.path().join("src"), &specifier);
281
282        assert!(resolution.is_ok());
283        assert_eq!(
284            resolution.unwrap().path(),
285            temp_dir.path().join("src/components/index.ts")
286        );
287
288        let specifier = "./components/Button/index.js";
289        let resolver = ProjectResolver::new(Some(temp_dir.path().join("tsconfig.json")));
290        let resolution = resolver.resolve(&temp_dir.path().join("src"), &specifier);
291
292        assert!(resolution.is_ok());
293        assert_eq!(
294            resolution.unwrap().path(),
295            temp_dir.path().join("src/components/Button/index.ts")
296        );
297
298        let specifier = "@components/Button/index.js";
299        let resolver = ProjectResolver::new(Some(temp_dir.path().join("tsconfig.json")));
300        let resolution = resolver.resolve(&temp_dir.path().join("src"), &specifier);
301
302        assert!(resolution.is_ok());
303        assert_eq!(
304            resolution.unwrap().path(),
305            temp_dir.path().join("src/components/Button/index.ts")
306        );
307    }
308}