Skip to content

Commit 74d57e1

Browse files
committed
fix(compiler): remove type-only exports
1 parent d3d9cce commit 74d57e1

File tree

1 file changed

+190
-1
lines changed

1 file changed

+190
-1
lines changed

crates/oxc_angular_compiler/src/component/import_elision.rs

Lines changed: 190 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -726,6 +726,7 @@ impl<'a> ImportElisionAnalyzer<'a> {
726726
/// Returns edits that remove type-only import specifiers from the source.
727727
/// Entire import declarations are removed if all their specifiers are type-only,
728728
/// or if the import has no specifiers at all (`import {} from 'module'`).
729+
/// Type-only export declarations (`export type { X }`, `export { type X }`) are also removed.
729730
pub fn import_elision_edits<'a>(
730731
source: &str,
731732
program: &Program<'a>,
@@ -741,7 +742,21 @@ pub fn import_elision_edits<'a>(
741742
false
742743
});
743744

744-
if !analyzer.has_type_only_imports() && !has_empty_imports {
745+
// Check if there are type-only exports that need removal
746+
let has_type_only_exports = program.body.iter().any(|stmt| {
747+
if let Statement::ExportNamedDeclaration(export_decl) = stmt {
748+
if export_decl.source.is_some() || export_decl.declaration.is_some() {
749+
return export_decl.export_kind.is_type();
750+
}
751+
if export_decl.export_kind.is_type() {
752+
return true;
753+
}
754+
return export_decl.specifiers.iter().any(|spec| spec.export_kind.is_type());
755+
}
756+
false
757+
});
758+
759+
if !analyzer.has_type_only_imports() && !has_empty_imports && !has_type_only_exports {
745760
return Vec::new();
746761
}
747762

@@ -856,6 +871,79 @@ pub fn import_elision_edits<'a>(
856871
}
857872
}
858873

874+
// Process type-only export declarations
875+
for stmt in &program.body {
876+
let Statement::ExportNamedDeclaration(export_decl) = stmt else {
877+
continue;
878+
};
879+
880+
// Skip exports with declarations (e.g. `export class X {}`)
881+
if export_decl.declaration.is_some() {
882+
continue;
883+
}
884+
885+
if export_decl.export_kind.is_type() {
886+
// `export type { X }` or `export type { X } from './foo'` — remove entirely
887+
let start = export_decl.span.start as usize;
888+
let mut end = export_decl.span.end as usize;
889+
let bytes = source.as_bytes();
890+
while end < bytes.len() && (bytes[end] == b'\n' || bytes[end] == b'\r') {
891+
end += 1;
892+
}
893+
edits.push(Edit::delete(start as u32, end as u32));
894+
continue;
895+
}
896+
897+
// Check for individual type-only specifiers (`export { type X, Y }`)
898+
let (type_specs, value_specs): (Vec<_>, Vec<_>) =
899+
export_decl.specifiers.iter().partition(|spec| spec.export_kind.is_type());
900+
901+
if type_specs.is_empty() {
902+
continue;
903+
}
904+
905+
let start = export_decl.span.start as usize;
906+
let mut end = export_decl.span.end as usize;
907+
let bytes = source.as_bytes();
908+
while end < bytes.len() && (bytes[end] == b'\n' || bytes[end] == b'\r') {
909+
end += 1;
910+
}
911+
912+
if value_specs.is_empty() {
913+
// All specifiers are type-only — remove entire statement
914+
edits.push(Edit::delete(start as u32, end as u32));
915+
} else {
916+
// Partial removal — reconstruct with only value specifiers
917+
let mut named_specifiers: Vec<String> = Vec::new();
918+
for spec in &value_specs {
919+
let local_name = spec.local.name().as_str();
920+
let exported_name = spec.exported.name().as_str();
921+
if local_name == exported_name {
922+
named_specifiers.push(local_name.to_string());
923+
} else {
924+
named_specifiers.push(format!("{local_name} as {exported_name}"));
925+
}
926+
}
927+
928+
let mut new_export = String::from("export { ");
929+
new_export.push_str(&named_specifiers.join(", "));
930+
new_export.push_str(" }");
931+
932+
if let Some(source_lit) = &export_decl.source {
933+
new_export.push_str(" from \"");
934+
new_export.push_str(source_lit.value.as_str());
935+
new_export.push('"');
936+
}
937+
new_export.push(';');
938+
939+
if end > export_decl.span.end as usize {
940+
new_export.push('\n');
941+
}
942+
943+
edits.push(Edit::replace(start as u32, end as u32, new_export));
944+
}
945+
}
946+
859947
edits
860948
}
861949

@@ -1990,4 +2078,105 @@ class UsersTableComponent {}
19902078
filtered
19912079
);
19922080
}
2081+
2082+
#[test]
2083+
fn test_export_type_with_import_both_removed() {
2084+
let source = r#"
2085+
import { Config } from './config';
2086+
export type { Config };
2087+
"#;
2088+
let filtered = filter_source(source);
2089+
assert!(
2090+
!filtered.contains("Config"),
2091+
"Both import and export type should be removed.\nFiltered:\n{}",
2092+
filtered
2093+
);
2094+
assert!(
2095+
!filtered.contains("export"),
2096+
"export type declaration should be removed.\nFiltered:\n{}",
2097+
filtered
2098+
);
2099+
}
2100+
2101+
#[test]
2102+
fn test_export_type_multiple_specifiers_removed() {
2103+
let source = r#"
2104+
import { Foo, Bar } from './types';
2105+
export type { Foo, Bar };
2106+
"#;
2107+
let filtered = filter_source(source);
2108+
assert!(
2109+
!filtered.contains("Foo"),
2110+
"Foo should be removed.\nFiltered:\n{}",
2111+
filtered
2112+
);
2113+
assert!(
2114+
!filtered.contains("Bar"),
2115+
"Bar should be removed.\nFiltered:\n{}",
2116+
filtered
2117+
);
2118+
}
2119+
2120+
#[test]
2121+
fn test_export_mixed_type_and_value_specifiers() {
2122+
let source = r#"
2123+
import { Component } from '@angular/core';
2124+
import { Foo, Bar } from './types';
2125+
export { type Foo, Bar };
2126+
2127+
@Component({ selector: 'test' })
2128+
class TestComponent {
2129+
value = Bar;
2130+
}
2131+
"#;
2132+
let filtered = filter_source(source);
2133+
// type Foo should be removed, Bar should remain
2134+
assert!(
2135+
!filtered.contains("Foo"),
2136+
"type-only Foo should be removed from export.\nFiltered:\n{}",
2137+
filtered
2138+
);
2139+
assert!(
2140+
filtered.contains("export { Bar }"),
2141+
"Value export Bar should remain.\nFiltered:\n{}",
2142+
filtered
2143+
);
2144+
}
2145+
2146+
#[test]
2147+
fn test_export_type_with_source_removed() {
2148+
let source = r#"
2149+
import { Component } from '@angular/core';
2150+
export type { Config } from './config';
2151+
2152+
@Component({ selector: 'test' })
2153+
class TestComponent {}
2154+
"#;
2155+
let filtered = filter_source(source);
2156+
assert!(
2157+
!filtered.contains("Config"),
2158+
"export type with source should be removed.\nFiltered:\n{}",
2159+
filtered
2160+
);
2161+
}
2162+
2163+
#[test]
2164+
fn test_value_export_not_affected() {
2165+
let source = r#"
2166+
import { Component } from '@angular/core';
2167+
import { helper } from './utils';
2168+
export { helper };
2169+
2170+
@Component({ selector: 'test' })
2171+
class TestComponent {
2172+
value = helper();
2173+
}
2174+
"#;
2175+
let filtered = filter_source(source);
2176+
assert!(
2177+
filtered.contains("export { helper }"),
2178+
"Value export should not be affected.\nFiltered:\n{}",
2179+
filtered
2180+
);
2181+
}
19932182
}

0 commit comments

Comments
 (0)