From 74d57e1f9d6fe44fc45403d419089f6b4e17104f Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:11:51 +0900 Subject: [PATCH 1/2] fix(compiler): remove type-only exports --- .../src/component/import_elision.rs | 191 +++++++++++++++++- 1 file changed, 190 insertions(+), 1 deletion(-) diff --git a/crates/oxc_angular_compiler/src/component/import_elision.rs b/crates/oxc_angular_compiler/src/component/import_elision.rs index 5a5c377a7..c2ba12cba 100644 --- a/crates/oxc_angular_compiler/src/component/import_elision.rs +++ b/crates/oxc_angular_compiler/src/component/import_elision.rs @@ -726,6 +726,7 @@ impl<'a> ImportElisionAnalyzer<'a> { /// Returns edits that remove type-only import specifiers from the source. /// Entire import declarations are removed if all their specifiers are type-only, /// or if the import has no specifiers at all (`import {} from 'module'`). +/// Type-only export declarations (`export type { X }`, `export { type X }`) are also removed. pub fn import_elision_edits<'a>( source: &str, program: &Program<'a>, @@ -741,7 +742,21 @@ pub fn import_elision_edits<'a>( false }); - if !analyzer.has_type_only_imports() && !has_empty_imports { + // Check if there are type-only exports that need removal + let has_type_only_exports = program.body.iter().any(|stmt| { + if let Statement::ExportNamedDeclaration(export_decl) = stmt { + if export_decl.source.is_some() || export_decl.declaration.is_some() { + return export_decl.export_kind.is_type(); + } + if export_decl.export_kind.is_type() { + return true; + } + return export_decl.specifiers.iter().any(|spec| spec.export_kind.is_type()); + } + false + }); + + if !analyzer.has_type_only_imports() && !has_empty_imports && !has_type_only_exports { return Vec::new(); } @@ -856,6 +871,79 @@ pub fn import_elision_edits<'a>( } } + // Process type-only export declarations + for stmt in &program.body { + let Statement::ExportNamedDeclaration(export_decl) = stmt else { + continue; + }; + + // Skip exports with declarations (e.g. `export class X {}`) + if export_decl.declaration.is_some() { + continue; + } + + if export_decl.export_kind.is_type() { + // `export type { X }` or `export type { X } from './foo'` — remove entirely + let start = export_decl.span.start as usize; + let mut end = export_decl.span.end as usize; + let bytes = source.as_bytes(); + while end < bytes.len() && (bytes[end] == b'\n' || bytes[end] == b'\r') { + end += 1; + } + edits.push(Edit::delete(start as u32, end as u32)); + continue; + } + + // Check for individual type-only specifiers (`export { type X, Y }`) + let (type_specs, value_specs): (Vec<_>, Vec<_>) = + export_decl.specifiers.iter().partition(|spec| spec.export_kind.is_type()); + + if type_specs.is_empty() { + continue; + } + + let start = export_decl.span.start as usize; + let mut end = export_decl.span.end as usize; + let bytes = source.as_bytes(); + while end < bytes.len() && (bytes[end] == b'\n' || bytes[end] == b'\r') { + end += 1; + } + + if value_specs.is_empty() { + // All specifiers are type-only — remove entire statement + edits.push(Edit::delete(start as u32, end as u32)); + } else { + // Partial removal — reconstruct with only value specifiers + let mut named_specifiers: Vec = Vec::new(); + for spec in &value_specs { + let local_name = spec.local.name().as_str(); + let exported_name = spec.exported.name().as_str(); + if local_name == exported_name { + named_specifiers.push(local_name.to_string()); + } else { + named_specifiers.push(format!("{local_name} as {exported_name}")); + } + } + + let mut new_export = String::from("export { "); + new_export.push_str(&named_specifiers.join(", ")); + new_export.push_str(" }"); + + if let Some(source_lit) = &export_decl.source { + new_export.push_str(" from \""); + new_export.push_str(source_lit.value.as_str()); + new_export.push('"'); + } + new_export.push(';'); + + if end > export_decl.span.end as usize { + new_export.push('\n'); + } + + edits.push(Edit::replace(start as u32, end as u32, new_export)); + } + } + edits } @@ -1990,4 +2078,105 @@ class UsersTableComponent {} filtered ); } + + #[test] + fn test_export_type_with_import_both_removed() { + let source = r#" +import { Config } from './config'; +export type { Config }; +"#; + let filtered = filter_source(source); + assert!( + !filtered.contains("Config"), + "Both import and export type should be removed.\nFiltered:\n{}", + filtered + ); + assert!( + !filtered.contains("export"), + "export type declaration should be removed.\nFiltered:\n{}", + filtered + ); + } + + #[test] + fn test_export_type_multiple_specifiers_removed() { + let source = r#" +import { Foo, Bar } from './types'; +export type { Foo, Bar }; +"#; + let filtered = filter_source(source); + assert!( + !filtered.contains("Foo"), + "Foo should be removed.\nFiltered:\n{}", + filtered + ); + assert!( + !filtered.contains("Bar"), + "Bar should be removed.\nFiltered:\n{}", + filtered + ); + } + + #[test] + fn test_export_mixed_type_and_value_specifiers() { + let source = r#" +import { Component } from '@angular/core'; +import { Foo, Bar } from './types'; +export { type Foo, Bar }; + +@Component({ selector: 'test' }) +class TestComponent { + value = Bar; +} +"#; + let filtered = filter_source(source); + // type Foo should be removed, Bar should remain + assert!( + !filtered.contains("Foo"), + "type-only Foo should be removed from export.\nFiltered:\n{}", + filtered + ); + assert!( + filtered.contains("export { Bar }"), + "Value export Bar should remain.\nFiltered:\n{}", + filtered + ); + } + + #[test] + fn test_export_type_with_source_removed() { + let source = r#" +import { Component } from '@angular/core'; +export type { Config } from './config'; + +@Component({ selector: 'test' }) +class TestComponent {} +"#; + let filtered = filter_source(source); + assert!( + !filtered.contains("Config"), + "export type with source should be removed.\nFiltered:\n{}", + filtered + ); + } + + #[test] + fn test_value_export_not_affected() { + let source = r#" +import { Component } from '@angular/core'; +import { helper } from './utils'; +export { helper }; + +@Component({ selector: 'test' }) +class TestComponent { + value = helper(); +} +"#; + let filtered = filter_source(source); + assert!( + filtered.contains("export { helper }"), + "Value export should not be affected.\nFiltered:\n{}", + filtered + ); + } } From e70861f331e463002c01869223392a46167387ca Mon Sep 17 00:00:00 2001 From: sapphi-red <49056869+sapphi-red@users.noreply.github.com> Date: Fri, 27 Mar 2026 20:23:48 +0900 Subject: [PATCH 2/2] chore: format --- .../src/component/import_elision.rs | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/import_elision.rs b/crates/oxc_angular_compiler/src/component/import_elision.rs index c2ba12cba..459df1d9a 100644 --- a/crates/oxc_angular_compiler/src/component/import_elision.rs +++ b/crates/oxc_angular_compiler/src/component/import_elision.rs @@ -2105,16 +2105,8 @@ import { Foo, Bar } from './types'; export type { Foo, Bar }; "#; let filtered = filter_source(source); - assert!( - !filtered.contains("Foo"), - "Foo should be removed.\nFiltered:\n{}", - filtered - ); - assert!( - !filtered.contains("Bar"), - "Bar should be removed.\nFiltered:\n{}", - filtered - ); + assert!(!filtered.contains("Foo"), "Foo should be removed.\nFiltered:\n{}", filtered); + assert!(!filtered.contains("Bar"), "Bar should be removed.\nFiltered:\n{}", filtered); } #[test]