Skip to content

Commit c6baba3

Browse files
Brooooooklynclaude
andcommitted
fix: align .d.ts declarations with Angular TS compiler
- Fix factory CtorDeps to emit object literal types with attribute/optional/host/self/skipSelf - Emit `null` instead of `""` for pipe names when not specified - Add type_argument_count support for generic Injectable/Pipe/NgModule - Handle control character escaping (\n, \r, \t) in dts strings - Surface ng-content selectors from template compilation pipeline - Generate ngAcceptInputType_* fields for inputs with transforms - Add comprehensive tests for all dts generation scenarios Fix #86 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 4356c38 commit c6baba3

File tree

3 files changed

+592
-44
lines changed

3 files changed

+592
-44
lines changed

crates/oxc_angular_compiler/src/component/transform.rs

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1768,6 +1768,7 @@ pub fn transform_angular_file(
17681768
type_argument_count,
17691769
&content_query_names,
17701770
has_injectable,
1771+
&compilation_result.ng_content_selectors,
17711772
));
17721773

17731774
result.component_count += 1;
@@ -1937,11 +1938,15 @@ pub fn transform_angular_file(
19371938
}
19381939

19391940
// Generate .d.ts type declaration for this pipe
1941+
let type_argument_count =
1942+
class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32);
19401943
let has_injectable =
19411944
extract_injectable_metadata(allocator, class).is_some();
1942-
result
1943-
.dts_declarations
1944-
.push(dts::generate_pipe_dts(&pipe_metadata, has_injectable));
1945+
result.dts_declarations.push(dts::generate_pipe_dts(
1946+
&pipe_metadata,
1947+
type_argument_count,
1948+
has_injectable,
1949+
));
19451950

19461951
class_positions.push((
19471952
class_name.clone(),
@@ -2025,11 +2030,15 @@ pub fn transform_angular_file(
20252030
}
20262031

20272032
// Generate .d.ts type declaration for this NgModule
2033+
let type_argument_count =
2034+
class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32);
20282035
let has_injectable =
20292036
extract_injectable_metadata(allocator, class).is_some();
2030-
result
2031-
.dts_declarations
2032-
.push(dts::generate_ng_module_dts(&ng_module_metadata, has_injectable));
2037+
result.dts_declarations.push(dts::generate_ng_module_dts(
2038+
&ng_module_metadata,
2039+
type_argument_count,
2040+
has_injectable,
2041+
));
20332042

20342043
// NgModule: external_decls go AFTER the class (they reference the class name)
20352044
class_positions.push((
@@ -2083,9 +2092,12 @@ pub fn transform_angular_file(
20832092
);
20842093

20852094
// Generate .d.ts type declaration for this injectable
2086-
result
2087-
.dts_declarations
2088-
.push(dts::generate_injectable_dts(&injectable_metadata));
2095+
let type_argument_count =
2096+
class.type_parameters.as_ref().map_or(0, |tp| tp.params.len() as u32);
2097+
result.dts_declarations.push(dts::generate_injectable_dts(
2098+
&injectable_metadata,
2099+
type_argument_count,
2100+
));
20892101

20902102
class_positions.push((
20912103
class_name.clone(),
@@ -2231,6 +2243,9 @@ struct FullCompilationResult {
22312243
/// The next constant pool index to use for the next component.
22322244
/// This is used to share pool state across multiple components in the same file.
22332245
next_pool_index: u32,
2246+
2247+
/// The ng-content selectors found in the template (e.g., `["*", ".header"]`).
2248+
ng_content_selectors: Vec<String>,
22342249
}
22352250

22362251
/// Compile a component template and generate ɵcmp/ɵfac definitions.
@@ -2305,6 +2320,10 @@ fn compile_component_full<'a>(
23052320
return Err(diagnostics);
23062321
}
23072322

2323+
// Capture ng-content selectors from the R3 AST for .d.ts generation
2324+
let ng_content_selectors: Vec<String> =
2325+
r3_result.ng_content_selectors.iter().map(|s| s.to_string()).collect();
2326+
23082327
// Merge inline template styles into component metadata
23092328
// These are styles from <style> tags directly in the template HTML
23102329
for style in r3_result.styles.iter() {
@@ -2545,6 +2564,7 @@ fn compile_component_full<'a>(
25452564
hmr_initializer_js,
25462565
class_debug_info_js,
25472566
next_pool_index,
2567+
ng_content_selectors,
25482568
})
25492569
}
25502570

crates/oxc_angular_compiler/src/dts.rs

Lines changed: 140 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ pub fn generate_component_dts(
5252
type_argument_count: u32,
5353
content_query_names: &[String],
5454
has_injectable: bool,
55+
ng_content_selectors: &[String],
5556
) -> DtsDeclaration {
5657
let class_name = metadata.class_name.as_str();
5758
let type_with_params = type_with_parameters(class_name, type_argument_count);
@@ -103,8 +104,19 @@ pub fn generate_component_dts(
103104
)
104105
};
105106

106-
// NgContentSelectors: would require template analysis; use never for now
107-
let ng_content_selectors = "never".to_string();
107+
// NgContentSelectors: format as tuple type from template ng-content selectors
108+
let ng_content_selectors = if ng_content_selectors.is_empty() {
109+
"never".to_string()
110+
} else {
111+
format!(
112+
"[{}]",
113+
ng_content_selectors
114+
.iter()
115+
.map(|s| format!("\"{}\"", escape_dts_string(s)))
116+
.collect::<Vec<_>>()
117+
.join(", ")
118+
)
119+
};
108120

109121
let is_standalone = if metadata.standalone { "true" } else { "false" };
110122

@@ -140,6 +152,9 @@ pub fn generate_component_dts(
140152
.push_str(&format!("\nstatic ɵprov: i0.ɵɵInjectableDeclaration<{type_with_params}>;"));
141153
}
142154

155+
// Add ngAcceptInputType_* fields for non-signal inputs with transform functions
156+
generate_input_transform_fields(&metadata.inputs, &mut members);
157+
143158
DtsDeclaration { class_name: class_name.to_string(), members }
144159
}
145160

@@ -241,6 +256,9 @@ pub fn generate_directive_dts(
241256
.push_str(&format!("\nstatic ɵprov: i0.ɵɵInjectableDeclaration<{type_with_params}>;"));
242257
}
243258

259+
// Add ngAcceptInputType_* fields for non-signal inputs with transform functions
260+
generate_input_transform_fields(&metadata.inputs, &mut members);
261+
244262
DtsDeclaration { class_name: class_name.to_string(), members }
245263
}
246264

@@ -253,10 +271,13 @@ pub fn generate_directive_dts(
253271
/// Produces:
254272
/// - `static ɵfac: i0.ɵɵFactoryDeclaration<T, CtorDeps>;`
255273
/// - `static ɵpipe: i0.ɵɵPipeDeclaration<T, Name, IsStandalone>;`
256-
pub fn generate_pipe_dts(metadata: &PipeMetadata, has_injectable: bool) -> DtsDeclaration {
274+
pub fn generate_pipe_dts(
275+
metadata: &PipeMetadata,
276+
type_argument_count: u32,
277+
has_injectable: bool,
278+
) -> DtsDeclaration {
257279
let class_name = metadata.class_name.as_str();
258-
// Pipes don't have type parameters in practice
259-
let type_with_params = class_name.to_string();
280+
let type_with_params = type_with_parameters(class_name, type_argument_count);
260281

261282
// ɵfac declaration
262283
let ctor_deps_type =
@@ -267,7 +288,7 @@ pub fn generate_pipe_dts(metadata: &PipeMetadata, has_injectable: bool) -> DtsDe
267288
// ɵpipe declaration
268289
let pipe_name = match &metadata.pipe_name {
269290
Some(name) => format!("\"{}\"", escape_dts_string(name.as_str())),
270-
None => "\"\"".to_string(),
291+
None => "null".to_string(),
271292
};
272293

273294
let is_standalone = if metadata.standalone { "true" } else { "false" };
@@ -296,9 +317,13 @@ pub fn generate_pipe_dts(metadata: &PipeMetadata, has_injectable: bool) -> DtsDe
296317
/// - `static ɵfac: i0.ɵɵFactoryDeclaration<T, CtorDeps>;`
297318
/// - `static ɵmod: i0.ɵɵNgModuleDeclaration<T, Declarations, Imports, Exports>;`
298319
/// - `static ɵinj: i0.ɵɵInjectorDeclaration<T>;`
299-
pub fn generate_ng_module_dts(metadata: &NgModuleMetadata, has_injectable: bool) -> DtsDeclaration {
320+
pub fn generate_ng_module_dts(
321+
metadata: &NgModuleMetadata,
322+
type_argument_count: u32,
323+
has_injectable: bool,
324+
) -> DtsDeclaration {
300325
let class_name = metadata.class_name.as_str();
301-
let type_with_params = class_name.to_string();
326+
let type_with_params = type_with_parameters(class_name, type_argument_count);
302327

303328
// ɵfac declaration
304329
let ctor_deps_type =
@@ -375,9 +400,12 @@ pub fn generate_ng_module_dts(metadata: &NgModuleMetadata, has_injectable: bool)
375400
/// Produces:
376401
/// - `static ɵfac: i0.ɵɵFactoryDeclaration<T, CtorDeps>;`
377402
/// - `static ɵprov: i0.ɵɵInjectableDeclaration<T>;`
378-
pub fn generate_injectable_dts(metadata: &InjectableMetadata) -> DtsDeclaration {
403+
pub fn generate_injectable_dts(
404+
metadata: &InjectableMetadata,
405+
type_argument_count: u32,
406+
) -> DtsDeclaration {
379407
let class_name = metadata.class_name.as_str();
380-
let type_with_params = class_name.to_string();
408+
let type_with_params = type_with_parameters(class_name, type_argument_count);
381409

382410
// ɵfac declaration
383411
let ctor_deps_type =
@@ -412,22 +440,47 @@ fn type_with_parameters(class_name: &str, count: u32) -> String {
412440

413441
/// Generate the constructor deps type parameter for `ɵɵFactoryDeclaration`.
414442
///
415-
/// Returns `never` if there are no `@Attribute()` dependencies.
416-
/// Returns a tuple type like `[null, "attrName", null]` if there are attribute deps.
443+
/// Returns `never` if no dependency has any special flags (attribute, optional, host, self, skipSelf).
444+
/// Otherwise returns a tuple type like `[null, {attribute: "title", optional: true}, null]`.
417445
fn generate_ctor_deps_type_from_component_deps(deps: Option<&[R3DependencyMetadata]>) -> String {
418446
match deps {
419447
None => "never".to_string(),
420448
Some(deps) => {
421-
let has_attributes = deps.iter().any(|d| d.attribute_name.is_some());
422-
if !has_attributes {
449+
let dep_types: Vec<Option<String>> = deps
450+
.iter()
451+
.map(|d| {
452+
let mut entries: Vec<String> = Vec::new();
453+
if let Some(name) = &d.attribute_name {
454+
entries
455+
.push(format!("attribute: \"{}\"", escape_dts_string(name.as_str())));
456+
}
457+
if d.optional {
458+
entries.push("optional: true".to_string());
459+
}
460+
if d.host {
461+
entries.push("host: true".to_string());
462+
}
463+
if d.self_ {
464+
entries.push("self: true".to_string());
465+
}
466+
if d.skip_self {
467+
entries.push("skipSelf: true".to_string());
468+
}
469+
if entries.is_empty() {
470+
None
471+
} else {
472+
Some(format!("{{ {} }}", entries.join(", ")))
473+
}
474+
})
475+
.collect();
476+
477+
let has_types = dep_types.iter().any(|t| t.is_some());
478+
if !has_types {
423479
"never".to_string()
424480
} else {
425-
let entries: Vec<String> = deps
426-
.iter()
427-
.map(|d| match &d.attribute_name {
428-
Some(name) => format!("\"{}\"", escape_dts_string(name.as_str())),
429-
None => "null".to_string(),
430-
})
481+
let entries: Vec<String> = dep_types
482+
.into_iter()
483+
.map(|t| t.unwrap_or_else(|| "null".to_string()))
431484
.collect();
432485
format!("[{}]", entries.join(", "))
433486
}
@@ -439,35 +492,80 @@ fn generate_ctor_deps_type_from_component_deps(deps: Option<&[R3DependencyMetada
439492
///
440493
/// Uses the factory module's `R3DependencyMetadata` which is used by directives,
441494
/// pipes, NgModules, and injectables.
495+
///
496+
/// Returns `never` if no dependency has any special flags (attribute, optional, host, self, skipSelf).
497+
/// Otherwise returns a tuple type like `[null, {attribute: string, optional: true}, null]`.
442498
fn generate_ctor_deps_type_from_factory_deps(
443499
deps: Option<&[crate::factory::R3DependencyMetadata]>,
444500
) -> String {
445501
match deps {
446502
None => "never".to_string(),
447503
Some(deps) => {
448-
// The factory R3DependencyMetadata uses `attribute_name_type` (an OutputExpression)
449-
// for @Attribute() dependencies. If any dep has it set, we emit a tuple type.
450-
let has_attributes = deps.iter().any(|d| d.attribute_name_type.is_some());
451-
if !has_attributes {
504+
let dep_types: Vec<Option<String>> = deps
505+
.iter()
506+
.map(|d| {
507+
let mut entries: Vec<String> = Vec::new();
508+
if d.attribute_name_type.is_some() {
509+
entries.push("attribute: string".to_string());
510+
}
511+
if d.optional {
512+
entries.push("optional: true".to_string());
513+
}
514+
if d.host {
515+
entries.push("host: true".to_string());
516+
}
517+
if d.self_ {
518+
entries.push("self: true".to_string());
519+
}
520+
if d.skip_self {
521+
entries.push("skipSelf: true".to_string());
522+
}
523+
if entries.is_empty() {
524+
None
525+
} else {
526+
Some(format!("{{ {} }}", entries.join(", ")))
527+
}
528+
})
529+
.collect();
530+
531+
let has_types = dep_types.iter().any(|t| t.is_some());
532+
if !has_types {
452533
"never".to_string()
453534
} else {
454-
let entries: Vec<String> = deps
455-
.iter()
456-
.map(|d| {
457-
if d.attribute_name_type.is_some() {
458-
// @Attribute deps get a string type in the tuple
459-
"string".to_string()
460-
} else {
461-
"null".to_string()
462-
}
463-
})
535+
let entries: Vec<String> = dep_types
536+
.into_iter()
537+
.map(|t| t.unwrap_or_else(|| "null".to_string()))
464538
.collect();
465539
format!("[{}]", entries.join(", "))
466540
}
467541
}
468542
}
469543
}
470544

545+
/// Generate `ngAcceptInputType_*` static fields for non-signal inputs with transform functions.
546+
///
547+
/// When an input has a `transform` function (e.g., `@Input({transform: booleanAttribute})`),
548+
/// Angular generates a static field like:
549+
/// ```text
550+
/// static ngAcceptInputType_disabled: unknown;
551+
/// ```
552+
/// This enables template type-checking to know that transformed inputs accept wider types.
553+
///
554+
/// Signal inputs do NOT generate these fields (they capture WriteT within the InputSignal type).
555+
///
556+
/// Note: We use `unknown` as the type because we don't have access to the TypeScript type checker
557+
/// to determine the actual write type of the transform function.
558+
fn generate_input_transform_fields(inputs: &[R3InputMetadata], members: &mut String) {
559+
for input in inputs {
560+
if !input.is_signal && input.transform_function.is_some() {
561+
members.push_str(&format!(
562+
"\nstatic ngAcceptInputType_{}: unknown;",
563+
input.class_property_name.as_str()
564+
));
565+
}
566+
}
567+
}
568+
471569
/// Generate the input map type for `ɵɵComponentDeclaration` / `ɵɵDirectiveDeclaration`.
472570
///
473571
/// Produces a TypeScript object literal type like:
@@ -628,7 +726,11 @@ fn extract_directive_name_from_expr(expr: &crate::output::ast::OutputExpression)
628726

629727
/// Escape a string for use in a TypeScript `.d.ts` string literal type.
630728
fn escape_dts_string(s: &str) -> String {
631-
s.replace('\\', "\\\\").replace('"', "\\\"")
729+
s.replace('\\', "\\\\")
730+
.replace('"', "\\\"")
731+
.replace('\n', "\\n")
732+
.replace('\r', "\\r")
733+
.replace('\t', "\\t")
632734
}
633735

634736
#[cfg(test)]
@@ -650,6 +752,9 @@ mod tests {
650752
assert_eq!(escape_dts_string("hello"), "hello");
651753
assert_eq!(escape_dts_string(r#"he"llo"#), r#"he\"llo"#);
652754
assert_eq!(escape_dts_string(r"he\llo"), r"he\\llo");
755+
assert_eq!(escape_dts_string("line1\nline2"), "line1\\nline2");
756+
assert_eq!(escape_dts_string("col1\tcol2"), "col1\\tcol2");
757+
assert_eq!(escape_dts_string("a\r\nb"), "a\\r\\nb");
653758
}
654759

655760
#[test]

0 commit comments

Comments
 (0)