Skip to content

Commit a08f16e

Browse files
Brooooooklynclaude
andauthored
fix: import elision for namespace imports, arrow function bodies, empty imports, and star export barrels (#18)
- Elide namespace imports (`import * as X`) when only used in type positions - Traverse arrow function bodies to find value references (e.g., `forwardRef(() => Comp)`) - Remove empty imports (`import {} from 'module'`) - Fix cross-file type detection through star export barrels (`export * from './module'`) - Remove duplicate match arms in expand_safe_reads Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 55ed478 commit a08f16e

3 files changed

Lines changed: 195 additions & 23 deletions

File tree

crates/oxc_angular_compiler/src/component/cross_file_elision.rs

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -312,6 +312,9 @@ impl CrossFileAnalyzer {
312312
exports.get(export_name).cloned()
313313
};
314314

315+
// If not found directly, check star exports (export * from './other')
316+
let export_info = export_info.or_else(|| self.find_in_star_exports(file_path, export_name));
317+
315318
let Some(export_info) = export_info else {
316319
return false;
317320
};
@@ -920,4 +923,55 @@ export { doSomething } from './utils';
920923
let resolved = analyzer.resolve_import_source_path("./index", "RenamedExport", &main_file);
921924
assert_eq!(resolved, Some("./original".to_string()));
922925
}
926+
927+
/// Regression test for quick-access.component.ts:
928+
/// `import { WIDGET_CONTROL, WidgetControlService } from '../../widget-control'`
929+
/// where `widget-control/index.ts` has `export * from './widget-control.service'`
930+
/// and `widget-control.service.ts` has `export interface WidgetControlService { ... }`
931+
///
932+
/// The interface should be detected as type-only through the barrel star export chain.
933+
#[test]
934+
fn test_interface_through_star_export_barrel_is_type_only() {
935+
let dir = TempDir::new().unwrap();
936+
// widget-control/widget-control.service.ts — interface (type-only)
937+
create_test_file(
938+
dir.path(),
939+
"widget-control/widget-control.service.ts",
940+
"export interface WidgetControlService { hideWidget(widgetId: string): void; }",
941+
);
942+
// widget-control/widget-control.token.ts — const (runtime value)
943+
create_test_file(
944+
dir.path(),
945+
"widget-control/widget-control.token.ts",
946+
"import { InjectionToken } from '@angular/core';\nexport const WIDGET_CONTROL = new InjectionToken('WidgetControlService');",
947+
);
948+
// widget-control/index.ts — barrel re-export via star
949+
create_test_file(
950+
dir.path(),
951+
"widget-control/index.ts",
952+
"export * from './widget-control.service';\nexport * from './widget-control.token';",
953+
);
954+
955+
let main_file = dir.path().join("quick-access/quick-access/main.ts");
956+
// Create parent dir so the path is valid
957+
std::fs::create_dir_all(main_file.parent().unwrap()).unwrap();
958+
959+
let mut analyzer = CrossFileAnalyzer::new(dir.path(), None);
960+
961+
// WidgetControlService is an interface — should be type-only
962+
assert!(
963+
analyzer.is_type_only_import(
964+
"../../widget-control",
965+
"WidgetControlService",
966+
&main_file
967+
),
968+
"WidgetControlService interface should be detected as type-only through star export barrel"
969+
);
970+
971+
// WIDGET_CONTROL is a const — should NOT be type-only
972+
assert!(
973+
!analyzer.is_type_only_import("../../widget-control", "WIDGET_CONTROL", &main_file),
974+
"WIDGET_CONTROL const should NOT be type-only"
975+
);
976+
}
923977
}

crates/oxc_angular_compiler/src/component/import_elision.rs

Lines changed: 141 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -114,8 +114,13 @@ impl<'a> ImportElisionAnalyzer<'a> {
114114
type_only_specifiers.insert(name.clone());
115115
}
116116
}
117-
ImportDeclarationSpecifier::ImportNamespaceSpecifier(_) => {
118-
// Namespace imports are generally kept (hard to determine type-only usage)
117+
ImportDeclarationSpecifier::ImportNamespaceSpecifier(spec) => {
118+
// Check if the namespace import is type-only using semantic analysis.
119+
// e.g., `import * as moment from 'moment'` where `moment` is only
120+
// used in type annotations like `moment.Moment`.
121+
if Self::is_type_only_import(&spec.local, semantic) {
122+
type_only_specifiers.insert(spec.local.name.clone());
123+
}
119124
}
120125
}
121126
}
@@ -587,9 +592,25 @@ impl<'a> ImportElisionAnalyzer<'a> {
587592
}
588593
}
589594
}
590-
Expression::ArrowFunctionExpression(_) => {
591-
// For arrow functions, we don't need to deeply analyze the body
592-
// since we're only checking decorator positions
595+
Expression::ArrowFunctionExpression(arrow) => {
596+
// Arrow function bodies may contain value references, e.g.,
597+
// `forwardRef(() => TagPickerComponent)` in Component imports.
598+
for stmt in &arrow.body.statements {
599+
match stmt {
600+
Statement::ExpressionStatement(expr_stmt) => {
601+
Self::collect_value_uses_from_expr(
602+
&expr_stmt.expression,
603+
other_value_uses,
604+
);
605+
}
606+
Statement::ReturnStatement(ret) => {
607+
if let Some(arg) = &ret.argument {
608+
Self::collect_value_uses_from_expr(arg, other_value_uses);
609+
}
610+
}
611+
_ => {}
612+
}
613+
}
593614
}
594615
_ => {}
595616
}
@@ -701,13 +722,24 @@ impl<'a> ImportElisionAnalyzer<'a> {
701722
/// Filter import declarations to remove type-only specifiers.
702723
///
703724
/// Returns a new source string with type-only import specifiers removed.
704-
/// Entire import declarations are removed if all their specifiers are type-only.
725+
/// Entire import declarations are removed if all their specifiers are type-only,
726+
/// or if the import has no specifiers at all (`import {} from 'module'`).
705727
pub fn filter_imports<'a>(
706728
source: &str,
707729
program: &Program<'a>,
708730
analyzer: &ImportElisionAnalyzer<'a>,
709731
) -> String {
710-
if !analyzer.has_type_only_imports() {
732+
// Check if there are empty imports that need removal (import {} from '...')
733+
let has_empty_imports = program.body.iter().any(|stmt| {
734+
if let Statement::ImportDeclaration(import_decl) = stmt {
735+
if let Some(specifiers) = &import_decl.specifiers {
736+
return specifiers.is_empty();
737+
}
738+
}
739+
false
740+
});
741+
742+
if !analyzer.has_type_only_imports() && !has_empty_imports {
711743
return source.to_string();
712744
}
713745

@@ -740,8 +772,8 @@ pub fn filter_imports<'a>(
740772
!analyzer.should_elide(name)
741773
});
742774

743-
if removed.is_empty() {
744-
// All specifiers kept, no changes needed
775+
if removed.is_empty() && !kept.is_empty() {
776+
// All specifiers kept and at least one exists, no changes needed
745777
continue;
746778
}
747779

@@ -1870,4 +1902,104 @@ class MyComponent {
18701902
"myKey in parenthesized type literal should be preserved"
18711903
);
18721904
}
1905+
1906+
// =========================================================================
1907+
// Regression tests for ClickUp import elision mismatches
1908+
// =========================================================================
1909+
1910+
#[test]
1911+
fn test_namespace_import_type_only_should_be_elided() {
1912+
// Reproduces: bookmark.component.ts
1913+
// `import * as moment from 'moment'` where `moment` is only used as
1914+
// `moment.Moment` in type annotations should be elided.
1915+
let source = r#"
1916+
import * as moment from 'moment';
1917+
import { Component } from '@angular/core';
1918+
1919+
@Component({ selector: 'app-bookmark' })
1920+
class BookmarkComponent {
1921+
dueDate: moment.Moment = null;
1922+
}
1923+
"#;
1924+
let type_only = analyze_source(source);
1925+
// `moment` is only referenced in type position (moment.Moment)
1926+
// so it should be marked for elision
1927+
assert!(
1928+
type_only.contains("moment"),
1929+
"Namespace import `moment` used only in type annotation `moment.Moment` should be elided"
1930+
);
1931+
}
1932+
1933+
#[test]
1934+
fn test_namespace_import_with_value_usage_preserved() {
1935+
// Namespace import that IS used at runtime should be preserved
1936+
let source = r#"
1937+
import * as moment from 'moment';
1938+
import { Component } from '@angular/core';
1939+
1940+
@Component({ selector: 'app-test' })
1941+
class TestComponent {
1942+
now = moment();
1943+
}
1944+
"#;
1945+
let type_only = analyze_source(source);
1946+
assert!(
1947+
!type_only.contains("moment"),
1948+
"Namespace import `moment` used in value expression `moment()` should be preserved"
1949+
);
1950+
}
1951+
1952+
#[test]
1953+
fn test_forward_ref_arrow_function_preserves_value_use() {
1954+
// Reproduces: tags.component.ts
1955+
// `forwardRef(() => TagPickerComponent)` in @Component imports array
1956+
// uses TagPickerComponent as a value inside an arrow function.
1957+
// The arrow function body MUST be traversed to find value uses.
1958+
let source = r#"
1959+
import { Component, forwardRef, Inject, Optional, SkipSelf } from '@angular/core';
1960+
import { TagPickerComponent } from './tag-picker/tag-picker.component';
1961+
1962+
@Component({
1963+
selector: 'app-tags',
1964+
imports: [forwardRef(() => TagPickerComponent)]
1965+
})
1966+
class TagsComponent {
1967+
constructor(
1968+
@Optional()
1969+
@SkipSelf()
1970+
@Inject(TagPickerComponent)
1971+
readonly tagPickerComponent: TagPickerComponent,
1972+
) {}
1973+
}
1974+
"#;
1975+
let type_only = analyze_source(source);
1976+
// TagPickerComponent is used as a value in forwardRef(() => TagPickerComponent)
1977+
// and as @Inject(TagPickerComponent) argument. It must NOT be elided.
1978+
assert!(
1979+
!type_only.contains("TagPickerComponent"),
1980+
"TagPickerComponent used in forwardRef arrow function and @Inject should be preserved"
1981+
);
1982+
}
1983+
1984+
#[test]
1985+
fn test_empty_import_should_be_elided() {
1986+
// Reproduces: users-table.component.ts
1987+
// `import {} from '@cu/teams-pulse/types'` is an empty import that
1988+
// should be completely removed (no specifiers to keep).
1989+
let source = r#"
1990+
import { Component } from '@angular/core';
1991+
import {} from '@cu/teams-pulse/types';
1992+
1993+
@Component({ selector: 'app-users-table' })
1994+
class UsersTableComponent {}
1995+
"#;
1996+
let filtered = filter_source(source);
1997+
1998+
// The empty import should be removed entirely
1999+
assert!(
2000+
!filtered.contains("@cu/teams-pulse/types"),
2001+
"Empty import `import {{}} from '@cu/teams-pulse/types'` should be removed.\nFiltered:\n{}",
2002+
filtered
2003+
);
2004+
}
18732005
}

crates/oxc_angular_compiler/src/pipeline/phases/expand_safe_reads.rs

Lines changed: 0 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -174,20 +174,6 @@ fn needs_temporary_in_safe_access(expr: &IrExpression<'_>) -> bool {
174174
needs_temporary_in_safe_access(&keyed.receiver)
175175
|| needs_temporary_in_safe_access(&keyed.key)
176176
}
177-
// Binary operators need to check both operands
178-
IrExpression::Binary(bin) => {
179-
needs_temporary_in_safe_access(&bin.lhs) || needs_temporary_in_safe_access(&bin.rhs)
180-
}
181-
// Ternary needs to check all branches
182-
IrExpression::Ternary(ternary) => {
183-
needs_temporary_in_safe_access(&ternary.condition)
184-
|| needs_temporary_in_safe_access(&ternary.true_expr)
185-
|| needs_temporary_in_safe_access(&ternary.false_expr)
186-
}
187-
// Not expression needs to check operand
188-
IrExpression::Not(not) => needs_temporary_in_safe_access(&not.expr),
189-
// Unary operator needs to check operand
190-
IrExpression::Unary(unary) => needs_temporary_in_safe_access(&unary.expr),
191177
// Check AST expressions for function calls
192178
IrExpression::Ast(ast_expr) => needs_temporary_in_ast_expression(ast_expr),
193179
// Parenthesized expressions need to check their inner expression

0 commit comments

Comments
 (0)