Skip to main content

fallow_graph/resolve/
jsx_runtime.rs

1//! Graph edges for a JSX import source that a bundler or test config sets.
2//!
3//! With the automatic JSX runtime, the transform adds an import of
4//! `<source>/jsx-dev-runtime` to each file with JSX. Vitest runs the
5//! transform in dev mode, so the dev runtime is the module that the test
6//! files import. No import statement shows that edge, so this pass adds it
7//! from the plugin rules.
8
9use 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
20/// The runtime module that the automatic JSX transform imports in dev mode.
21const JSX_DEV_RUNTIME_SUBPATH: &str = "jsx-dev-runtime";
22
23/// The bindings that the automatic JSX transform imports from
24/// `<source>/jsx-dev-runtime`.
25const JSX_DEV_RUNTIME_BINDINGS: [&str; 2] = ["jsxDEV", "Fragment"];
26
27/// One config JSX import source with its compiled include and exclude globs.
28pub struct CompiledJsxRule<'a> {
29    rule: &'a JsxImportSourceRule,
30    include: GlobSet,
31    exclude: GlobSet,
32}
33
34impl<'a> CompiledJsxRule<'a> {
35    /// The rule that these globs come from.
36    #[must_use]
37    pub const fn rule(&self) -> &'a JsxImportSourceRule {
38        self.rule
39    }
40
41    /// Whether the config transforms the file at `file_path`: its path
42    /// relative to the config directory matches an include glob and no
43    /// exclude glob.
44    #[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/// Compile the globs of each rule. A rule without a valid include glob
55/// matches no file, so it is dropped. An exclude glob that does not compile
56/// is skipped.
57#[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
76/// Compile the valid patterns into one set, with the number of patterns
77/// that compiled. A pattern can have an empty alternative, such as `{,m}js`,
78/// because the rewrite of an extglob `?(m)` group gives one.
79fn 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
98/// Resolve the config JSX runtime imports of one module.
99///
100/// A module gets an edge only when extraction set `jsx_runtime_from_config`
101/// (JSX and no runtime pragma) and its path relative to the config directory
102/// matches the include globs and no exclude glob of a rule. A relative
103/// source that does not resolve from the module resolves from the config
104/// directory, as Vite does.
105/// A source that resolves from neither place adds no edge, so a config value
106/// never causes an unresolved-import finding.
107pub(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    // The resolver reads a leading `/` as root-relative, so the fallback
162    // spells the config-relative target relative to the module directory.
163    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
174/// The specifier that the automatic JSX transform imports in dev mode:
175/// `<source>/jsx-dev-runtime`, with one separator.
176fn 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
184/// Spell `specifier`, relative to `config_dir`, as a specifier relative to
185/// `file_dir`. `None` when the two directories share no root, for example on
186/// two Windows drives.
187fn 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
209/// Join a relative specifier to a directory and remove `.` and `..`
210/// segments.
211fn 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}