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.
78fn compile_globs(patterns: &[String]) -> (GlobSet, usize) {
79    let mut builder = GlobSetBuilder::new();
80    let mut added = 0;
81    for pattern in patterns {
82        if let Ok(glob) = GlobBuilder::new(pattern).literal_separator(true).build() {
83            builder.add(glob);
84            added += 1;
85        }
86    }
87    match builder.build() {
88        Ok(set) => (set, added),
89        Err(_) => (GlobSet::empty(), 0),
90    }
91}
92
93/// Resolve the config JSX runtime imports of one module.
94///
95/// A module gets an edge only when extraction set `jsx_runtime_from_config`
96/// (JSX and no runtime pragma) and its path relative to the config directory
97/// matches the include globs and no exclude glob of a rule. A relative
98/// source that does not resolve from the module resolves from the config
99/// directory, as Vite does.
100/// A source that resolves from neither place adds no edge, so a config value
101/// never causes an unresolved-import finding.
102pub(super) fn resolve_config_jsx_runtime_imports(
103    ctx: &ResolveContext<'_>,
104    file_path: &Path,
105    module: &ModuleInfo,
106    rules: &[CompiledJsxRule<'_>],
107) -> Vec<ResolvedImport> {
108    if !module.jsx_runtime_from_config || rules.is_empty() {
109        return Vec::new();
110    }
111    let mut seen: FxHashSet<&str> = FxHashSet::default();
112    let mut imports = Vec::new();
113    for compiled in rules {
114        let rule = compiled.rule;
115        if !compiled.matches(file_path) || !seen.insert(rule.source.as_str()) {
116            continue;
117        }
118        let specifier = jsx_dev_runtime_specifier(&rule.source);
119        let Some(target) = resolve_runtime(ctx, file_path, rule, &specifier) else {
120            continue;
121        };
122        imports.extend(
123            JSX_DEV_RUNTIME_BINDINGS
124                .iter()
125                .map(|binding| ResolvedImport {
126                    info: ImportInfo {
127                        source: specifier.clone(),
128                        imported_name: ImportedName::Named((*binding).to_string()),
129                        local_name: String::new(),
130                        is_type_only: false,
131                        is_type_only_star: false,
132                        from_style: false,
133                        span: Span::default(),
134                        source_span: Span::default(),
135                    },
136                    target: target.clone(),
137                }),
138        );
139    }
140    imports
141}
142
143fn resolve_runtime(
144    ctx: &ResolveContext<'_>,
145    file_path: &Path,
146    rule: &JsxImportSourceRule,
147    specifier: &str,
148) -> Option<ResolveResult> {
149    let from_file = resolve_import_specifier(ctx, file_path, specifier, false);
150    if !matches!(from_file, ResolveResult::Unresolvable(_)) {
151        return Some(from_file);
152    }
153    if !is_relative(&rule.source) {
154        return None;
155    }
156    // The resolver reads a leading `/` as root-relative, so the fallback
157    // spells the config-relative target relative to the module directory.
158    let from_config = config_relative_specifier(file_path.parent()?, &rule.config_dir, specifier)?;
159    match resolve_import_specifier(ctx, file_path, &from_config, false) {
160        ResolveResult::Unresolvable(_) => None,
161        target => Some(target),
162    }
163}
164
165fn is_relative(source: &str) -> bool {
166    matches!(source, "." | "..") || source.starts_with("./") || source.starts_with("../")
167}
168
169/// The specifier that the automatic JSX transform imports in dev mode:
170/// `<source>/jsx-dev-runtime`, with one separator.
171fn jsx_dev_runtime_specifier(import_source: &str) -> String {
172    let base = import_source.trim_end_matches('/');
173    if base.is_empty() {
174        return format!("/{JSX_DEV_RUNTIME_SUBPATH}");
175    }
176    format!("{base}/{JSX_DEV_RUNTIME_SUBPATH}")
177}
178
179/// Spell `specifier`, relative to `config_dir`, as a specifier relative to
180/// `file_dir`. `None` when the two directories share no root, for example on
181/// two Windows drives.
182fn config_relative_specifier(
183    file_dir: &Path,
184    config_dir: &Path,
185    specifier: &str,
186) -> Option<String> {
187    let target = lexical_join(config_dir, specifier);
188    let from: Vec<Component<'_>> = file_dir.components().collect();
189    let to: Vec<Component<'_>> = target.components().collect();
190    let common = from.iter().zip(&to).take_while(|(a, b)| a == b).count();
191    if common == 0 {
192        return None;
193    }
194    let mut parts: Vec<String> = vec!["..".to_string(); from.len() - common];
195    if parts.is_empty() {
196        parts.push(".".to_string());
197    }
198    for component in &to[common..] {
199        parts.push(component.as_os_str().to_str()?.to_string());
200    }
201    Some(parts.join("/"))
202}
203
204/// Join a relative specifier to a directory and remove `.` and `..`
205/// segments.
206fn lexical_join(dir: &Path, relative: &str) -> PathBuf {
207    let mut joined = dir.to_path_buf();
208    for component in Path::new(relative).components() {
209        match component {
210            Component::CurDir => {}
211            Component::ParentDir => {
212                joined.pop();
213            }
214            other => joined.push(other.as_os_str()),
215        }
216    }
217    joined
218}
219
220#[cfg(test)]
221mod tests {
222    use super::*;
223
224    #[test]
225    fn dev_runtime_specifier_has_one_separator() {
226        assert_eq!(
227            jsx_dev_runtime_specifier("preact"),
228            "preact/jsx-dev-runtime"
229        );
230        assert_eq!(
231            jsx_dev_runtime_specifier("./src/jsx/"),
232            "./src/jsx/jsx-dev-runtime"
233        );
234        assert_eq!(jsx_dev_runtime_specifier("."), "./jsx-dev-runtime");
235    }
236
237    #[test]
238    fn lexical_join_removes_dot_segments() {
239        assert_eq!(
240            lexical_join(Path::new("/project/app"), "../src/./jsx/jsx-dev-runtime"),
241            PathBuf::from("/project/src/jsx/jsx-dev-runtime")
242        );
243    }
244
245    #[test]
246    fn config_relative_specifier_starts_from_the_module_directory() {
247        assert_eq!(
248            config_relative_specifier(
249                Path::new("/project/src/deep"),
250                Path::new("/project"),
251                "./src/jsx/jsx-dev-runtime"
252            )
253            .as_deref(),
254            Some("../jsx/jsx-dev-runtime")
255        );
256        assert_eq!(
257            config_relative_specifier(Path::new("/project"), Path::new("/project"), "./jsx/x")
258                .as_deref(),
259            Some("./jsx/x")
260        );
261    }
262
263    #[test]
264    fn relative_sources() {
265        assert!(is_relative("./src/jsx"));
266        assert!(is_relative("../jsx"));
267        assert!(is_relative("."));
268        assert!(!is_relative("preact"));
269        assert!(!is_relative("@emotion/react"));
270    }
271}