Skip to content
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
183 changes: 182 additions & 1 deletion crates/oxc_angular_compiler/src/component/import_elision.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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>,
Expand All @@ -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();
}

Expand Down Expand Up @@ -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<String> = 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
}

Expand Down Expand Up @@ -1990,4 +2078,97 @@ 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
);
}
}
Loading