fallow_graph/resolve/
jsx_runtime.rs1use std::path::{Component, Path, PathBuf};
10
11use fallow_config::JsxImportSourceRule;
12use fallow_types::extract::{ImportInfo, ImportedName, ModuleInfo};
13use globset::{GlobBuilder, GlobSet, GlobSetBuilder};
14use oxc_span::Span;
15use rustc_hash::FxHashSet;
16
17use super::specifier::resolve_import_specifier;
18use super::types::{ResolveContext, ResolveResult, ResolvedImport};
19
20const JSX_DEV_RUNTIME_SUBPATH: &str = "jsx-dev-runtime";
22
23const JSX_DEV_RUNTIME_BINDINGS: [&str; 2] = ["jsxDEV", "Fragment"];
26
27pub struct CompiledJsxRule<'a> {
29 rule: &'a JsxImportSourceRule,
30 include: GlobSet,
31 exclude: GlobSet,
32}
33
34impl<'a> CompiledJsxRule<'a> {
35 #[must_use]
37 pub const fn rule(&self) -> &'a JsxImportSourceRule {
38 self.rule
39 }
40
41 #[must_use]
45 pub fn matches(&self, file_path: &Path) -> bool {
46 file_path
47 .strip_prefix(&self.rule.config_dir)
48 .is_ok_and(|relative| {
49 self.include.is_match(relative) && !self.exclude.is_match(relative)
50 })
51 }
52}
53
54#[must_use]
58pub fn compile_jsx_rules(rules: &[JsxImportSourceRule]) -> Vec<CompiledJsxRule<'_>> {
59 rules
60 .iter()
61 .filter_map(|rule| {
62 let (include, added) = compile_globs(&rule.include);
63 if added == 0 {
64 return None;
65 }
66 let (exclude, _) = compile_globs(&rule.exclude);
67 Some(CompiledJsxRule {
68 rule,
69 include,
70 exclude,
71 })
72 })
73 .collect()
74}
75
76fn compile_globs(patterns: &[String]) -> (GlobSet, usize) {
80 let mut builder = GlobSetBuilder::new();
81 let mut added = 0;
82 for pattern in patterns {
83 if let Ok(glob) = GlobBuilder::new(pattern)
84 .literal_separator(true)
85 .empty_alternates(true)
86 .build()
87 {
88 builder.add(glob);
89 added += 1;
90 }
91 }
92 match builder.build() {
93 Ok(set) => (set, added),
94 Err(_) => (GlobSet::empty(), 0),
95 }
96}
97
98pub(super) fn resolve_config_jsx_runtime_imports(
108 ctx: &ResolveContext<'_>,
109 file_path: &Path,
110 module: &ModuleInfo,
111 rules: &[CompiledJsxRule<'_>],
112) -> Vec<ResolvedImport> {
113 if !module.jsx_runtime_from_config || rules.is_empty() {
114 return Vec::new();
115 }
116 let mut seen: FxHashSet<&str> = FxHashSet::default();
117 let mut imports = Vec::new();
118 for compiled in rules {
119 let rule = compiled.rule;
120 if !compiled.matches(file_path) || !seen.insert(rule.source.as_str()) {
121 continue;
122 }
123 let specifier = jsx_dev_runtime_specifier(&rule.source);
124 let Some(target) = resolve_runtime(ctx, file_path, rule, &specifier) else {
125 continue;
126 };
127 imports.extend(
128 JSX_DEV_RUNTIME_BINDINGS
129 .iter()
130 .map(|binding| ResolvedImport {
131 info: ImportInfo {
132 source: specifier.clone(),
133 imported_name: ImportedName::Named((*binding).to_string()),
134 local_name: String::new(),
135 is_type_only: false,
136 is_type_only_star: false,
137 from_style: false,
138 span: Span::default(),
139 source_span: Span::default(),
140 },
141 target: target.clone(),
142 }),
143 );
144 }
145 imports
146}
147
148fn resolve_runtime(
149 ctx: &ResolveContext<'_>,
150 file_path: &Path,
151 rule: &JsxImportSourceRule,
152 specifier: &str,
153) -> Option<ResolveResult> {
154 let from_file = resolve_import_specifier(ctx, file_path, specifier, false);
155 if !matches!(from_file, ResolveResult::Unresolvable(_)) {
156 return Some(from_file);
157 }
158 if !is_relative(&rule.source) {
159 return None;
160 }
161 let from_config = config_relative_specifier(file_path.parent()?, &rule.config_dir, specifier)?;
164 match resolve_import_specifier(ctx, file_path, &from_config, false) {
165 ResolveResult::Unresolvable(_) => None,
166 target => Some(target),
167 }
168}
169
170fn is_relative(source: &str) -> bool {
171 matches!(source, "." | "..") || source.starts_with("./") || source.starts_with("../")
172}
173
174fn jsx_dev_runtime_specifier(import_source: &str) -> String {
177 let base = import_source.trim_end_matches('/');
178 if base.is_empty() {
179 return format!("/{JSX_DEV_RUNTIME_SUBPATH}");
180 }
181 format!("{base}/{JSX_DEV_RUNTIME_SUBPATH}")
182}
183
184fn config_relative_specifier(
188 file_dir: &Path,
189 config_dir: &Path,
190 specifier: &str,
191) -> Option<String> {
192 let target = lexical_join(config_dir, specifier);
193 let from: Vec<Component<'_>> = file_dir.components().collect();
194 let to: Vec<Component<'_>> = target.components().collect();
195 let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
196 if common == 0 {
197 return None;
198 }
199 let mut parts: Vec<String> = vec!["..".to_string(); from.len() - common];
200 if parts.is_empty() {
201 parts.push(".".to_string());
202 }
203 for component in &to[common..] {
204 parts.push(component.as_os_str().to_str()?.to_string());
205 }
206 Some(parts.join("/"))
207}
208
209fn lexical_join(dir: &Path, relative: &str) -> PathBuf {
212 let mut joined = dir.to_path_buf();
213 for component in Path::new(relative).components() {
214 match component {
215 Component::CurDir => {}
216 Component::ParentDir => {
217 joined.pop();
218 }
219 other => joined.push(other.as_os_str()),
220 }
221 }
222 joined
223}
224
225#[cfg(test)]
226mod tests {
227 use super::*;
228
229 #[test]
230 fn dev_runtime_specifier_has_one_separator() {
231 assert_eq!(
232 jsx_dev_runtime_specifier("preact"),
233 "preact/jsx-dev-runtime"
234 );
235 assert_eq!(
236 jsx_dev_runtime_specifier("./src/jsx/"),
237 "./src/jsx/jsx-dev-runtime"
238 );
239 assert_eq!(jsx_dev_runtime_specifier("."), "./jsx-dev-runtime");
240 }
241
242 #[test]
243 fn lexical_join_removes_dot_segments() {
244 assert_eq!(
245 lexical_join(Path::new("/project/app"), "../src/./jsx/jsx-dev-runtime"),
246 PathBuf::from("/project/src/jsx/jsx-dev-runtime")
247 );
248 }
249
250 #[test]
251 fn config_relative_specifier_starts_from_the_module_directory() {
252 assert_eq!(
253 config_relative_specifier(
254 Path::new("/project/src/deep"),
255 Path::new("/project"),
256 "./src/jsx/jsx-dev-runtime"
257 )
258 .as_deref(),
259 Some("../jsx/jsx-dev-runtime")
260 );
261 assert_eq!(
262 config_relative_specifier(Path::new("/project"), Path::new("/project"), "./jsx/x")
263 .as_deref(),
264 Some("./jsx/x")
265 );
266 }
267
268 #[test]
269 fn relative_sources() {
270 assert!(is_relative("./src/jsx"));
271 assert!(is_relative("../jsx"));
272 assert!(is_relative("."));
273 assert!(!is_relative("preact"));
274 assert!(!is_relative("@emotion/react"));
275 }
276}