From 30f11f37c8e8f9b1a52a3cb473af718e6edffb92 Mon Sep 17 00:00:00 2001 From: Edvin CANDON Date: Thu, 23 Apr 2026 07:00:30 +0200 Subject: [PATCH] fix: rewrite nested, specifier-only, and out-of-barrel re-exports --- src/exports.rs | 61 +++++++++++++------ src/path.rs | 25 +++++++- src/reaper.rs | 23 +++++-- .../nested-reexports/barrel/feature/extras.ts | 2 + .../nested-reexports/barrel/feature/impl.ts | 2 + .../nested-reexports/barrel/feature/index.ts | 5 ++ .../barrel/feature/wrapper-impl.ts | 1 + .../barrel/feature/wrapper.ts | 2 + .../fixtures/nested-reexports/barrel/index.ts | 2 + .../fixtures/nested-reexports/shared/utils.ts | 2 + tests/fixtures/nested-reexports/tsconfig.json | 8 +++ .../via-alias/consumer.expected | 14 +++++ .../nested-reexports/via-alias/consumer.ts | 10 +++ .../via-relative/consumer.expected | 14 +++++ .../nested-reexports/via-relative/consumer.ts | 10 +++ tests/reaper.rs | 29 +++++++++ 16 files changed, 183 insertions(+), 27 deletions(-) create mode 100644 tests/fixtures/nested-reexports/barrel/feature/extras.ts create mode 100644 tests/fixtures/nested-reexports/barrel/feature/impl.ts create mode 100644 tests/fixtures/nested-reexports/barrel/feature/index.ts create mode 100644 tests/fixtures/nested-reexports/barrel/feature/wrapper-impl.ts create mode 100644 tests/fixtures/nested-reexports/barrel/feature/wrapper.ts create mode 100644 tests/fixtures/nested-reexports/barrel/index.ts create mode 100644 tests/fixtures/nested-reexports/shared/utils.ts create mode 100644 tests/fixtures/nested-reexports/tsconfig.json create mode 100644 tests/fixtures/nested-reexports/via-alias/consumer.expected create mode 100644 tests/fixtures/nested-reexports/via-alias/consumer.ts create mode 100644 tests/fixtures/nested-reexports/via-relative/consumer.expected create mode 100644 tests/fixtures/nested-reexports/via-relative/consumer.ts diff --git a/src/exports.rs b/src/exports.rs index 65975bf..b48f3ee 100644 --- a/src/exports.rs +++ b/src/exports.rs @@ -7,7 +7,7 @@ use oxc_ast::ast::*; use oxc_parser::Parser; use oxc_span::SourceType; -use crate::path::{barrel_export_path, resolve_export_path}; +use crate::path::{barrel_export_path, resolved_export_path}; use crate::resolver::ModuleResolver; use crate::{Context, SymbolKind}; @@ -62,7 +62,9 @@ fn collect_into( for stmt in &parsed.program.body { match stmt { Statement::ExportAllDeclaration(decl) => { - handle_export_all(decl, file, barrel, alias, resolver, exports, visited); + handle_export_all( + decl, file, barrel, barrel_dir, alias, resolver, exports, visited, + ); } Statement::ExportNamedDeclaration(decl) => { handle_export_named(decl, file, barrel_dir, alias, resolver, exports); @@ -90,6 +92,7 @@ fn handle_export_all( decl: &ExportAllDeclaration, file: &Path, barrel: &Path, + barrel_dir: &Path, alias: Option<&str>, resolver: &ModuleResolver, exports: &mut HashMap, @@ -104,7 +107,12 @@ fn handle_export_all( exports.insert( name_node.name().to_string(), BarrelExport { - source_path: resolve_export_path(from_module, alias), + source_path: resolved_export_path( + resolved.as_deref(), + from_module, + barrel_dir, + alias, + ), kind: SymbolKind::Named, source_file_path: resolved, }, @@ -130,23 +138,8 @@ fn handle_export_named( if let Some(source) = &decl.source { let from_module = source.value.as_str(); let resolved = resolver.resolve(from_module, file); - let source_path = resolve_export_path(from_module, alias); - - for spec in &decl.specifiers { - let kind = if spec.local.name() == "default" { - SymbolKind::Default - } else { - SymbolKind::Named - }; - exports.insert( - spec.exported.name().to_string(), - BarrelExport { - source_path: source_path.clone(), - kind, - source_file_path: resolved.clone(), - }, - ); - } + let source_path = resolved_export_path(resolved.as_deref(), from_module, barrel_dir, alias); + insert_reexports(&decl.specifiers, &source_path, resolved.as_deref(), exports); } else if let Some(declaration) = &decl.declaration { let source_path = barrel_export_path(file, barrel_dir, alias); for name in declaration_names(declaration) { @@ -159,6 +152,34 @@ fn handle_export_named( }, ); } + } else if !decl.specifiers.is_empty() { + // `export { foo };` — republishing a local/imported binding. Point + // at the current file and let TS follow any further re-export chain. + let source_path = barrel_export_path(file, barrel_dir, alias); + insert_reexports(&decl.specifiers, &source_path, Some(file), exports); + } +} + +fn insert_reexports( + specifiers: &[ExportSpecifier<'_>], + source_path: &str, + source_file_path: Option<&Path>, + exports: &mut HashMap, +) { + for spec in specifiers { + let kind = if spec.local.name() == "default" { + SymbolKind::Default + } else { + SymbolKind::Named + }; + exports.insert( + spec.exported.name().to_string(), + BarrelExport { + source_path: source_path.to_string(), + kind, + source_file_path: source_file_path.map(Path::to_path_buf), + }, + ); } } diff --git a/src/path.rs b/src/path.rs index cf53050..74e5e75 100644 --- a/src/path.rs +++ b/src/path.rs @@ -7,13 +7,36 @@ pub fn barrel_export_path(file: &Path, barrel_dir: &Path, alias: Option<&str>) - format!("{prefix}/{}", without_ext.display()) } -pub fn resolve_export_path(from_module: &str, alias: Option<&str>) -> String { +/// Picks the right `source_path` for a re-export. When the target resolves +/// inside `barrel_dir`, render it as a barrel-space path (alias or `./`); +/// otherwise fall back to aliasing the literal specifier — a later +/// consumer-relative rewrite in `reaper::format_import` will rescue +/// out-of-barrel cases when we have a resolved target. +pub fn resolved_export_path( + resolved: Option<&Path>, + literal: &str, + barrel_dir: &Path, + alias: Option<&str>, +) -> String { + match resolved { + Some(target) if target.starts_with(barrel_dir) => { + barrel_export_path(target, barrel_dir, alias) + } + _ => aliased_literal(literal, alias), + } +} + +fn aliased_literal(from_module: &str, alias: Option<&str>) -> String { match (alias, from_module.strip_prefix("./")) { (Some(alias), Some(rest)) => format!("{alias}/{rest}"), _ => from_module.to_string(), } } +pub fn is_relative_specifier(spec: &str) -> bool { + spec.starts_with("./") || spec.starts_with("../") +} + /// Relative import string between two files. Always prefixed with `./` when /// not climbing with `../`, since bare paths would parse as bare-package /// specifiers. diff --git a/src/reaper.rs b/src/reaper.rs index ac77225..65f6be4 100644 --- a/src/reaper.rs +++ b/src/reaper.rs @@ -5,7 +5,7 @@ use oxc_span::Span; use crate::exports::BarrelExport; use crate::imports::{BarrelImport, BarrelImportStatement}; -use crate::path::get_import_path; +use crate::path::{get_import_path, is_relative_specifier}; use crate::{Context, ReapedFile, SymbolKind}; pub fn rewrite( @@ -64,10 +64,16 @@ pub fn rewrite( } } -/// Relative mode needs an absolute target to render a path relative to the -/// consumer. Alias mode prefixes the stored `source_path` and is unaffected. +/// With a resolved target we can always render a path (alias path if the +/// target is inside the barrel, otherwise consumer-relative). Without one, +/// alias mode can still emit the stored `source_path` — but only if it's a +/// bare-package-style specifier; a relative literal would be wrong to paste +/// into the consumer. fn can_rewrite(ctx: &Context, export: &BarrelExport) -> bool { - ctx.barrel_alias.is_some() || export.source_file_path.is_some() + if export.source_file_path.is_some() { + return true; + } + ctx.barrel_alias.is_some() && !is_relative_specifier(&export.source_path) } fn resolves(ctx: &Context, exports: &HashMap, name: &str) -> bool { @@ -80,8 +86,13 @@ fn format_import( from_file: &Path, ctx: &Context, ) -> String { - let source_path = match (&ctx.barrel_alias, &export.source_file_path) { - (None, Some(target)) => get_import_path(from_file, target), + // Use the resolved target (consumer-relative) whenever the stored + // `source_path` isn't a valid specifier to paste into the consumer: in + // relative mode always, in alias mode when the target lives outside the + // barrel's alias space (`source_path` kept its relative literal). + let use_target = ctx.barrel_alias.is_none() || is_relative_specifier(&export.source_path); + let source_path = match &export.source_file_path { + Some(target) if use_target => get_import_path(from_file, target), _ => export.source_path.clone(), }; let type_prefix = if imp.type_import { "type " } else { "" }; diff --git a/tests/fixtures/nested-reexports/barrel/feature/extras.ts b/tests/fixtures/nested-reexports/barrel/feature/extras.ts new file mode 100644 index 0000000..d0086e2 --- /dev/null +++ b/tests/fixtures/nested-reexports/barrel/feature/extras.ts @@ -0,0 +1,2 @@ +export const ex1 = 1; +export const ex2 = 2; diff --git a/tests/fixtures/nested-reexports/barrel/feature/impl.ts b/tests/fixtures/nested-reexports/barrel/feature/impl.ts new file mode 100644 index 0000000..6f26f55 --- /dev/null +++ b/tests/fixtures/nested-reexports/barrel/feature/impl.ts @@ -0,0 +1,2 @@ +export const foo = 1; +export type FooType = string; diff --git a/tests/fixtures/nested-reexports/barrel/feature/index.ts b/tests/fixtures/nested-reexports/barrel/feature/index.ts new file mode 100644 index 0000000..867e2d7 --- /dev/null +++ b/tests/fixtures/nested-reexports/barrel/feature/index.ts @@ -0,0 +1,5 @@ +export { foo } from './impl'; +export type { FooType } from './impl'; +export * as extras from './extras'; +export * from './wrapper'; +export { nestedExternal } from '../../shared/utils'; diff --git a/tests/fixtures/nested-reexports/barrel/feature/wrapper-impl.ts b/tests/fixtures/nested-reexports/barrel/feature/wrapper-impl.ts new file mode 100644 index 0000000..9d11d42 --- /dev/null +++ b/tests/fixtures/nested-reexports/barrel/feature/wrapper-impl.ts @@ -0,0 +1 @@ +export const wrapped = 42; diff --git a/tests/fixtures/nested-reexports/barrel/feature/wrapper.ts b/tests/fixtures/nested-reexports/barrel/feature/wrapper.ts new file mode 100644 index 0000000..6826d2a --- /dev/null +++ b/tests/fixtures/nested-reexports/barrel/feature/wrapper.ts @@ -0,0 +1,2 @@ +import { wrapped } from './wrapper-impl'; +export { wrapped }; diff --git a/tests/fixtures/nested-reexports/barrel/index.ts b/tests/fixtures/nested-reexports/barrel/index.ts new file mode 100644 index 0000000..1a36845 --- /dev/null +++ b/tests/fixtures/nested-reexports/barrel/index.ts @@ -0,0 +1,2 @@ +export * from './feature'; +export { external } from '../shared/utils'; diff --git a/tests/fixtures/nested-reexports/shared/utils.ts b/tests/fixtures/nested-reexports/shared/utils.ts new file mode 100644 index 0000000..a95d180 --- /dev/null +++ b/tests/fixtures/nested-reexports/shared/utils.ts @@ -0,0 +1,2 @@ +export const external = 'root'; +export const nestedExternal = 'nested'; diff --git a/tests/fixtures/nested-reexports/tsconfig.json b/tests/fixtures/nested-reexports/tsconfig.json new file mode 100644 index 0000000..13e23d7 --- /dev/null +++ b/tests/fixtures/nested-reexports/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "paths": { + "@barrel": ["./barrel/index"], + "@barrel/*": ["./barrel/*"] + } + } +} diff --git a/tests/fixtures/nested-reexports/via-alias/consumer.expected b/tests/fixtures/nested-reexports/via-alias/consumer.expected new file mode 100644 index 0000000..cbcedf7 --- /dev/null +++ b/tests/fixtures/nested-reexports/via-alias/consumer.expected @@ -0,0 +1,14 @@ +import { foo } from '@barrel/feature/impl'; +import { extras } from '@barrel/feature/extras'; +import { wrapped } from '@barrel/feature/wrapper'; +import { external } from '../shared/utils'; +import { nestedExternal } from '../shared/utils'; +import type { FooType } from '@barrel/feature/impl'; + +void foo; +void extras; +void wrapped; +void external; +void nestedExternal; +const fx: FooType = ''; +void fx; diff --git a/tests/fixtures/nested-reexports/via-alias/consumer.ts b/tests/fixtures/nested-reexports/via-alias/consumer.ts new file mode 100644 index 0000000..28dcf9d --- /dev/null +++ b/tests/fixtures/nested-reexports/via-alias/consumer.ts @@ -0,0 +1,10 @@ +import { foo, extras, wrapped, external, nestedExternal } from '@barrel'; +import type { FooType } from '@barrel'; + +void foo; +void extras; +void wrapped; +void external; +void nestedExternal; +const fx: FooType = ''; +void fx; diff --git a/tests/fixtures/nested-reexports/via-relative/consumer.expected b/tests/fixtures/nested-reexports/via-relative/consumer.expected new file mode 100644 index 0000000..fe3dce6 --- /dev/null +++ b/tests/fixtures/nested-reexports/via-relative/consumer.expected @@ -0,0 +1,14 @@ +import { foo } from '../barrel/feature/impl'; +import { extras } from '../barrel/feature/extras'; +import { wrapped } from '../barrel/feature/wrapper'; +import { external } from '../shared/utils'; +import { nestedExternal } from '../shared/utils'; +import type { FooType } from '../barrel/feature/impl'; + +void foo; +void extras; +void wrapped; +void external; +void nestedExternal; +const fx: FooType = ''; +void fx; diff --git a/tests/fixtures/nested-reexports/via-relative/consumer.ts b/tests/fixtures/nested-reexports/via-relative/consumer.ts new file mode 100644 index 0000000..f3620f6 --- /dev/null +++ b/tests/fixtures/nested-reexports/via-relative/consumer.ts @@ -0,0 +1,10 @@ +import { foo, extras, wrapped, external, nestedExternal } from '../barrel'; +import type { FooType } from '../barrel'; + +void foo; +void extras; +void wrapped; +void external; +void nestedExternal; +const fx: FooType = ''; +void fx; diff --git a/tests/reaper.rs b/tests/reaper.rs index 9bc544b..b5cc1d8 100644 --- a/tests/reaper.rs +++ b/tests/reaper.rs @@ -187,6 +187,35 @@ fn reaps_through_chained_barrels() { ); } +// ---------- fixture: nested-reexports (named/namespace + specifier-only + +// out-of-barrel re-exports, some reached via `export *`) ---------- + +const NESTED_BARREL: &str = "tests/fixtures/nested-reexports/barrel/index.ts"; + +#[test] +fn reaps_nested_reexports_via_alias() { + assert_reap_matches( + &ctx( + NESTED_BARREL, + Some("@barrel"), + "fixtures/nested-reexports/via-alias/**", + ), + "tests/fixtures/nested-reexports/via-alias/consumer.expected", + ); +} + +#[test] +fn reaps_nested_reexports_via_relative() { + assert_reap_matches( + &ctx( + NESTED_BARREL, + None, + "fixtures/nested-reexports/via-relative/**", + ), + "tests/fixtures/nested-reexports/via-relative/consumer.expected", + ); +} + // ---------- fixture: mixed (default + named + renamed + type) ---------- #[test]