1use rustc_hash::{FxHashMap, FxHashSet};
12
13use fallow_types::discover::FileId;
14
15use super::{EffectiveExportBinding, EffectiveExportResolution, ExportNamespace, ModuleGraph};
16
17#[derive(Debug, Clone, PartialEq, Eq)]
19pub struct AmbiguousStarExport {
20 pub barrel: FileId,
22 pub name: Box<str>,
24 pub namespace: ExportNamespace,
26 pub contributors: Box<[FileId]>,
28}
29
30#[derive(Debug, Default)]
38pub struct AmbiguityParticipants {
39 names_by_file: FxHashMap<(FileId, ExportNamespace), FxHashSet<Box<str>>>,
40}
41
42impl AmbiguityParticipants {
43 #[must_use]
46 pub fn contains_in_namespace(
47 &self,
48 file_id: FileId,
49 name: &str,
50 namespace: ExportNamespace,
51 ) -> bool {
52 self.names_by_file
53 .get(&(file_id, namespace))
54 .is_some_and(|names| names.contains(name))
55 }
56
57 #[must_use]
65 pub fn contains_declaration(&self, file_id: FileId, name: &str, is_type_only: bool) -> bool {
66 self.contains_in_namespace(file_id, name, ExportNamespace::Type)
67 || (!is_type_only && self.contains_in_namespace(file_id, name, ExportNamespace::Value))
68 }
69}
70
71impl ModuleGraph {
72 #[must_use]
74 pub fn ambiguous_star_exports(&self) -> Vec<AmbiguousStarExport> {
75 if !self.has_star_re_exports() {
76 return Vec::new();
77 }
78 self.collect_ambiguous_star_exports(self.effective_exports.ambiguous_names())
79 }
80
81 #[must_use]
83 pub fn ambiguous_star_exports_on(&self, file_id: FileId) -> Vec<AmbiguousStarExport> {
84 if !self.has_star_re_exports() {
85 return Vec::new();
86 }
87 self.collect_ambiguous_star_exports(self.effective_exports.ambiguous_names_on(file_id))
88 }
89
90 #[must_use]
92 pub fn ambiguity_participants(&self) -> AmbiguityParticipants {
93 if !self.has_star_re_exports() {
94 return AmbiguityParticipants::default();
95 }
96
97 let mut participants = AmbiguityParticipants::default();
98 for (barrel, name, namespace) in self.effective_exports.ambiguous_names() {
99 for binding in self.star_contributor_bindings(barrel, name, namespace) {
100 let declaration = self
101 .export_binding_origin(binding)
102 .map(|origin| {
103 (
104 origin.file_id(),
105 Box::<str>::from(origin.export().name.to_string()),
106 )
107 })
108 .or_else(|| {
109 binding
110 .is_implicit_default()
111 .then(|| (binding.origin_file(), Box::<str>::from("default")))
112 });
113 let Some((file_id, declaration_name)) = declaration else {
114 continue;
115 };
116 participants
117 .names_by_file
118 .entry((file_id, namespace))
119 .or_default()
120 .insert(declaration_name);
121 }
122 }
123 participants
124 }
125
126 fn collect_ambiguous_star_exports(
127 &self,
128 ambiguous: Vec<(FileId, &str, ExportNamespace)>,
129 ) -> Vec<AmbiguousStarExport> {
130 let mut collisions: Vec<AmbiguousStarExport> = ambiguous
131 .into_iter()
132 .filter_map(|(barrel, name, namespace)| {
133 let contributors = self.star_contributors(barrel, name, namespace);
134 (!contributors.is_empty()).then(|| AmbiguousStarExport {
135 barrel,
136 name: Box::from(name),
137 namespace,
138 contributors,
139 })
140 })
141 .collect();
142 collisions.sort_by(|left, right| {
143 (left.barrel.0, &left.name, namespace_order(left.namespace)).cmp(&(
144 right.barrel.0,
145 &right.name,
146 namespace_order(right.namespace),
147 ))
148 });
149 collisions
150 }
151
152 fn has_star_re_exports(&self) -> bool {
153 self.modules
154 .iter()
155 .any(|module| module.re_exports.iter().any(is_star_re_export))
156 }
157
158 fn star_contributors(
164 &self,
165 barrel: FileId,
166 name: &str,
167 namespace: ExportNamespace,
168 ) -> Box<[FileId]> {
169 let mut contributors: Vec<FileId> = self
170 .star_contributor_bindings(barrel, name, namespace)
171 .into_iter()
172 .map(|binding| binding.origin_file())
173 .collect();
174 contributors.sort_unstable_by_key(|file_id| file_id.0);
175 contributors.dedup();
176 contributors.into_boxed_slice()
177 }
178
179 fn star_contributor_bindings(
181 &self,
182 barrel: FileId,
183 name: &str,
184 namespace: ExportNamespace,
185 ) -> FxHashSet<EffectiveExportBinding> {
186 if name == "default" {
187 return FxHashSet::default();
188 }
189 let mut contributors: FxHashSet<EffectiveExportBinding> = FxHashSet::default();
190 let mut visited: FxHashSet<FileId> = FxHashSet::from_iter([barrel]);
191 let mut pending = vec![barrel];
192 while let Some(current) = pending.pop() {
193 let Some(module) = self.modules.get(current.0 as usize) else {
194 continue;
195 };
196 for re_export in module.re_exports.iter().filter(|re| is_star_re_export(re)) {
197 if namespace == ExportNamespace::Value && re_export.is_type_only {
198 continue;
199 }
200 let source = re_export.source_file;
201 if !visited.insert(source) {
202 continue;
203 }
204 match self.resolve_export(source, name, namespace) {
205 EffectiveExportResolution::Unique(binding) => {
206 contributors.insert(binding);
207 }
208 EffectiveExportResolution::Ambiguous => pending.push(source),
209 EffectiveExportResolution::Missing => {}
210 }
211 }
212 }
213 contributors
214 }
215}
216
217fn is_star_re_export(re_export: &super::ReExportEdge) -> bool {
218 re_export.imported_name == "*" && re_export.exported_name == "*"
219}
220
221const fn namespace_order(namespace: ExportNamespace) -> u8 {
222 match namespace {
223 ExportNamespace::Type => 0,
224 ExportNamespace::Value => 1,
225 }
226}
227
228#[cfg(test)]
229mod tests {
230 use std::path::PathBuf;
231
232 use fallow_types::discover::DiscoveredFile;
233 use fallow_types::extract::{ExportInfo, ExportName, ReExportInfo, VisibilityTag};
234
235 use super::{AmbiguousStarExport, ExportNamespace, FileId, ModuleGraph};
236 use crate::resolve::{ResolveResult, ResolvedModule, ResolvedReExport};
237
238 fn value_export(name: &str) -> ExportInfo {
239 ExportInfo {
240 name: ExportName::Named(name.to_string()),
241 local_name: Some(name.to_string()),
242 is_type_only: false,
243 visibility: VisibilityTag::None,
244 expected_unused_reason: None,
245 span: oxc_span::Span::default(),
246 members: vec![],
247 is_side_effect_used: false,
248 super_class: None,
249 deprecated: false,
250 deprecated_reason: None,
251 }
252 }
253
254 fn default_export() -> ExportInfo {
255 ExportInfo {
256 name: ExportName::Default,
257 local_name: Some("Component".to_string()),
258 is_type_only: false,
259 visibility: VisibilityTag::None,
260 expected_unused_reason: None,
261 span: oxc_span::Span::default(),
262 members: vec![],
263 is_side_effect_used: false,
264 super_class: None,
265 deprecated: false,
266 deprecated_reason: None,
267 }
268 }
269
270 fn star_re_export(target: u32) -> ResolvedReExport {
271 ResolvedReExport {
272 info: ReExportInfo {
273 source: format!("./module-{target}"),
274 imported_name: "*".to_string(),
275 exported_name: "*".to_string(),
276 is_type_only: false,
277 span: oxc_span::Span::default(),
278 statement_span: oxc_span::Span::default(),
279 source_span: oxc_span::Span::default(),
280 },
281 target: ResolveResult::InternalModule(FileId(target)),
282 }
283 }
284
285 fn type_only_star_re_export(target: u32) -> ResolvedReExport {
286 let mut re_export = star_re_export(target);
287 re_export.info.is_type_only = true;
288 re_export
289 }
290
291 fn named_re_export(target: u32, imported_name: &str, exported_name: &str) -> ResolvedReExport {
292 ResolvedReExport {
293 info: ReExportInfo {
294 source: format!("./module-{target}"),
295 imported_name: imported_name.to_string(),
296 exported_name: exported_name.to_string(),
297 is_type_only: false,
298 span: oxc_span::Span::default(),
299 statement_span: oxc_span::Span::default(),
300 source_span: oxc_span::Span::default(),
301 },
302 target: ResolveResult::InternalModule(FileId(target)),
303 }
304 }
305
306 fn type_only_colliding_star_graph() -> ModuleGraph {
311 let files: Vec<_> = (0..3)
312 .map(|index| DiscoveredFile {
313 id: FileId(index),
314 path: PathBuf::from(format!("/project/module-{index}.ts")),
315 size_bytes: 10,
316 })
317 .collect();
318 let modules = vec![
319 ResolvedModule {
320 file_id: FileId(0),
321 path: files[0].path.clone(),
322 re_exports: vec![type_only_star_re_export(1), type_only_star_re_export(2)],
323 ..Default::default()
324 },
325 ResolvedModule {
326 file_id: FileId(1),
327 path: files[1].path.clone(),
328 exports: vec![value_export("Foo")].into(),
329 ..Default::default()
330 },
331 ResolvedModule {
332 file_id: FileId(2),
333 path: files[2].path.clone(),
334 exports: vec![value_export("Foo")].into(),
335 ..Default::default()
336 },
337 ];
338 ModuleGraph::build(&modules, &[], &files)
339 }
340
341 fn colliding_star_graph(extra_barrel: bool) -> ModuleGraph {
343 let count = if extra_barrel { 4 } else { 3 };
344 let files: Vec<_> = (0..count)
345 .map(|index| DiscoveredFile {
346 id: FileId(index),
347 path: PathBuf::from(format!("/project/module-{index}.ts")),
348 size_bytes: 10,
349 })
350 .collect();
351 let mut modules: Vec<ResolvedModule> = vec![
352 ResolvedModule {
353 file_id: FileId(0),
354 path: files[0].path.clone(),
355 re_exports: vec![star_re_export(1), star_re_export(2)],
356 ..Default::default()
357 },
358 ResolvedModule {
359 file_id: FileId(1),
360 path: files[1].path.clone(),
361 exports: vec![value_export("foo")].into(),
362 ..Default::default()
363 },
364 ResolvedModule {
365 file_id: FileId(2),
366 path: files[2].path.clone(),
367 exports: vec![value_export("foo"), value_export("bar")].into(),
368 ..Default::default()
369 },
370 ];
371 if extra_barrel {
372 modules.push(ResolvedModule {
373 file_id: FileId(3),
374 path: files[3].path.clone(),
375 re_exports: vec![star_re_export(0)],
376 ..Default::default()
377 });
378 }
379 ModuleGraph::build(&modules, &[], &files)
380 }
381
382 #[test]
383 fn colliding_star_sources_are_reported_with_both_contributors() {
384 let graph = colliding_star_graph(false);
385
386 let collisions = graph.ambiguous_star_exports();
387
388 assert_eq!(
389 collisions,
390 vec![AmbiguousStarExport {
391 barrel: FileId(0),
392 name: Box::from("foo"),
393 namespace: ExportNamespace::Value,
394 contributors: Box::from([FileId(1), FileId(2)]),
395 }]
396 );
397 }
398
399 #[test]
400 fn colliding_type_only_star_sources_are_reported_in_the_type_namespace() {
401 let graph = type_only_colliding_star_graph();
402
403 assert_eq!(
404 graph.ambiguous_star_exports(),
405 vec![AmbiguousStarExport {
406 barrel: FileId(0),
407 name: Box::from("Foo"),
408 namespace: ExportNamespace::Type,
409 contributors: Box::from([FileId(1), FileId(2)]),
410 }]
411 );
412 }
413
414 #[test]
415 fn a_value_declaration_behind_a_type_only_star_collision_is_a_participant() {
416 let participants = type_only_colliding_star_graph().ambiguity_participants();
417
418 assert!(participants.contains_declaration(FileId(1), "Foo", false));
419 assert!(participants.contains_declaration(FileId(2), "Foo", false));
420 assert!(!participants.contains_declaration(FileId(1), "Bar", false));
421 }
422
423 #[test]
424 fn aliased_default_origins_are_participants_under_their_declaration_name() {
425 let files: Vec<_> = (0..5)
426 .map(|index| DiscoveredFile {
427 id: FileId(index),
428 path: PathBuf::from(format!("/project/module-{index}.ts")),
429 size_bytes: 10,
430 })
431 .collect();
432 let modules = vec![
433 ResolvedModule {
434 file_id: FileId(0),
435 path: files[0].path.clone(),
436 re_exports: vec![star_re_export(1), star_re_export(2)],
437 ..Default::default()
438 },
439 ResolvedModule {
440 file_id: FileId(1),
441 path: files[1].path.clone(),
442 re_exports: vec![named_re_export(3, "default", "Widget")],
443 ..Default::default()
444 },
445 ResolvedModule {
446 file_id: FileId(2),
447 path: files[2].path.clone(),
448 re_exports: vec![named_re_export(4, "default", "Widget")],
449 ..Default::default()
450 },
451 ResolvedModule {
452 file_id: FileId(3),
453 path: files[3].path.clone(),
454 exports: vec![default_export()].into(),
455 ..Default::default()
456 },
457 ResolvedModule {
458 file_id: FileId(4),
459 path: files[4].path.clone(),
460 exports: vec![default_export()].into(),
461 ..Default::default()
462 },
463 ];
464 let graph = ModuleGraph::build(&modules, &[], &files);
465
466 let participants = graph.ambiguity_participants();
467
468 assert!(participants.contains_declaration(FileId(3), "default", false));
469 assert!(participants.contains_declaration(FileId(4), "default", false));
470 assert!(!participants.contains_declaration(FileId(1), "Widget", false));
471 assert_eq!(
472 graph.ambiguous_star_exports()[0].contributors.as_ref(),
473 &[FileId(3), FileId(4)]
474 );
475 }
476
477 #[test]
483 fn a_value_star_collision_is_not_reported_twice_across_namespaces() {
484 let files: Vec<_> = (0..4)
485 .map(|index| DiscoveredFile {
486 id: FileId(index),
487 path: PathBuf::from(format!("/project/module-{index}.ts")),
488 size_bytes: 10,
489 })
490 .collect();
491 let type_only_named = ResolvedReExport {
492 info: ReExportInfo {
493 source: "./module-0".to_string(),
494 imported_name: "foo".to_string(),
495 exported_name: "foo".to_string(),
496 is_type_only: true,
497 span: oxc_span::Span::default(),
498 statement_span: oxc_span::Span::default(),
499 source_span: oxc_span::Span::default(),
500 },
501 target: ResolveResult::InternalModule(FileId(0)),
502 };
503 let modules = vec![
504 ResolvedModule {
505 file_id: FileId(0),
506 path: files[0].path.clone(),
507 re_exports: vec![star_re_export(1), star_re_export(2)],
508 ..Default::default()
509 },
510 ResolvedModule {
511 file_id: FileId(1),
512 path: files[1].path.clone(),
513 exports: vec![value_export("foo")].into(),
514 ..Default::default()
515 },
516 ResolvedModule {
517 file_id: FileId(2),
518 path: files[2].path.clone(),
519 exports: vec![value_export("foo")].into(),
520 ..Default::default()
521 },
522 ResolvedModule {
523 file_id: FileId(3),
524 path: files[3].path.clone(),
525 re_exports: vec![type_only_named],
526 ..Default::default()
527 },
528 ];
529 let graph = ModuleGraph::build(&modules, &[], &files);
530
531 let collisions = graph.ambiguous_star_exports();
532
533 assert_eq!(collisions.len(), 1, "{collisions:?}");
534 assert_eq!(collisions[0].namespace, ExportNamespace::Value);
535 }
536
537 #[test]
538 fn participants_cover_only_the_colliding_name() {
539 let participants = colliding_star_graph(false).ambiguity_participants();
540
541 assert!(participants.contains_in_namespace(FileId(1), "foo", ExportNamespace::Value));
542 assert!(participants.contains_in_namespace(FileId(2), "foo", ExportNamespace::Value));
543 assert!(!participants.contains_in_namespace(FileId(2), "bar", ExportNamespace::Value));
544 assert!(!participants.contains_in_namespace(FileId(1), "foo", ExportNamespace::Type));
545 }
546
547 #[test]
548 fn a_collision_forwarded_by_a_second_barrel_keeps_the_same_contributors() {
549 let graph = colliding_star_graph(true);
550
551 let forwarded = graph.ambiguous_star_exports_on(FileId(3));
552
553 assert_eq!(
554 forwarded,
555 vec![AmbiguousStarExport {
556 barrel: FileId(3),
557 name: Box::from("foo"),
558 namespace: ExportNamespace::Value,
559 contributors: Box::from([FileId(1), FileId(2)]),
560 }]
561 );
562 }
563
564 #[test]
565 fn a_project_without_star_re_exports_reports_nothing() {
566 let files = vec![DiscoveredFile {
567 id: FileId(0),
568 path: PathBuf::from("/project/only.ts"),
569 size_bytes: 10,
570 }];
571 let modules = vec![ResolvedModule {
572 file_id: FileId(0),
573 path: files[0].path.clone(),
574 exports: vec![value_export("foo")].into(),
575 ..Default::default()
576 }];
577 let graph = ModuleGraph::build(&modules, &[], &files);
578
579 assert!(graph.ambiguous_star_exports().is_empty());
580 assert!(
581 !graph
582 .ambiguity_participants()
583 .contains_declaration(FileId(0), "foo", false)
584 );
585 }
586}