1use 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
11pub fn has_react_native_plugin(active_plugins: &[String]) -> bool {
14 active_plugins
15 .iter()
16 .any(|p| p == "react-native" || p == "expo")
17}
18
19const RN_SOURCE_EXTS: &[&str] = &[".ts", ".tsx", ".js", ".jsx"];
21
22fn 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
33fn 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct PlatformFamilyKey<'a> {
49 pub parent: &'a Path,
51 pub base: &'a str,
53 pub is_platform_variant: bool,
55}
56
57pub 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
79fn 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
88struct 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
134fn 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
162fn 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
193pub(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 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
257pub(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
300pub(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}