Skip to main content

fallow_graph/resolve/
react_native.rs

1//! React Native and Expo platform extension support.
2
3use std::path::Path;
4
5use rustc_hash::FxHashMap;
6
7use super::types::{RN_PLATFORM_PREFIXES, ResolveResult, ResolvedImport, ResolvedModule};
8use fallow_types::discover::{DiscoveredFile, FileId};
9use fallow_types::extract::{ImportInfo, ImportedName};
10
11/// Whether the React Native or Expo plugin is active, the gate for every
12/// Metro platform-extension behavior in the resolver and its consumers.
13pub fn has_react_native_plugin(active_plugins: &[String]) -> bool {
14    active_plugins
15        .iter()
16        .any(|p| p == "react-native" || p == "expo")
17}
18
19/// Source extensions that participate in Metro platform-extension resolution.
20const RN_SOURCE_EXTS: &[&str] = &[".ts", ".tsx", ".js", ".jsx"];
21
22/// Split a file or specifier basename into its stem and a Metro source
23/// extension, when one is present.
24fn split_source_ext(name: &str) -> (&str, Option<&str>) {
25    for ext in RN_SOURCE_EXTS {
26        if let Some(stem) = name.strip_suffix(ext) {
27            return (stem, Some(ext));
28        }
29    }
30    (name, None)
31}
32
33/// Strip a trailing platform segment (`.ios`, `.android`, ...) from a stem.
34/// Returns the family base stem and whether a platform segment was present.
35fn strip_platform_segment(stem: &str) -> (&str, bool) {
36    for platform in RN_PLATFORM_PREFIXES {
37        if let Some(base) = stem.strip_suffix(platform) {
38            return (base, true);
39        }
40    }
41    (stem, false)
42}
43
44/// Where a source file sits in a Metro platform-extension family: the
45/// directory and base stem shared by `<stem>.<platform><ext>` and
46/// `<stem><ext>`, plus whether this member carries a platform segment.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct PlatformFamilyKey<'a> {
49    /// Directory containing the file.
50    pub parent: &'a Path,
51    /// File stem with the source extension and any platform segment removed.
52    pub base: &'a str,
53    /// Whether the file name carries a platform segment (`.ios`, `.web`, ...).
54    pub is_platform_variant: bool,
55}
56
57/// Classify `path` by its Metro platform-extension family.
58///
59/// Membership is syntactic: files sharing `parent` and `base` belong to the
60/// same family, and the caller decides whether enough members exist for the
61/// family to matter. Returns `None` for files outside the Metro source
62/// extensions, names without a stem, and paths without a parent directory.
63pub fn platform_family_key(path: &Path) -> Option<PlatformFamilyKey<'_>> {
64    let name = path.file_name()?.to_str()?;
65    let (stem, ext) = split_source_ext(name);
66    ext?;
67    let (base, is_platform_variant) = strip_platform_segment(stem);
68    if base.is_empty() {
69        return None;
70    }
71    let parent = path.parent()?;
72    Some(PlatformFamilyKey {
73        parent,
74        base,
75        is_platform_variant,
76    })
77}
78
79/// Whether an import specifier explicitly names a platform variant
80/// (e.g. `./UserMenu.ios` or `./UserMenu.ios.tsx`), in which case the author
81/// targeted one variant and the family must not be credited as a whole.
82fn specifier_names_platform_variant(specifier: &str) -> bool {
83    let basename = specifier.rsplit('/').next().unwrap_or(specifier);
84    let (stem, _) = split_source_ext(basename);
85    strip_platform_segment(stem).1
86}
87
88/// Metro platform-extension families among the discovered files, keyed by the
89/// member [`FileId`]. A family is every file in one directory sharing a base
90/// stem across `<stem>.<platform><ext>` and `<stem><ext>`, and only counts
91/// when at least one platform variant exists alongside another member.
92struct PlatformFamilies {
93    family_of: FxHashMap<FileId, usize>,
94    members: Vec<Vec<FileId>>,
95}
96
97impl PlatformFamilies {
98    fn build(files: &[DiscoveredFile]) -> Self {
99        let mut grouped: FxHashMap<(&Path, &str), Vec<(FileId, bool)>> = FxHashMap::default();
100        for file in files {
101            let Some(key) = platform_family_key(&file.path) else {
102                continue;
103            };
104            grouped
105                .entry((key.parent, key.base))
106                .or_default()
107                .push((file.id, key.is_platform_variant));
108        }
109
110        let mut family_of = FxHashMap::default();
111        let mut members = Vec::new();
112        for group in grouped.into_values() {
113            if group.len() < 2 || !group.iter().any(|(_, is_platform)| *is_platform) {
114                continue;
115            }
116            let mut ids: Vec<FileId> = group.into_iter().map(|(id, _)| id).collect();
117            ids.sort_unstable_by_key(|id| id.0);
118            let index = members.len();
119            for id in &ids {
120                family_of.insert(*id, index);
121            }
122            members.push(ids);
123        }
124        Self { family_of, members }
125    }
126
127    fn siblings(&self, target: FileId) -> Option<&[FileId]> {
128        self.family_of
129            .get(&target)
130            .map(|index| self.members[*index].as_slice())
131    }
132}
133
134/// Rebuild a project-internal [`ResolveResult`] against a sibling file,
135/// preserving the original edge kind and package attribution.
136fn retarget(result: &ResolveResult, sibling: FileId) -> Option<ResolveResult> {
137    match result {
138        ResolveResult::InternalModule(_) => Some(ResolveResult::InternalModule(sibling)),
139        ResolveResult::CommonJsInternalModule(_) => {
140            Some(ResolveResult::CommonJsInternalModule(sibling))
141        }
142        ResolveResult::SyntheticAutoImport(_) => Some(ResolveResult::SyntheticAutoImport(sibling)),
143        ResolveResult::InternalPackageModule { package_name, .. } => {
144            Some(ResolveResult::InternalPackageModule {
145                file_id: sibling,
146                package_name: package_name.clone(),
147            })
148        }
149        ResolveResult::CommonJsInternalPackageModule { package_name, .. } => {
150            Some(ResolveResult::CommonJsInternalPackageModule {
151                file_id: sibling,
152                package_name: package_name.clone(),
153            })
154        }
155        ResolveResult::ExternalFile(_)
156        | ResolveResult::NpmPackage(_)
157        | ResolveResult::CommonJsNpmPackage(_)
158        | ResolveResult::Unresolvable(_) => None,
159    }
160}
161
162/// Expand `imports` with sibling edges for every platform-extension family
163/// member, appending the extra edges to `extra`.
164fn expand_family_imports(
165    imports: &[ResolvedImport],
166    families: &PlatformFamilies,
167    extra: &mut Vec<ResolvedImport>,
168) {
169    for import in imports {
170        if specifier_names_platform_variant(&import.info.source) {
171            continue;
172        }
173        let Some(target) = import.target.internal_file_id() else {
174            continue;
175        };
176        let Some(siblings) = families.siblings(target) else {
177            continue;
178        };
179        for sibling in siblings {
180            if *sibling == target {
181                continue;
182            }
183            if let Some(retargeted) = retarget(&import.target, *sibling) {
184                extra.push(ResolvedImport {
185                    info: import.info.clone(),
186                    target: retargeted,
187                });
188            }
189        }
190    }
191}
192
193/// Credit whole Metro platform-extension families when the RN/Expo plugin is
194/// active.
195///
196/// Metro resolves `./UserMenu` to `UserMenu.ios.tsx` on iOS and to
197/// `UserMenu.tsx` (or `.android.tsx`, `.native.tsx`, ...) elsewhere, so a
198/// specifier that resolved to one family member reaches every member at
199/// runtime. The resolver picks a single winner per platform-extension order;
200/// this pass appends edges to the remaining family members so none are
201/// reported as unused files and their matching exports stay credited. Imports
202/// that explicitly name a platform variant keep their single edge.
203pub(super) fn synthesize_platform_family_edges(
204    resolved: &mut [ResolvedModule],
205    files: &[DiscoveredFile],
206    active_plugins: &[String],
207) {
208    if !has_react_native_plugin(active_plugins) {
209        return;
210    }
211    let families = PlatformFamilies::build(files);
212    if families.members.is_empty() {
213        return;
214    }
215
216    for module in resolved.iter_mut() {
217        let mut extra = Vec::new();
218        expand_family_imports(&module.resolved_imports, &families, &mut extra);
219        expand_family_imports(&module.resolved_dynamic_imports, &families, &mut extra);
220
221        for re_export in &module.re_exports {
222            if specifier_names_platform_variant(&re_export.info.source) {
223                continue;
224            }
225            let Some(target) = re_export.target.internal_file_id() else {
226                continue;
227            };
228            let Some(siblings) = families.siblings(target) else {
229                continue;
230            };
231            for sibling in siblings {
232                if *sibling == target {
233                    continue;
234                }
235                // Re-export propagation keeps its single resolved source; a
236                // side-effect edge is enough to keep the sibling reachable.
237                extra.push(ResolvedImport {
238                    info: ImportInfo {
239                        source: re_export.info.source.clone(),
240                        imported_name: ImportedName::SideEffect,
241                        local_name: String::new(),
242                        is_type_only: re_export.info.is_type_only,
243                        is_type_only_star: false,
244                        from_style: false,
245                        span: oxc_span::Span::default(),
246                        source_span: oxc_span::Span::default(),
247                    },
248                    target: ResolveResult::InternalModule(*sibling),
249                });
250            }
251        }
252
253        module.resolved_imports.extend(extra);
254    }
255}
256
257/// Build the resolver extension list, optionally prepending React Native platform
258/// extensions when the RN/Expo plugin is active.
259pub(super) fn build_extensions(active_plugins: &[String]) -> Vec<String> {
260    let base: Vec<String> = vec![
261        ".ts".into(),
262        ".tsx".into(),
263        ".mts".into(),
264        ".cts".into(),
265        ".gts".into(),
266        ".js".into(),
267        ".jsx".into(),
268        ".mjs".into(),
269        ".cjs".into(),
270        ".gjs".into(),
271        ".d.ts".into(),
272        ".d.mts".into(),
273        ".d.cts".into(),
274        ".json".into(),
275        ".vue".into(),
276        ".svelte".into(),
277        ".astro".into(),
278        ".mdx".into(),
279        ".css".into(),
280        ".scss".into(),
281        ".graphql".into(),
282        ".gql".into(),
283    ];
284
285    if has_react_native_plugin(active_plugins) {
286        let source_exts = [".ts", ".tsx", ".js", ".jsx"];
287        let mut rn_extensions: Vec<String> = Vec::new();
288        for platform in RN_PLATFORM_PREFIXES {
289            for ext in &source_exts {
290                rn_extensions.push(format!("{platform}{ext}"));
291            }
292        }
293        rn_extensions.extend(base);
294        rn_extensions
295    } else {
296        base
297    }
298}
299
300/// Build the resolver `condition_names` list.
301///
302/// Baseline conditions (in priority order): `development`, `import`, `require`,
303/// `default`, `types`, `node`. `development` is included so that package.json
304/// `exports` / `imports` entries declaring a `development` branch (a widely
305/// used community condition, supported by Vite, Vitest, esbuild, and Rollup)
306/// resolve to their source files instead of compiled `dist/` output. See
307/// <https://nodejs.org/api/packages.html#community-conditions-definitions>.
308///
309/// When the React Native or Expo plugin is active, `react-native` and
310/// `browser` are prepended ahead of the baseline for Metro-style resolution.
311/// User-supplied `extra_conditions` are prepended ahead of everything else
312/// so they take highest priority.
313pub(super) fn build_condition_names(
314    active_plugins: &[String],
315    extra_conditions: &[String],
316) -> Vec<String> {
317    let mut names = vec![
318        "development".into(),
319        "import".into(),
320        "require".into(),
321        "default".into(),
322        "types".into(),
323        "node".into(),
324    ];
325    if has_react_native_plugin(active_plugins) {
326        names.insert(0, "react-native".into());
327        names.insert(1, "browser".into());
328    }
329    for extra in extra_conditions.iter().rev() {
330        names.insert(0, extra.clone());
331    }
332    let mut seen: rustc_hash::FxHashSet<String> = rustc_hash::FxHashSet::default();
333    names.retain(|name| seen.insert(name.clone()));
334    names
335}
336
337#[cfg(test)]
338mod tests {
339    use super::*;
340
341    #[test]
342    fn test_has_react_native_plugin_active() {
343        let plugins = vec!["react-native".to_string(), "typescript".to_string()];
344        assert!(has_react_native_plugin(&plugins));
345    }
346
347    #[test]
348    fn test_has_expo_plugin_active() {
349        let plugins = vec!["expo".to_string(), "typescript".to_string()];
350        assert!(has_react_native_plugin(&plugins));
351    }
352
353    #[test]
354    fn test_has_react_native_plugin_inactive() {
355        let plugins = vec!["nextjs".to_string(), "typescript".to_string()];
356        assert!(!has_react_native_plugin(&plugins));
357    }
358
359    #[test]
360    fn test_rn_platform_extensions_prepended() {
361        let no_rn = build_extensions(&[]);
362        let rn_plugins = vec!["react-native".to_string()];
363        let with_rn = build_extensions(&rn_plugins);
364
365        assert_eq!(no_rn[0], ".ts");
366
367        assert_eq!(with_rn[0], ".web.ts");
368        assert_eq!(with_rn[1], ".web.tsx");
369        assert_eq!(with_rn[2], ".web.js");
370        assert_eq!(with_rn[3], ".web.jsx");
371
372        assert!(with_rn.len() > no_rn.len());
373        assert_eq!(
374            with_rn.len(),
375            no_rn.len() + 16,
376            "should add 16 platform extensions (4 platforms x 4 exts)"
377        );
378    }
379
380    #[test]
381    fn test_rn_condition_names_prepended() {
382        let no_rn = build_condition_names(&[], &[]);
383        let rn_plugins = vec!["react-native".to_string()];
384        let with_rn = build_condition_names(&rn_plugins, &[]);
385
386        assert_eq!(no_rn[0], "development");
387
388        assert_eq!(with_rn[0], "react-native");
389        assert_eq!(with_rn[1], "browser");
390        assert_eq!(with_rn[2], "development");
391    }
392
393    #[test]
394    fn test_development_condition_in_baseline() {
395        let names = build_condition_names(&[], &[]);
396        assert!(
397            names.contains(&"development".to_string()),
398            "`development` must be part of the default condition set"
399        );
400    }
401
402    #[test]
403    fn test_extra_conditions_prepended_before_baseline() {
404        let names = build_condition_names(&[], &["worker".to_string(), "edge-light".to_string()]);
405        assert_eq!(names[0], "worker");
406        assert_eq!(names[1], "edge-light");
407        assert_eq!(names[2], "development");
408    }
409
410    #[test]
411    fn test_extra_conditions_prepended_before_rn() {
412        let rn_plugins = vec!["react-native".to_string()];
413        let names = build_condition_names(&rn_plugins, &["worker".to_string()]);
414        assert_eq!(names[0], "worker");
415        assert_eq!(names[1], "react-native");
416        assert_eq!(names[2], "browser");
417        assert_eq!(names[3], "development");
418    }
419
420    #[test]
421    fn test_duplicate_baseline_condition_from_user_is_deduped() {
422        let names = build_condition_names(&[], &["development".to_string()]);
423        let dev_count = names.iter().filter(|n| *n == "development").count();
424        assert_eq!(dev_count, 1, "`development` should appear exactly once");
425        assert_eq!(
426            names[0], "development",
427            "user-supplied entry keeps its position"
428        );
429    }
430
431    #[test]
432    fn test_specifier_names_platform_variant() {
433        assert!(specifier_names_platform_variant("./UserMenu.ios"));
434        assert!(specifier_names_platform_variant("./UserMenu.android.tsx"));
435        assert!(specifier_names_platform_variant("../deep/UserMenu.native"));
436        assert!(!specifier_names_platform_variant("./UserMenu"));
437        assert!(!specifier_names_platform_variant("./UserMenu.tsx"));
438        assert!(!specifier_names_platform_variant("./ios/UserMenu"));
439    }
440
441    #[test]
442    fn test_platform_family_key_marks_platform_variants() {
443        let key = platform_family_key(Path::new("src/components/UserMenu.ios.tsx"))
444            .expect("source file has a family key");
445        assert_eq!(key.parent, Path::new("src/components"));
446        assert_eq!(key.base, "UserMenu");
447        assert!(key.is_platform_variant);
448
449        let key = platform_family_key(Path::new("src/components/UserMenu.tsx"))
450            .expect("source file has a family key");
451        assert_eq!(key.parent, Path::new("src/components"));
452        assert_eq!(key.base, "UserMenu");
453        assert!(!key.is_platform_variant);
454    }
455
456    #[test]
457    fn test_platform_family_key_covers_every_platform_and_source_extension() {
458        for platform in RN_PLATFORM_PREFIXES {
459            for ext in RN_SOURCE_EXTS {
460                let path = format!("src/Button{platform}{ext}");
461                let key = platform_family_key(Path::new(&path)).expect("family key");
462                assert_eq!(key.base, "Button", "{path}");
463                assert!(key.is_platform_variant, "{path}");
464            }
465        }
466    }
467
468    #[test]
469    fn test_platform_family_key_rejects_non_source_files() {
470        assert_eq!(platform_family_key(Path::new("src/UserMenu.css")), None);
471        assert_eq!(
472            platform_family_key(Path::new("src/UserMenu.ios.json")),
473            None
474        );
475        assert_eq!(platform_family_key(Path::new("src/UserMenu.ios")), None);
476        assert_eq!(
477            platform_family_key(Path::new("src/.ios.tsx")),
478            None,
479            "a bare platform segment has no base stem"
480        );
481    }
482
483    fn discovered(id: u32, path: &str) -> DiscoveredFile {
484        DiscoveredFile {
485            id: FileId(id),
486            path: std::path::PathBuf::from(path),
487            size_bytes: 0,
488        }
489    }
490
491    #[test]
492    fn test_platform_families_group_base_and_variants() {
493        let files = vec![
494            discovered(0, "src/UserMenu.tsx"),
495            discovered(1, "src/UserMenu.ios.tsx"),
496            discovered(2, "src/UserMenu.android.tsx"),
497            discovered(3, "src/Other.tsx"),
498            discovered(4, "src/nested/UserMenu.tsx"),
499        ];
500        let families = PlatformFamilies::build(&files);
501
502        assert_eq!(families.members.len(), 1);
503        assert_eq!(
504            families.siblings(FileId(0)),
505            Some([FileId(0), FileId(1), FileId(2)].as_slice())
506        );
507        assert_eq!(families.siblings(FileId(1)), families.siblings(FileId(0)));
508        assert_eq!(families.siblings(FileId(3)), None);
509        assert_eq!(
510            families.siblings(FileId(4)),
511            None,
512            "same stem in a different directory is not part of the family"
513        );
514    }
515
516    #[test]
517    fn test_platform_families_require_a_platform_variant() {
518        let files = vec![
519            discovered(0, "src/Button.ts"),
520            discovered(1, "src/Button.tsx"),
521        ];
522        let families = PlatformFamilies::build(&files);
523        assert!(
524            families.members.is_empty(),
525            "same-stem files without a platform variant are not a Metro family"
526        );
527    }
528
529    #[test]
530    fn test_duplicate_user_conditions_are_deduped_preserving_first() {
531        let names = build_condition_names(
532            &[],
533            &[
534                "worker".to_string(),
535                "edge-light".to_string(),
536                "worker".to_string(),
537            ],
538        );
539        let worker_count = names.iter().filter(|n| *n == "worker").count();
540        assert_eq!(worker_count, 1);
541        assert_eq!(names[0], "worker");
542        assert_eq!(names[1], "edge-light");
543    }
544}