From 8e873638a02c07011fde08922843e106495f82b4 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Thu, 8 Oct 2026 23:28:41 +0800 Subject: [PATCH 01/10] fix(dts): keep the comments ngtsc keeps on `ngAcceptInputType_*` types The dts type printer dropped every comment; ngtsc's path through TypeScript's emitter (`onlyPrintJsDocStyle`) keeps `/**`-style and `/*!` comments on a node's leading/trailing edges, plus every comment the intervening list scans collect. Rewrite the printer to port the emitter's comment paths verbatim: `emitNodeList`/`emitNodeListItems` list handling with `ListFormat` flags, `emitTokenWithComment`, the `containerPos`/`containerEnd` dedup in `emitCommentsBeforeNode`/`emitCommentsAfterNode`, `iterateCommentRanges` (including U+2028/U+2029 counting as line breaks while the scan reads past them), and `writeCommentRange` re-indenting. This also reproduces the quirks: a comment scanned by two enclosing lists prints twice, and a comment after a named tuple member's `?` is dropped because the colon's position scan starts after it. Verified against ngtsc 22.1.7: the `COMMENTS_NOT_KEPT` fixture becomes `COMMENTS_KEPT` and gains the remaining issue cases (`string /** doc */`, `string | number /** doc */`, a leading `/** doc */` on its own line, a multiline `/* */` inside type arguments, and the doubled `Array<\u2028` comment). Closes #506 --- .../src/directive/dts_type.rs | 1992 +++++++++++++---- .../src/directive/evaluator.rs | 2 + .../tests/dts_input_transform_type_test.rs | 63 +- 3 files changed, 1557 insertions(+), 500 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs index 2d233d75f..785e69630 100644 --- a/crates/oxc_angular_compiler/src/directive/dts_type.rs +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -9,20 +9,87 @@ //! Every part of the type is printed from the AST, never copied from the //! source, so no position can keep a name ngtsc would have rewritten. A form //! this printer doesn't cover makes the whole type `unknown`. +//! +//! Comments follow TypeScript's declaration emit (`onlyPrintJsDocStyle`): +//! node-level leading/trailing comment scans keep `/**`-style (and `/*!`) +//! comments only, while the intervening scan inside a list keeps every +//! comment — which is also why a comment can print twice, once per enclosing +//! list's intervening scan. Which trivia a scan sees comes from +//! `iterateCommentRanges`: a trailing scan collects comments on `pos`'s +//! line, a leading one everything after the first `\r`/`\n` — except that +//! U+2028/U+2029 are whitespace for the scan while the line map counts them +//! as line breaks. +//! +//! ngtsc's `markForEmitAsSingleLine` marks the whole translated type +//! subtree, so under ngtsc 22.1.7 every list uses its single-line format. +//! The comment paths still need the writer's line tracking: a `//` comment +//! or a `/**` with `hasTrailingNewLine` ends the line, and the next write +//! indents it. use oxc_ast::ast::{ - BigIntLiteral, BindingPattern, Expression, FormalParameters, PropertyKey, StringLiteral, - TSLiteral, TSMappedTypeModifierOperator, TSMethodSignatureKind, TSSignature, TSThisParameter, - TSTupleElement, TSType, TSTypeAnnotation, TSTypeName, TSTypeOperatorOperator, TSTypeParameter, - TSTypeParameterDeclaration, TSTypeParameterInstantiation, TSTypePredicateName, - TSTypeQueryExprName, UnaryOperator, + BigIntLiteral, BindingPattern, BindingProperty, BindingRestElement, Expression, + FormalParameter, FormalParameterRest, FormalParameters, PropertyKey, StringLiteral, + TSIndexSignatureName, TSLiteral, TSMappedTypeModifierOperator, TSMethodSignatureKind, + TSNamedTupleMember, TSSignature, TSThisParameter, TSTupleElement, TSType, TSTypeAnnotation, + TSTypeName, TSTypeOperatorOperator, TSTypeParameter, TSTypeParameterDeclaration, + TSTypeParameterInstantiation, TSTypePredicateName, TSTypeQueryExprName, UnaryOperator, }; -use oxc_span::GetSpan; +use oxc_span::{GetSpan, Span}; use super::evaluator::{AliasTarget, FileScope}; use crate::output::emitter::format_number_like_js; use crate::r3::CORE; +/// TypeScript's `ListFormat` bits (`emitter.ts`) for the lists this printer +/// emits. ngtsc marks the type `EmitFlags.SingleLine`, so only the +/// single-line formats are reachable: `MultiLine`/`Indented` are defined for +/// completeness but never engage — which is also why `increaseIndent` never +/// runs and the writer indent stays at one level (four spaces). +mod fmt { + pub const BAR: u32 = 4; + pub const AMPERSAND: u32 = 8; + pub const COMMA: u32 = 16; + pub const INDENTED: u32 = 128; + pub const SPACE_BETWEEN_BRACES: u32 = 256; + pub const SPACE_BETWEEN_SIBLINGS: u32 = 512; + pub const PARENTHESIS: u32 = 1024; + pub const ANGLE: u32 = 2048; + pub const SQUARE: u32 = 8192; + pub const OPTIONAL_UNDEFINED: u32 = 16384; + pub const OPTIONAL_EMPTY: u32 = 32768; + pub const NO_INTERVENING: u32 = 262144; + pub const NO_SPACE_IF_EMPTY: u32 = 524288; + pub const SPACE_AFTER_LIST: u32 = 2097152; + + pub const DELIMITERS: u32 = BAR | AMPERSAND | COMMA | 32; // DelimitersMask + pub const BRACKETS: u32 = PARENTHESIS | ANGLE | 4096 | SQUARE; // BracketsMask + + /// `UnionTypeConstituents` / `IntersectionTypeConstituents`. + pub const UNION: u32 = BAR | SPACE_BETWEEN_SIBLINGS; + pub const INTERSECTION: u32 = AMPERSAND | SPACE_BETWEEN_SIBLINGS; + /// `TypeParameters` / `TypeArguments`. + pub const TYPE_ARGS: u32 = + OPTIONAL_UNDEFINED | OPTIONAL_EMPTY | ANGLE | COMMA | SPACE_BETWEEN_SIBLINGS; + /// `Parameters`. + pub const PARAMETERS: u32 = PARENTHESIS | COMMA | SPACE_BETWEEN_SIBLINGS; + /// `IndexSignatureParameters`. + pub const INDEX_PARAMETERS: u32 = SQUARE | INDENTED | COMMA | SPACE_BETWEEN_SIBLINGS; + /// `SingleLineTypeLiteralMembers` (`NoSpaceIfEmpty` is added at the site). + pub const TYPE_LITERAL_MEMBERS: u32 = SPACE_BETWEEN_BRACES | SPACE_BETWEEN_SIBLINGS; + /// `SingleLineTupleTypeElements` (`NoSpaceIfEmpty` is added at the site). + pub const TUPLE_ELEMENTS: u32 = COMMA | SPACE_BETWEEN_SIBLINGS; + /// `ObjectBindingPatternElements`. + pub const OBJECT_BINDING: u32 = + SPACE_BETWEEN_BRACES | COMMA | SPACE_BETWEEN_SIBLINGS | NO_SPACE_IF_EMPTY; + /// `ArrayBindingPatternElements`. + pub const ARRAY_BINDING: u32 = COMMA | SPACE_BETWEEN_SIBLINGS | NO_SPACE_IF_EMPTY; + /// `Modifiers`: a space-separated, comment-free list. + pub const MODIFIERS: u32 = SPACE_BETWEEN_SIBLINGS | NO_INTERVENING | SPACE_AFTER_LIST; +} + +/// The writer indent (`getIndentString`): `static` members sit at one level. +const INDENT: &str = " "; + pub(crate) struct TypePrinter<'s, 'a> { pub scope: &'s FileScope<'a>, pub source: &'a str, @@ -35,44 +102,333 @@ pub(crate) struct TypePrinter<'s, 'a> { /// since aliases numbered per source file can't be merged into bundled /// declaration files safely. pub other_module: bool, + /// TypeScript's printer dedupes comments through `containerPos` / + /// `containerEnd`: a node's leading comments are skipped when its `pos` + /// (trivia start) is the enclosing emitted node's, and its trailing + /// comments when its `end` is the enclosing node's. These hold the + /// enclosing node's range while [`Self::emit_node`] prints one. + pub container_pos: u32, + pub container_end: u32, +} + +/// A list element for [`TypePrinter::emit_list`]: a node with a span plus the +/// body that prints it, like `emitListItem` calling `emit` on a `TSSignature`, +/// `Parameter`, `TypeNode`, … +enum El<'n, 'a> { + Ty(&'n TSType<'a>), + /// A `TSTupleElement` (`TSOptionalType`/`TSRestType`/`TSType`). + TupleEl(&'n TSTupleElement<'a>), + /// A `TSSignature` member of a type literal. + Member(&'n TSSignature<'a>), + /// A `TSTypeParameter`. + TypeParam(&'n TSTypeParameter<'a>), + /// A `FormalParameter`. + Param(&'n FormalParameter<'a>), + /// The `this` parameter. + This(&'n TSThisParameter<'a>), + /// A `FormalParameterRest`. + Rest(&'n FormalParameterRest<'a>), + /// An index signature's `k: T` (`TSIndexSignatureName`, a `Parameter`). + IndexParam(&'n TSIndexSignatureName<'a>), + /// A `BindingPattern` (a `BindingElement`'s name). + Binding(&'n BindingPattern<'a>), + /// A `BindingProperty` (object-pattern `key: name` or shorthand `name`). + BindingProp(&'n BindingProperty<'a>), + /// A `BindingRestElement` (`...arg`). + BindingRest(&'n BindingRestElement<'a>), + /// A span-printed token: a modifier keyword, a `?`, or an omitted array + /// element (zero length at the comma). + Token { + span: Span, + text: &'static str, + }, +} + +impl El<'_, '_> { + fn span(&self) -> Span { + match self { + El::Ty(t) => t.span(), + El::TupleEl(e) => e.span(), + El::Member(m) => m.span(), + El::TypeParam(t) => t.span, + El::Param(p) => p.span, + El::This(t) => t.span, + El::Rest(r) => r.span, + El::IndexParam(p) => p.span, + El::Binding(b) => b.span(), + El::BindingProp(p) => p.span, + El::BindingRest(r) => r.span, + El::Token { span, .. } => *span, + } + } } impl<'a> TypePrinter<'_, 'a> { /// `unknown` when the type has a form ngtsc can't emit either (an /// `import('...')` type) or that isn't valid in a type annotation. - pub(crate) fn print(&mut self, ty: &TSType<'_>) -> String { - self.ty(ty).unwrap_or_else(|| "unknown".to_string()) - } - - fn ty(&mut self, ty: &TSType<'_>) -> Option { - Some(match ty { - TSType::TSAnyKeyword(_) => "any".into(), - TSType::TSBigIntKeyword(_) => "bigint".into(), - TSType::TSBooleanKeyword(_) => "boolean".into(), - TSType::TSIntrinsicKeyword(_) => "intrinsic".into(), - TSType::TSNeverKeyword(_) => "never".into(), - TSType::TSNullKeyword(_) => "null".into(), - TSType::TSNumberKeyword(_) => "number".into(), - TSType::TSObjectKeyword(_) => "object".into(), - TSType::TSStringKeyword(_) => "string".into(), - TSType::TSSymbolKeyword(_) => "symbol".into(), - TSType::TSUndefinedKeyword(_) => "undefined".into(), - TSType::TSUnknownKeyword(_) => "unknown".into(), - TSType::TSVoidKeyword(_) => "void".into(), - TSType::TSThisType(_) => "this".into(), + pub(crate) fn print(&mut self, ty: &TSType<'a>) -> String { + let mut out = String::new(); + match self.ty_node(&mut out, ty) { + Some(()) => out, + None => "unknown".to_string(), + } + } + + /// A node's `pos` (start of its leading trivia): the end of the previous + /// token. + fn pos_of(&self, start: u32) -> u32 { + token_end_before(self.source, start as usize).unwrap_or(start as usize) as u32 + } + + /// Emit a node like TypeScript's `emit(node)`: its leading comments, the + /// node body, then its trailing comments — `emitCommentsBeforeNode` / + /// `emitCommentsAfterNode` around every emitted node. `pos` is the trivia + /// start and `end` the token end; the scans dedupe against the enclosing + /// node through `container_pos` / `container_end`. With `pos == end` no + /// comments run (`emitComments*` skip that range) and nothing wraps + /// `body`. + fn emit_node( + &mut self, + out: &mut String, + pos: u32, + end: u32, + body: impl FnOnce(&mut Self, &mut String) -> Option<()>, + ) -> Option<()> { + if pos == end { + return body(self, out); + } + self.emit_leading_of_pos(out, pos as usize); + let (saved_pos, saved_end) = (self.container_pos, self.container_end); + self.container_pos = pos; + self.container_end = end; + let ok = body(self, out); + // Trailing comments compare against the *enclosing* node's range — + // the saved values are restored first, like `emitTrailingCommentsOfNode`. + self.container_pos = saved_pos; + self.container_end = saved_end; + self.emit_trailing(out, end as usize); + ok + } + + /// [`Self::emit_node`] for a `TSType`. + fn ty_node(&mut self, out: &mut String, ty: &TSType<'a>) -> Option<()> { + let pos = self.pos_of(ty.span().start); + self.emit_node(out, pos, ty.span().end, |s, o| s.ty_body(o, ty)) + } + + /// `write`/`writeComment`/`writeSpace`/`writePunctuation`: the text, with + /// the writer indent prepended at a line start (after a `writeLine`, + /// which comments emit). + fn write(&self, out: &mut String, s: &str) { + if out.ends_with('\n') { + out.push_str(INDENT); + } + out.push_str(s); + } + + /// `emitNodeList` + `emitNodeListItems`. + /// + /// `items` are the list's children; `list_pos`/`list_end` are the + /// `NodeArray`'s trivia range (inside the brackets — used only for the + /// empty-list comment scans); `parent_end` is the enclosing node's `end` + /// — the gate that keeps the comments after the last element out when the + /// parent ends there too. `open`/`close` are the bracket texts for + /// `BracketsMask` formats. + fn emit_list( + &mut self, + out: &mut String, + items: &[El<'_, 'a>], + format: u32, + parent_end: u32, + list_pos: usize, + list_end: usize, + open: Option<&'static str>, + close: Option<&'static str>, + ) -> Option<()> { + if items.is_empty() && format & fmt::OPTIONAL_EMPTY != 0 { + return Some(()); + } + if format & fmt::BRACKETS != 0 { + self.write(out, open.expect("bracketed format")); + if items.is_empty() { + // `emitTrailingCommentsOfPosition(children.pos, prefixSpace)`: + // JSDoc comments inside the empty brackets. + self.emit_trailing_filtered(out, list_pos); + } + } + if items.is_empty() { + // The MultiLine branch never runs (single-line formats only). + if format & fmt::SPACE_BETWEEN_BRACES != 0 && format & fmt::NO_SPACE_IF_EMPTY == 0 { + self.write(out, " "); + } + } else { + self.emit_list_items(out, items, format, parent_end)?; + } + if format & fmt::BRACKETS != 0 { + if items.is_empty() { + // `emitLeadingCommentsOfPosition(children.end)`. + self.emit_leading_of_pos(out, list_end); + } + self.write(out, close.expect("bracketed format")); + } + Some(()) + } + + /// `emitNodeListItems`, always on the single-line path: every list this + /// printer uses is `SingleLine`-flagged, so the leading, separating and + /// closing line-terminator counts are all `0` and `Indented` never runs. + fn emit_list_items( + &mut self, + out: &mut String, + items: &[El<'_, 'a>], + format: u32, + parent_end: u32, + ) -> Option<()> { + let may_intervene = format & fmt::NO_INTERVENING == 0; + let mut should_intervene = may_intervene; + if format & fmt::SPACE_BETWEEN_BRACES != 0 { + self.write(out, " "); + } + let mut prev_end: Option = None; + for item in items { + let span = item.span(); + let pos = self.pos_of(span.start); + if prev_end.is_some() { + // The JSDoc comments between the previous element and the + // delimiter, then the delimiter, then the separator space + // (the separating-line-terminator branch never fires). + if format & fmt::DELIMITERS != 0 && prev_end != Some(parent_end) { + self.emit_leading_of_pos(out, prev_end.unwrap() as usize); + } + self.write_delimiter(out, format); + if format & fmt::SPACE_BETWEEN_SIBLINGS != 0 { + self.write(out, " "); + } + } + if should_intervene { + // `emitTrailingCommentsOfPosition(child.pos)`: the + // unfiltered same-line scan — this is what keeps `/* */` + // comments the node-level paths drop. + self.emit_intervening(out, pos as usize); + } else { + should_intervene = may_intervene; + } + self.emit_item(out, item, pos)?; + prev_end = Some(span.end); + } + // `emitTrailingComma`: no format here has both `AllowTrailingComma` + // and `CommaDelimited`, so it's never emitted. + if let Some(prev_end) = prev_end + && prev_end != parent_end + && format & fmt::DELIMITERS != 0 + { + // The JSDoc comments after the last element, before the closing + // token. + self.emit_leading_of_pos(out, prev_end as usize); + } + if format & (fmt::SPACE_AFTER_LIST | fmt::SPACE_BETWEEN_BRACES) != 0 { + self.write(out, " "); + } + Some(()) + } + + /// `writeDelimiter`: the separator token and its leading space. + fn write_delimiter(&self, out: &mut String, format: u32) { + match format & fmt::DELIMITERS { + d if d == fmt::COMMA => self.write(out, ","), + d if d == fmt::BAR => { + self.write(out, " "); + self.write(out, "|"); + } + d if d == fmt::AMPERSAND => { + self.write(out, " "); + self.write(out, "&"); + } + _ => {} + } + } + + /// `emitListItem` for one [`El`]: `emit(node)`, with the comment range of + /// the node itself. + fn emit_item(&mut self, out: &mut String, item: &El<'_, 'a>, pos: u32) -> Option<()> { + let end = item.span().end; + match item { + El::Ty(ty) => self.ty_node(out, ty), + El::TupleEl(e) => self.emit_node(out, pos, end, |s, o| s.tuple_element_body(o, e)), + El::Member(m) => self.emit_node(out, pos, end, |s, o| s.member_body(o, m)), + El::TypeParam(tp) => self.emit_node(out, pos, end, |s, o| s.type_param_body(o, tp)), + El::Param(p) => self.emit_node(out, pos, end, |s, o| s.param_body(o, p)), + El::This(t) => self.emit_node(out, pos, end, |s, o| s.this_body(o, t)), + El::Rest(r) => self.emit_node(out, pos, end, |s, o| s.rest_body(o, r)), + El::IndexParam(p) => self.emit_node(out, pos, end, |s, o| s.index_param_body(o, p)), + El::Binding(b) => self.emit_node(out, pos, end, |s, o| s.binding_body(o, b)), + El::BindingProp(p) => self.emit_node(out, pos, end, |s, o| s.binding_prop_body(o, p)), + El::BindingRest(r) => self.emit_node(out, pos, end, |s, o| s.binding_rest_body(o, r)), + El::Token { text, .. } => self.emit_node(out, pos, end, |s, o| { + s.write(o, text); + Some(()) + }), + } + } + + /// A type's body (`emit` minus the comment pipeline, which + /// [`Self::emit_node`] supplies). + fn ty_body(&mut self, out: &mut String, ty: &TSType<'a>) -> Option<()> { + match ty { + TSType::TSAnyKeyword(_) => self.write(out, "any"), + TSType::TSBigIntKeyword(_) => self.write(out, "bigint"), + TSType::TSBooleanKeyword(_) => self.write(out, "boolean"), + TSType::TSIntrinsicKeyword(_) => self.write(out, "intrinsic"), + TSType::TSNeverKeyword(_) => self.write(out, "never"), + TSType::TSNullKeyword(_) => self.write(out, "null"), + TSType::TSNumberKeyword(_) => self.write(out, "number"), + TSType::TSObjectKeyword(_) => self.write(out, "object"), + TSType::TSStringKeyword(_) => self.write(out, "string"), + TSType::TSSymbolKeyword(_) => self.write(out, "symbol"), + TSType::TSUndefinedKeyword(_) => self.write(out, "undefined"), + TSType::TSUnknownKeyword(_) => self.write(out, "unknown"), + TSType::TSVoidKeyword(_) => self.write(out, "void"), + TSType::TSThisType(_) => self.write(out, "this"), TSType::TSTypeReference(r) => { - let mut out = self.type_name(&r.type_name)?; + // `emit(node.typeName)`: the whole entity name as one node — + // TypeScript emits qualified names part by part, but the + // comments each part would keep land the same either way. + let text = self.type_name(&r.type_name)?; + self.emit_node( + out, + self.pos_of(r.type_name.span().start), + r.type_name.span().end, + |s, o| { + s.write(o, &text); + Some(()) + }, + )?; if let Some(args) = &r.type_arguments { - out.push_str(&self.type_args(args)?); + self.type_args(out, args)?; } - out } - TSType::TSUnionType(u) => self.constituents(&u.types, u.span.start, "|")?, - TSType::TSIntersectionType(i) => self.constituents(&i.types, i.span.start, "&")?, - TSType::TSParenthesizedType(p) => format!("({})", self.ty(&p.type_annotation)?), - TSType::TSArrayType(a) => format!("{}[]", self.ty(&a.element_type)?), + TSType::TSUnionType(u) => { + let items: Vec = u.types.iter().map(El::Ty).collect(); + self.emit_list(out, &items, fmt::UNION, u.span.end, 0, 0, None, None)?; + } + TSType::TSIntersectionType(i) => { + let items: Vec = i.types.iter().map(El::Ty).collect(); + self.emit_list(out, &items, fmt::INTERSECTION, i.span.end, 0, 0, None, None)?; + } + TSType::TSParenthesizedType(p) => { + self.write(out, "("); + self.ty_node(out, &p.type_annotation)?; + self.write(out, ")"); + } + TSType::TSArrayType(a) => { + self.ty_node(out, &a.element_type)?; + self.write(out, "[]"); + } TSType::TSIndexedAccessType(i) => { - format!("{}[{}]", self.ty(&i.object_type)?, self.ty(&i.index_type)?) + self.ty_node(out, &i.object_type)?; + self.write(out, "["); + self.ty_node(out, &i.index_type)?; + self.write(out, "]"); } TSType::TSTypeOperatorType(o) => { let op = match o.operator { @@ -80,123 +436,864 @@ impl<'a> TypePrinter<'_, 'a> { TSTypeOperatorOperator::Unique => "unique", TSTypeOperatorOperator::Readonly => "readonly", }; - format!("{op} {}", self.ty(&o.type_annotation)?) + self.write(out, op); + self.write(out, " "); + self.ty_node(out, &o.type_annotation)?; } TSType::TSTupleType(t) => { - let items = - self.elements(Some(t.span.start), &t.element_types, Self::tuple_element)?; - format!("[{}]", items.join(", ")) - } - TSType::TSNamedTupleMember(m) => format!( - "{}{}: {}", - m.label.name, - if m.optional { "?" } else { "" }, - self.tuple_element(&m.element_type)? - ), - TSType::TSLiteralType(l) => self.literal(&l.literal)?, + // `emitTokenWithComment` for both brackets: `[` carries its + // trailing same-line comments, `]` the JSDoc ones before it. + self.token_with_comment( + out, + "[", + self.pos_of(t.span.start), + t.span.start + 1, + t.span.end, + )?; + let items: Vec = t.element_types.iter().map(El::TupleEl).collect(); + let list_pos = t.span.start as usize + 1; + let list_end = t.element_types.last().map_or(list_pos, |e| e.span().end as usize); + self.emit_list( + out, + &items, + fmt::TUPLE_ELEMENTS | fmt::NO_SPACE_IF_EMPTY, + t.span.end, + list_pos, + list_end, + None, + None, + )?; + // `emitTokenWithComment(CloseBracket, elements.end, node)`: + // the leading scan runs at the last element's end; `]`'s own + // end is the tuple's, so no trailing scan. + self.token_with_comment(out, "]", list_end as u32, t.span.end - 1, t.span.end)?; + } + TSType::TSNamedTupleMember(m) => self.named_tuple_member_body(out, m, None)?, + TSType::TSLiteralType(l) => self.literal_body(out, &l.literal)?, TSType::TSTemplateLiteralType(t) => { - let mut out = String::from("`"); + self.write(out, "`"); for (i, quasi) in t.quasis.iter().enumerate() { - out.push_str(quasi.value.raw.as_str()); + self.write(out, quasi.value.raw.as_str()); if let Some(ty) = t.types.get(i) { - out.push_str("${"); - out.push_str(&self.ty(ty)?); - out.push('}'); + self.write(out, "${"); + self.ty_node(out, ty)?; + self.write(out, "}"); } } - out.push('`'); - out + self.write(out, "`"); } TSType::TSTypeQuery(q) => { // `typeof x` names a value; ngtsc leaves it as written. - let mut out = match &q.expr_name { - TSTypeQueryExprName::IdentifierReference(id) => format!("typeof {}", id.name), + self.write(out, "typeof "); + let (span, text) = match &q.expr_name { + TSTypeQueryExprName::IdentifierReference(id) => (id.span, id.name.to_string()), TSTypeQueryExprName::QualifiedName(name) => { - format!("typeof {}.{}", entity_name(&name.left)?, name.right.name) + (name.span, format!("{}.{}", entity_name(&name.left)?, name.right.name)) } - TSTypeQueryExprName::ThisExpression(_) => "typeof this".into(), + TSTypeQueryExprName::ThisExpression(e) => (e.span, "this".to_string()), TSTypeQueryExprName::TSImportType(_) => return None, }; + self.emit_node(out, self.pos_of(span.start), span.end, |s, o| { + s.write(o, &text); + Some(()) + })?; if let Some(args) = &q.type_arguments { - out.push_str(&self.type_args(args)?); + self.type_args(out, args)?; } - out } - TSType::TSTypeLiteral(l) if l.members.is_empty() => "{}".into(), TSType::TSTypeLiteral(l) => { - let members = self.elements(Some(l.span.start), &l.members, Self::member)?; - format!("{{ {} }}", members.join(" ")) + self.write(out, "{"); + let items: Vec = l.members.iter().map(El::Member).collect(); + self.emit_list( + out, + &items, + fmt::TYPE_LITERAL_MEMBERS | fmt::NO_SPACE_IF_EMPTY, + l.span.end, + l.span.start as usize + 1, + l.span.end as usize - 1, + None, + None, + )?; + self.write(out, "}"); } TSType::TSMappedType(m) => { - let readonly = match m.readonly { - None => "", - Some(TSMappedTypeModifierOperator::True) => "readonly ", - Some(TSMappedTypeModifierOperator::Plus) => "+readonly ", - Some(TSMappedTypeModifierOperator::Minus) => "-readonly ", - }; - let optional = match m.optional { - None => "", - Some(TSMappedTypeModifierOperator::True) => "?", - Some(TSMappedTypeModifierOperator::Plus) => "+?", - Some(TSMappedTypeModifierOperator::Minus) => "-?", - }; - let name_type = match &m.name_type { - Some(t) => format!(" as {}", self.ty(t)?), - None => String::new(), - }; - let value = match &m.type_annotation { - Some(t) => self.ty(t)?, - None => String::new(), - }; - format!( - "{{ {readonly}[{} in {}{name_type}]{optional}: {value}; }}", - m.key.name, - self.ty(&m.constraint)? - ) - } - TSType::TSFunctionType(f) => format!( - "{}{} => {}", - self.type_params(f.type_parameters.as_deref())?, - self.params(f.this_param.as_deref(), &f.params)?, - self.ty(&f.return_type.type_annotation)? - ), - TSType::TSConstructorType(c) => format!( - "{}new {}{} => {}", - if c.r#abstract { "abstract " } else { "" }, - self.type_params(c.type_parameters.as_deref())?, - self.params(None, &c.params)?, - self.ty(&c.return_type.type_annotation)? - ), - TSType::TSConditionalType(c) => format!( - "{} extends {} ? {} : {}", - self.ty(&c.check_type)?, - self.ty(&c.extends_type)?, - self.ty(&c.true_type)?, - self.ty(&c.false_type)? - ), - TSType::TSInferType(i) => format!("infer {}", self.type_param(&i.type_parameter)?), + // `SingleLine` is always set: `{ ...; }` on one line. + self.write(out, "{ "); + if let Some(readonly) = &m.readonly { + let (sign, len) = match readonly { + TSMappedTypeModifierOperator::True => ("", "readonly".len()), + TSMappedTypeModifierOperator::Plus => ("+", "+readonly".len()), + TSMappedTypeModifierOperator::Minus => ("-", "-readonly".len()), + }; + // `emit(node.readonlyToken)` + `writeSpace`. + let start = skip_trivia(self.source, m.span.start as usize + 1); + self.emit_node( + out, + self.pos_of(start as u32), + (start + len) as u32, + |s, o| { + s.write(o, sign); + s.write(o, "readonly"); + Some(()) + }, + )?; + self.write(out, " "); + } + self.write(out, "["); + // `emitMappedTypeParameter`: `K in T` (the mapped modifiers + // live outside this node in TypeScript's AST too). + self.emit_node( + out, + self.pos_of(m.key.span.start), + m.constraint.span().end, + |s, o| { + s.emit_node(o, s.pos_of(m.key.span.start), m.key.span.end, |s2, o2| { + s2.write(o2, &m.key.name); + Some(()) + })?; + s.write(o, " in "); + s.ty_node(o, &m.constraint) + }, + )?; + let mut after = m.constraint.span().end; + if let Some(name_type) = &m.name_type { + self.write(out, " as "); + self.ty_node(out, name_type)?; + after = name_type.span().end; + } + self.write(out, "]"); + if m.optional.is_some() { + let (sign, len) = match m.optional.unwrap() { + TSMappedTypeModifierOperator::True => ("", 1), + TSMappedTypeModifierOperator::Plus + | TSMappedTypeModifierOperator::Minus => ( + if m.optional == Some(TSMappedTypeModifierOperator::Plus) { + "+" + } else { + "-" + }, + 2, + ), + }; + // `emit(node.questionToken)`: the `?`/`+?`/`-?` after `]`. + let start = + skip_trivia(self.source, skip_trivia(self.source, after as usize) + 1); + self.emit_node( + out, + self.pos_of(start as u32), + (start + len) as u32, + |s, o| { + s.write(o, sign); + s.write(o, "?"); + Some(()) + }, + )?; + } + self.write(out, ": "); + if let Some(value) = &m.type_annotation { + self.ty_node(out, value)?; + } + self.write(out, "; }"); + } + TSType::TSFunctionType(f) => { + // `emitFunctionTypeHead` + `emitFunctionTypeBody`. + self.type_params(out, f.type_parameters.as_deref())?; + self.params(out, f.this_param.as_deref(), &f.params, f.span.end)?; + self.write(out, " => "); + self.ty_node(out, &f.return_type.type_annotation)?; + } + TSType::TSConstructorType(c) => { + // `emitModifierList` (`abstract`), then `new `. + if c.r#abstract { + let start = skip_trivia(self.source, c.span.start as usize); + let mods = [El::Token { + span: Span::new(start as u32, start as u32 + 8), + text: "abstract", + }]; + self.emit_list( + out, + &mods, + fmt::MODIFIERS, + c.span.end, + start + 8, + start + 8, + None, + None, + )?; + } + self.write(out, "new "); + self.type_params(out, c.type_parameters.as_deref())?; + self.params(out, None, &c.params, c.span.end)?; + self.write(out, " => "); + self.ty_node(out, &c.return_type.type_annotation)?; + } + TSType::TSConditionalType(c) => { + self.ty_node(out, &c.check_type)?; + self.write(out, " extends "); + self.ty_node(out, &c.extends_type)?; + self.write(out, " ? "); + self.ty_node(out, &c.true_type)?; + self.write(out, " : "); + self.ty_node(out, &c.false_type)?; + } + TSType::TSInferType(i) => { + self.write(out, "infer "); + self.type_param_body(out, &i.type_parameter)?; + } TSType::TSTypePredicate(p) => { - let name = match &p.parameter_name { - TSTypePredicateName::Identifier(id) => id.name.as_str(), - TSTypePredicateName::This(_) => "this", + if p.asserts { + // `emit(node.assertsModifier)` + `writeSpace`. + let start = skip_trivia(self.source, p.span.start as usize); + self.emit_node(out, self.pos_of(start as u32), (start + 7) as u32, |s, o| { + s.write(o, "asserts"); + Some(()) + })?; + self.write(out, " "); + } + let (span, text) = match &p.parameter_name { + TSTypePredicateName::Identifier(id) => (id.span, id.name.to_string()), + TSTypePredicateName::This(t) => (t.span, "this".to_string()), }; - let mut out = if p.asserts { format!("asserts {name}") } else { name.to_string() }; + self.emit_node(out, self.pos_of(span.start), span.end, |s, o| { + s.write(o, &text); + Some(()) + })?; if let Some(t) = &p.type_annotation { - out.push_str(" is "); - out.push_str(&self.ty(&t.type_annotation)?); + self.write(out, " is "); + self.ty_node(out, &t.type_annotation)?; } - out } // TypeScript reports these as errors but prints them, with the // `?`/`!` in front even when it was written after the type. - TSType::JSDocNullableType(t) => format!("?{}", self.ty(&t.type_annotation)?), - TSType::JSDocNonNullableType(t) => format!("!{}", self.ty(&t.type_annotation)?), - TSType::JSDocUnknownType(_) => "?".into(), + TSType::JSDocNullableType(t) => { + self.write(out, "?"); + self.ty_node(out, &t.type_annotation)?; + } + TSType::JSDocNonNullableType(t) => { + self.write(out, "!"); + self.ty_node(out, &t.type_annotation)?; + } + TSType::JSDocUnknownType(_) => self.write(out, "?"), // ngtsc throws "Unable to emit import type" on `import('...')`. TSType::TSImportType(_) => return None, + } + Some(()) + } + + /// `emitTokenWithComment`: the JSDoc leading comments at `pos` (skipped + /// when the enclosing node starts there), the token, then its same-line + /// JSDoc comments (skipped when the enclosing node ends at `context_end` + /// — TypeScript compares against `contextNode.end`). + fn token_with_comment( + &mut self, + out: &mut String, + token: &'static str, + pos: u32, + end: u32, + context_end: u32, + ) -> Option<()> { + self.emit_leading_of_pos(out, pos as usize); + self.write(out, token); + if end != context_end { + self.emit_trailing_filtered(out, end as usize); + } + Some(()) + } + + /// `emitNamedTupleMember`: `[...]name[?]: type`, where the colon goes + /// through `emitTokenWithComment` — which is how `lbl: /** c */ T` keeps + /// its comment. `rest` is the `TSRestType` span when the member is + /// `...name: type` (its `...` is `emit`ted, not punctuation). + fn named_tuple_member_body( + &mut self, + out: &mut String, + m: &TSNamedTupleMember<'a>, + rest: Option, + ) -> Option<()> { + if let Some(rest) = rest { + self.emit_node(out, self.pos_of(rest.start), rest.start + 3, |s, o| { + s.write(o, "..."); + Some(()) + })?; + } + // `emit(node.name)`. + self.emit_node(out, self.pos_of(m.label.span.start), m.label.span.end, |s, o| { + s.write(o, &m.label.name); + Some(()) + })?; + if m.optional { + // `emit(node.questionToken)`. + let q = skip_trivia(self.source, m.label.span.end as usize); + self.emit_node(out, self.pos_of(q as u32), q as u32 + 1, |s, o| { + s.write(o, "?"); + Some(()) + })?; + } + // `emitTokenWithComment(ColonToken, node.name.end, ...)`: the `:` is + // written where trivia after the name lands — on the `?` when + // optional — and its trailing scan runs right after that position. + let colon_end = skip_trivia(self.source, m.label.span.end as usize) + 1; + self.token_with_comment(out, ":", m.label.span.end, colon_end as u32, m.span.end)?; + self.write(out, " "); + self.tuple_element_body(out, &m.element_type) + } + + /// A tuple element (`emit` on `TSOptionalType`/`TSRestType`/`TSType`; a + /// named member is a `TSType` variant). + fn tuple_element_body(&mut self, out: &mut String, element: &TSTupleElement<'a>) -> Option<()> { + match element { + TSTupleElement::TSOptionalType(o) => { + self.ty_node(out, &o.type_annotation)?; + self.write(out, "?"); + Some(()) + } + TSTupleElement::TSRestType(r) => { + // A labeled rest (`...c: T[]`) is a named tuple member whose + // `...` is an emitted token; an unlabeled one prints `...` + // as punctuation (`emitRestOrJSDocVariadicType`). + if let TSType::TSNamedTupleMember(m) = &r.type_annotation { + self.named_tuple_member_body(out, m, Some(r.span)) + } else { + self.write(out, "..."); + self.ty_node(out, &r.type_annotation) + } + } + other => self.ty_node(out, other.to_ts_type()), + } + } + + /// `emitLiteralType` → `emitExpression`: the literal as one node, so a + /// `/** */` on its trailing edge prints. + fn literal_body(&mut self, out: &mut String, literal: &TSLiteral<'a>) -> Option<()> { + let (span, text) = match literal { + TSLiteral::BooleanLiteral(b) => (b.span, b.value.to_string()), + TSLiteral::NumericLiteral(n) => (n.span, format_number_like_js(n.value)), + TSLiteral::BigIntLiteral(b) => (b.span, self.bigint(b)), + TSLiteral::StringLiteral(s) => (s.span, quote_literal(s)), + TSLiteral::TemplateLiteral(t) if t.expressions.is_empty() => { + (t.span, self.slice(t.span)) + } + TSLiteral::UnaryExpression(u) if u.operator == UnaryOperator::UnaryNegation => ( + u.span, + match &u.argument { + Expression::NumericLiteral(n) => format!("-{}", format_number_like_js(n.value)), + Expression::BigIntLiteral(b) => format!("-{}", self.bigint(b)), + _ => return None, + }, + ), + _ => return None, + }; + self.emit_node(out, self.pos_of(span.start), span.end, |s, o| { + s.write(o, &text); + Some(()) }) } + /// TypeScript's scanner keeps hexadecimal bigints in hex (lowercased) and + /// turns the other bases into decimal, without separators. + fn bigint(&self, literal: &BigIntLiteral<'_>) -> String { + let raw = self.slice(literal.span); + match raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) { + Some(hex) => format!("0x{}", hex.replace('_', "").to_ascii_lowercase()), + None => format!("{}n", literal.value), + } + } + + /// `emitTypeArguments` / `emitTypeParameters`: a `<…>` list. + fn type_args( + &mut self, + out: &mut String, + args: &TSTypeParameterInstantiation<'a>, + ) -> Option<()> { + let items: Vec = args.params.iter().map(El::Ty).collect(); + self.emit_list( + out, + &items, + fmt::TYPE_ARGS, + args.span.end, + args.span.start as usize + 1, + args.span.end as usize - 1, + Some("<"), + Some(">"), + ) + } + + fn type_params( + &mut self, + out: &mut String, + params: Option<&TSTypeParameterDeclaration<'a>>, + ) -> Option<()> { + let Some(params) = params else { return Some(()) }; + let items: Vec = params.params.iter().map(El::TypeParam).collect(); + self.emit_list( + out, + &items, + fmt::TYPE_ARGS, + params.span.end, + params.span.start as usize + 1, + params.span.end as usize - 1, + Some("<"), + Some(">"), + ) + } + + /// `emitTypeParameter`: modifiers, the name, `extends`, `=`. + fn type_param_body(&mut self, out: &mut String, param: &TSTypeParameter<'a>) -> Option<()> { + // The modifiers (`const`/`in`/`out`) are a `Modifiers` list in + // TypeScript's AST; oxc gives flags, so the spans come from the text + // in order. + let mut mods: Vec = Vec::new(); + let mut at = param.span.start as usize; + for (set, word) in [(param.r#const, "const"), (param.r#in, "in"), (param.out, "out")] { + if set { + let start = skip_trivia(self.source, at); + mods.push(El::Token { + span: Span::new(start as u32, start as u32 + word.len() as u32), + text: word, + }); + at = start + word.len(); + } + } + if !mods.is_empty() { + self.emit_list(out, &mods, fmt::MODIFIERS, param.span.end, at, at, None, None)?; + } + self.emit_node(out, self.pos_of(param.name.span.start), param.name.span.end, |s, o| { + s.write(o, ¶m.name.name); + Some(()) + })?; + if let Some(constraint) = ¶m.constraint { + self.write(out, " extends "); + self.ty_node(out, constraint)?; + } + if let Some(default) = ¶m.default { + self.write(out, " = "); + self.ty_node(out, default)?; + } + Some(()) + } + + /// `emitParameters`: a `(…)` list of parameters. + fn params( + &mut self, + out: &mut String, + this: Option<&TSThisParameter<'a>>, + params: &FormalParameters<'a>, + parent_end: u32, + ) -> Option<()> { + let mut items: Vec = Vec::new(); + if let Some(this) = this { + items.push(El::This(this)); + } + for param in ¶ms.items { + if param.initializer.is_some() { + return None; + } + items.push(El::Param(param)); + } + if let Some(rest) = ¶ms.rest { + items.push(El::Rest(rest)); + } + self.emit_list( + out, + &items, + fmt::PARAMETERS, + parent_end, + params.span.start as usize + 1, + params.span.end as usize - 1, + Some("("), + Some(")"), + ) + } + + /// `emitParameter`: modifiers, `...`, the name, `?`, `: type`. + fn param_body(&mut self, out: &mut String, param: &FormalParameter<'a>) -> Option<()> { + if param.readonly { + let start = skip_trivia(self.source, param.span.start as usize); + let mods = + [El::Token { span: Span::new(start as u32, start as u32 + 8), text: "readonly" }]; + self.emit_list( + out, + &mods, + fmt::MODIFIERS, + param.span.end, + start + 8, + start + 8, + None, + None, + )?; + } + self.emit_node( + out, + self.pos_of(param.pattern.span().start), + param.pattern.span().end, + |s, o| s.binding_body(o, ¶m.pattern), + )?; + if param.optional { + let q = skip_trivia(self.source, param.pattern.span().end as usize); + self.emit_node(out, self.pos_of(q as u32), q as u32 + 1, |s, o| { + s.write(o, "?"); + Some(()) + })?; + } + self.annotation(out, param.type_annotation.as_deref()) + } + + /// The `this` parameter: `this` (`emitNodeWithWriter`) then `: type`. + fn this_body(&mut self, out: &mut String, this: &TSThisParameter<'a>) -> Option<()> { + self.emit_node(out, self.pos_of(this.this_span.start), this.this_span.end, |s, o| { + s.write(o, "this"); + Some(()) + })?; + self.annotation(out, this.type_annotation.as_deref()) + } + + /// A `FormalParameterRest` (`emitParameter` with a `...` token). + fn rest_body(&mut self, out: &mut String, rest: &FormalParameterRest<'a>) -> Option<()> { + // `emit(node.dotDotDotToken)`. + self.emit_node( + out, + self.pos_of(rest.rest.span.start), + rest.rest.span.start + 3, + |s, o| { + s.write(o, "..."); + Some(()) + }, + )?; + self.emit_node( + out, + self.pos_of(rest.rest.argument.span().start), + rest.rest.argument.span().end, + |s, o| s.binding_body(o, &rest.rest.argument), + )?; + self.annotation(out, rest.type_annotation.as_deref()) + } + + /// An index signature's parameter (`emitParameter`): `name: type`. The + /// name ends where `:` starts, so comments between them are trivia of + /// the annotation — same as TypeScript's `Parameter`. + fn index_param_body( + &mut self, + out: &mut String, + param: &TSIndexSignatureName<'a>, + ) -> Option<()> { + let name_start = skip_trivia(self.source, param.span.start as usize); + let name_end = token_end_before(self.source, param.type_annotation.span.start as usize) + .unwrap_or(param.type_annotation.span.start as usize); + self.emit_node(out, self.pos_of(name_start as u32), name_end as u32, |s, o| { + s.write(o, param.name.as_str()); + Some(()) + })?; + self.annotation(out, Some(¶m.type_annotation)) + } + + /// `emitParametersForIndexSignature`: a `[…]` list. + fn index_params( + &mut self, + out: &mut String, + param: &TSIndexSignatureName<'a>, + parent_end: u32, + ) -> Option<()> { + let items = [El::IndexParam(param)]; + let list_pos = self.pos_of(param.span.start) as usize; + self.emit_list( + out, + &items, + fmt::INDEX_PARAMETERS, + parent_end, + list_pos, + param.span.end as usize, + Some("["), + Some("]"), + ) + } + + /// `: T`, or nothing — `emitTypeAnnotation`. The `:` is plain + /// punctuation; the type keeps its own comments (a same-line one after + /// `:` is trivia of the type and drops, like ngtsc). + fn annotation( + &mut self, + out: &mut String, + annotation: Option<&TSTypeAnnotation<'a>>, + ) -> Option<()> { + if let Some(a) = annotation { + self.write(out, ": "); + self.ty_node(out, &a.type_annotation)?; + } + Some(()) + } + + /// A member of a type literal (`emitSignature`), ending in `;`. + fn member_body(&mut self, out: &mut String, member: &TSSignature<'a>) -> Option<()> { + match member { + TSSignature::TSPropertySignature(p) => { + if p.readonly { + self.modifiers( + out, + p.span.start, + p.span.end, + &[(p.span.start, 8, "readonly")], + )?; + } + self.key_node(out, &p.key, p.computed)?; + if p.optional { + let q = skip_trivia(self.source, p.key.span().end as usize); + self.emit_node(out, self.pos_of(q as u32), q as u32 + 1, |s, o| { + s.write(o, "?"); + Some(()) + })?; + } + self.annotation(out, p.type_annotation.as_deref())?; + self.write(out, ";"); + } + TSSignature::TSIndexSignature(s) => { + if s.readonly { + self.modifiers( + out, + s.span.start, + s.span.end, + &[(s.span.start, 8, "readonly")], + )?; + } + self.index_params(out, &s.parameter, s.span.end)?; + self.annotation(out, Some(&s.type_annotation))?; + self.write(out, ";"); + } + TSSignature::TSCallSignatureDeclaration(c) => { + self.type_params(out, c.type_parameters.as_deref())?; + self.params(out, c.this_param.as_deref(), &c.params, c.span.end)?; + self.annotation(out, c.return_type.as_deref())?; + self.write(out, ";"); + } + TSSignature::TSConstructSignatureDeclaration(c) => { + self.write(out, "new "); + self.type_params(out, c.type_parameters.as_deref())?; + self.params(out, None, &c.params, c.span.end)?; + self.annotation(out, c.return_type.as_deref())?; + self.write(out, ";"); + } + TSSignature::TSMethodSignature(m) => { + // `get`/`set` are modifiers in TypeScript's AST. + if m.kind != TSMethodSignatureKind::Method { + let (word, len) = match m.kind { + TSMethodSignatureKind::Get => ("get", 3), + TSMethodSignatureKind::Set => ("set", 3), + TSMethodSignatureKind::Method => unreachable!(), + }; + self.modifiers(out, m.span.start, m.span.end, &[(m.span.start, len, word)])?; + } + self.key_node(out, &m.key, m.computed)?; + if m.optional { + let q = skip_trivia(self.source, m.key.span().end as usize); + self.emit_node(out, self.pos_of(q as u32), q as u32 + 1, |s, o| { + s.write(o, "?"); + Some(()) + })?; + } + // `emitSignatureHead` + `emitEmptyFunctionBody`. + self.type_params(out, m.type_parameters.as_deref())?; + self.params(out, m.this_param.as_deref(), &m.params, m.span.end)?; + self.annotation(out, m.return_type.as_deref())?; + self.write(out, ";"); + } + } + Some(()) + } + + /// `emitModifierList` for keywords oxc exposes as flags. `from` is where + /// the modifier text is searched for (the node's trivia end), `end` the + /// owning node's `end`, and `mods` `(search-from, byte-len, text)` in + /// order. + fn modifiers( + &mut self, + out: &mut String, + from: u32, + end: u32, + mods: &[(u32, u32, &'static str)], + ) -> Option<()> { + let items: Vec = mods + .iter() + .map(|&(_, len, text)| { + let start = skip_trivia(self.source, from as usize); + El::Token { span: Span::new(start as u32, start as u32 + len), text } + }) + .collect(); + self.emit_list(out, &items, fmt::MODIFIERS, end, 0, 0, None, None) + } + + /// A property name as one node — `emitNodeWithWriter(node.name)` — or a + /// computed one (`emitComputedPropertyName`): `[`, the expression, `]`. + fn key_node(&mut self, out: &mut String, key: &PropertyKey<'a>, computed: bool) -> Option<()> { + if computed { + // `ComputedPropertyName`'s range: from the trivia before `[` to + // `]`'s end. oxc's key span covers the expression only. + let bracket_end = token_end_before(self.source, key.span().start as usize) + .unwrap_or(key.span().start as usize); + let pos = token_end_before(self.source, bracket_end - 1).unwrap_or(bracket_end - 1); + let end = skip_trivia(self.source, key.span().end as usize) + 1; + let text = self.key_text(key)?; + self.emit_node(out, pos as u32, end as u32, |s, o| { + s.write(o, "["); + s.emit_node(o, bracket_end as u32, key.span().end, |s2, o2| { + s2.write(o2, &text); + Some(()) + })?; + s.write(o, "]"); + Some(()) + }) + } else { + let pos = self.pos_of(key.span().start); + let end = key.span().end; + let text = self.key_text(key)?; + self.emit_node(out, pos, end, |s, o| { + s.write(o, &text); + Some(()) + }) + } + } + + /// A property name's text, re-quoted like any other string literal. A + /// computed name (`[token]`, `[ns.token]`) follows the rules of a type + /// name: an `@angular/core` value becomes `i0.token`, another module's + /// makes the type `unknown`, and a local or global one stays as written + /// (ngtsc copies the expression, which doesn't resolve for an import). + /// + /// Through an import-equals alias too, although ngtsc copies those as + /// written: an alias of `@angular/core` gives `i0.token` and one of + /// another module `unknown`, where ngtsc's name doesn't resolve in the + /// `.d.ts`. An alias of a global is written as its target + /// (`[Symbol.iterator]` for `import S = Symbol`), and one of a name the + /// file declares stays as written, like ngtsc. + fn key_text(&mut self, key: &PropertyKey<'a>) -> Option { + Some(match key { + PropertyKey::StaticIdentifier(id) => id.name.to_string(), + PropertyKey::StringLiteral(s) => quote_literal(s), + PropertyKey::NumericLiteral(n) => format_number_like_js(n.value), + PropertyKey::TemplateLiteral(t) if t.expressions.is_empty() => self.slice(t.span), + PropertyKey::Identifier(id) => self.computed_name(std::vec![id.name.as_str()])?, + PropertyKey::StaticMemberExpression(m) => { + let mut parts = std::vec::Vec::new(); + member_parts(&m.object, &mut parts)?; + parts.push(m.property.name.as_str()); + self.computed_name(parts)? + } + _ => return None, + }) + } + + /// A computed property name `a.b.c` (`parts`, head first), as + /// [`Self::key_text`] writes it. + fn computed_name(&mut self, parts: std::vec::Vec<&str>) -> Option { + let written = parts.join("."); + Some(match self.resolve_aliases(parts)? { + Resolved::Core(rest) => format!("{}{rest}", self.core_ns), + Resolved::OtherModule(_) => { + self.other_module = true; + written + } + Resolved::Name(parts, aliased) => { + let (head, members) = (parts[0], &parts[1..]); + if aliased && self.scope.import(head).is_none() && self.scope.declares(head) { + written + } else { + let rest: String = members.iter().map(|m| format!(".{m}")).collect(); + self.value_name(head, &rest) + } + } + }) + } + + /// A `BindingPattern` node (`emit(name)` for a parameter's name). + fn binding_body(&mut self, out: &mut String, pattern: &BindingPattern<'a>) -> Option<()> { + match pattern { + BindingPattern::BindingIdentifier(id) => self.write(out, &id.name), + BindingPattern::ObjectPattern(o) => { + self.write(out, "{"); + let mut items: Vec = o.properties.iter().map(El::BindingProp).collect(); + if let Some(rest) = &o.rest { + items.push(El::BindingRest(rest)); + } + self.emit_list( + out, + &items, + fmt::OBJECT_BINDING, + o.span.end, + o.span.start as usize + 1, + o.span.end as usize - 1, + None, + None, + )?; + self.write(out, "}"); + } + BindingPattern::ArrayPattern(a) => { + self.write(out, "["); + let mut items: Vec = Vec::new(); + let mut prev_end = a.span.start; + for element in &a.elements { + match element { + Some(element) => { + items.push(El::Binding(element)); + prev_end = element.span().end; + } + None => { + // An elision is an `OmittedExpression`: zero + // width at the comma after the previous element + // (or after `[` when first). + let at = if items.is_empty() { a.span.start + 1 } else { prev_end + 1 }; + items.push(El::Token { span: Span::new(at, at), text: "" }); + } + } + } + if let Some(rest) = &a.rest { + items.push(El::BindingRest(rest)); + } + self.emit_list( + out, + &items, + fmt::ARRAY_BINDING, + a.span.end, + a.span.start as usize + 1, + a.span.end as usize - 1, + None, + None, + )?; + self.write(out, "]"); + } + // Default values aren't allowed in a type. + BindingPattern::AssignmentPattern(_) => return None, + } + Some(()) + } + + /// A `BindingElement`: `propertyName: name`, or just `name` for + /// shorthand (`emitBindingElement`; initializers can't appear in a type). + fn binding_prop_body( + &mut self, + out: &mut String, + property: &BindingProperty<'a>, + ) -> Option<()> { + if !property.shorthand { + self.key_node(out, &property.key, property.computed)?; + self.write(out, ": "); + } + self.emit_node( + out, + self.pos_of(property.value.span().start), + property.value.span().end, + |s, o| s.binding_body(o, &property.value), + ) + } + + /// A `RestElement`: `...` then its argument. + fn binding_rest_body(&mut self, out: &mut String, rest: &BindingRestElement<'a>) -> Option<()> { + self.write(out, "..."); + self.emit_node( + out, + self.pos_of(rest.argument.span().start), + rest.argument.span().end, + |s, o| s.binding_body(o, &rest.argument), + ) + } + /// `@angular/core` names become `i0.Name`; local and global names stay as /// written; names from other modules set `other_module`. /// @@ -299,327 +1396,171 @@ impl<'a> TypePrinter<'_, 'a> { } } - /// A union's or intersection's constituents, joined by ` | ` / ` & `. - fn constituents(&mut self, types: &[TSType<'_>], start: u32, op: &str) -> Option { - // After a leading `|`/`&`, the first constituent follows that token; - // otherwise it shares the start of the union (see `leading_comments`). - let open = self.source[start as usize..].starts_with(op).then_some(start); - Some(self.elements(open, types, Self::ty)?.join(&format!(" {op} "))) - } - - fn type_args(&mut self, args: &TSTypeParameterInstantiation<'_>) -> Option { - Some(format!( - "<{}>", - self.elements(Some(args.span.start), &args.params, Self::ty)?.join(", ") - )) - } + // ---- Comment emission (TypeScript's `onlyPrintJsDocStyle` paths) ---- - fn type_params(&mut self, params: Option<&TSTypeParameterDeclaration<'_>>) -> Option { - let Some(params) = params else { return Some(String::new()) }; - let items = self.elements(Some(params.span.start), ¶ms.params, Self::type_param)?; - Some(format!("<{}>", items.join(", "))) - } - - fn type_param(&mut self, param: &TSTypeParameter<'_>) -> Option { - let mut out = String::new(); - if param.r#const { - out.push_str("const "); - } - if param.r#in { - out.push_str("in "); - } - if param.out { - out.push_str("out "); - } - out.push_str(¶m.name.name); - if let Some(constraint) = ¶m.constraint { - out.push_str(" extends "); - out.push_str(&self.ty(constraint)?); - } - if let Some(default) = ¶m.default { - out.push_str(" = "); - out.push_str(&self.ty(default)?); - } - Some(out) - } - - fn tuple_element(&mut self, element: &TSTupleElement<'_>) -> Option { - match element { - TSTupleElement::TSOptionalType(o) => Some(format!("{}?", self.ty(&o.type_annotation)?)), - TSTupleElement::TSRestType(r) => Some(format!("...{}", self.ty(&r.type_annotation)?)), - other => self.ty(other.to_ts_type()), + /// `emitLeadingCommentsOfPosition(pos)` — the JSDoc-only leading scan, + /// gated on `containerPos` (`forEachLeadingCommentToEmit`). + fn emit_leading_of_pos(&self, out: &mut String, pos: usize) { + if pos as u32 == self.container_pos { + return; } + self.emit_leading(out, pos); } - fn literal(&self, literal: &TSLiteral<'_>) -> Option { - Some(match literal { - TSLiteral::BooleanLiteral(b) => b.value.to_string(), - TSLiteral::NumericLiteral(n) => format_number_like_js(n.value), - TSLiteral::BigIntLiteral(b) => self.bigint(b), - TSLiteral::StringLiteral(s) => quote_literal(s), - TSLiteral::TemplateLiteral(t) if t.expressions.is_empty() => self.slice(t.span), - TSLiteral::UnaryExpression(u) if u.operator == UnaryOperator::UnaryNegation => { - match &u.argument { - Expression::NumericLiteral(n) => format!("-{}", format_number_like_js(n.value)), - Expression::BigIntLiteral(b) => format!("-{}", self.bigint(b)), - _ => return None, + /// `emitLeadingComments(pos)` — comments after the first line break in + /// the trivia at `pos`, JSDoc-style only. The first one gets a preceding + /// newline when it starts on a different line than `pos` + /// (`emitNewLineBeforeLeadingCommentOfPosition`), and each is followed by + /// a newline when a line break follows it (`hasTrailingNewLine`), else a + /// space for `/* */`. + fn emit_leading(&self, out: &mut String, pos: usize) { + let mut first = true; + for c in comment_ranges(self.source, pos, false) { + if !self.is_jsdoc_comment(c.pos) { + continue; + } + if first && line_of(self.source, c.pos) != line_of(self.source, pos) { + if !out.ends_with('\n') { + out.push('\n'); } } - _ => return None, - }) - } - - /// TypeScript's scanner keeps hexadecimal bigints in hex (lowercased) and - /// turns the other bases into decimal, without separators. - fn bigint(&self, literal: &BigIntLiteral<'_>) -> String { - let raw = self.slice(literal.span); - match raw.strip_prefix("0x").or_else(|| raw.strip_prefix("0X")) { - Some(hex) => format!("0x{}", hex.replace('_', "").to_ascii_lowercase()), - None => format!("{}n", literal.value), + first = false; + self.emit_comment_text(out, &c); + if c.trailing_newline { + if !out.ends_with('\n') { + out.push('\n'); + } + } else if !c.line { + out.push(' '); + } } } - fn member(&mut self, member: &TSSignature<'_>) -> Option { - Some(match member { - TSSignature::TSPropertySignature(p) => format!( - "{}{}{}{};", - if p.readonly { "readonly " } else { "" }, - self.key(&p.key, p.computed)?, - if p.optional { "?" } else { "" }, - self.annotation(p.type_annotation.as_deref())? - ), - TSSignature::TSIndexSignature(s) => { - let param = &s.parameter; - format!( - "{}[{}{}: {}]: {};", - if s.readonly { "readonly " } else { "" }, - self.leading_comments(None, param.span.start), - param.name, - self.ty(¶m.type_annotation.type_annotation)?, - self.ty(&s.type_annotation.type_annotation)? - ) - } - TSSignature::TSCallSignatureDeclaration(c) => format!( - "{}{}{};", - self.type_params(c.type_parameters.as_deref())?, - self.params(c.this_param.as_deref(), &c.params)?, - self.annotation(c.return_type.as_deref())? - ), - TSSignature::TSConstructSignatureDeclaration(c) => format!( - "new {}{}{};", - self.type_params(c.type_parameters.as_deref())?, - self.params(None, &c.params)?, - self.annotation(c.return_type.as_deref())? - ), - TSSignature::TSMethodSignature(m) => format!( - "{}{}{}{}{}{};", - match m.kind { - TSMethodSignatureKind::Method => "", - TSMethodSignatureKind::Get => "get ", - TSMethodSignatureKind::Set => "set ", - }, - self.key(&m.key, m.computed)?, - if m.optional { "?" } else { "" }, - self.type_params(m.type_parameters.as_deref())?, - self.params(m.this_param.as_deref(), &m.params)?, - self.annotation(m.return_type.as_deref())? - ), - }) - } - - /// `: T`, or nothing when there's no annotation. - fn annotation(&mut self, annotation: Option<&TSTypeAnnotation<'_>>) -> Option { - match annotation { - Some(t) => Some(format!(": {}", self.ty(&t.type_annotation)?)), - None => Some(String::new()), + /// `emitTrailingComments(end)` — comments on `end`'s line, JSDoc-style + /// only, each preceded by a space unless at a line start; gated on + /// `containerEnd` like `forEachTrailingCommentToEmit`. + fn emit_trailing(&self, out: &mut String, end: usize) { + if end as u32 == self.container_end { + return; } - } - - /// A property name, re-quoted like any other string literal. A computed - /// name (`[token]`, `[ns.token]`) follows the rules of a type name: an - /// `@angular/core` value becomes `i0.token`, another module's makes the - /// type `unknown`, and a local or global one stays as written (ngtsc copies - /// the expression, which doesn't resolve for an import). - /// - /// Through an import-equals alias too, although ngtsc copies those as - /// written: an alias of `@angular/core` gives `i0.token` and one of another - /// module `unknown`, where ngtsc's name doesn't resolve in the `.d.ts`. An - /// alias of a global is written as its target (`[Symbol.iterator]` for - /// `import S = Symbol`), and one of a name the file declares stays as - /// written, like ngtsc. - fn key(&mut self, key: &PropertyKey<'_>, computed: bool) -> Option { - let text = match key { - PropertyKey::StaticIdentifier(id) => id.name.to_string(), - PropertyKey::StringLiteral(s) => quote_literal(s), - PropertyKey::NumericLiteral(n) => format_number_like_js(n.value), - PropertyKey::TemplateLiteral(t) if t.expressions.is_empty() => self.slice(t.span), - PropertyKey::Identifier(id) => self.computed_name(std::vec![id.name.as_str()])?, - PropertyKey::StaticMemberExpression(m) => { - let mut parts = std::vec::Vec::new(); - member_parts(&m.object, &mut parts)?; - parts.push(m.property.name.as_str()); - self.computed_name(parts)? + for c in comment_ranges(self.source, end, true) { + if !self.is_jsdoc_comment(c.pos) { + continue; } - _ => return None, - }; - Some(if computed { format!("[{text}]") } else { text }) - } - - /// A computed property name `a.b.c` (`parts`, head first), as [`Self::key`] - /// writes it. - fn computed_name(&mut self, parts: std::vec::Vec<&str>) -> Option { - let written = parts.join("."); - Some(match self.resolve_aliases(parts)? { - Resolved::Core(rest) => format!("{}{rest}", self.core_ns), - Resolved::OtherModule(_) => { - self.other_module = true; - written + if !out.is_empty() && !out.ends_with('\n') { + out.push(' '); } - Resolved::Name(parts, aliased) => { - let (head, members) = (parts[0], &parts[1..]); - if aliased && self.scope.import(head).is_none() && self.scope.declares(head) { - written - } else { - let rest: String = members.iter().map(|m| format!(".{m}")).collect(); - self.value_name(head, &rest) - } + self.emit_comment_text(out, &c); + if c.trailing_newline && !out.ends_with('\n') { + out.push('\n'); } - }) + } } - fn params( - &mut self, - this: Option<&TSThisParameter<'_>>, - params: &FormalParameters<'_>, - ) -> Option { - let mut out = Vec::new(); - let mut prev = params.span.start; - if let Some(this) = this { - let comments = self.leading_comments(Some(prev), this.span.start); - out.push(format!( - "{comments}this{}", - self.annotation(this.type_annotation.as_deref())? - )); - prev = this.span.end; - } - for param in ¶ms.items { - if param.initializer.is_some() { - return None; + /// `emitTrailingCommentsOfPosition(pos, prefixSpace: true)` — the + /// JSDoc-filtered same-line scan `emitTokenWithComment` and the + /// empty-brackets path use. Ungated (the position paths don't check + /// `containerEnd`). + fn emit_trailing_filtered(&self, out: &mut String, pos: usize) { + for c in comment_ranges(self.source, pos, true) { + if !self.is_jsdoc_comment(c.pos) { + continue; + } + if !out.is_empty() && !out.ends_with('\n') { + out.push(' '); + } + self.emit_comment_text(out, &c); + if c.trailing_newline && !out.ends_with('\n') { + out.push('\n'); } - let comments = self.leading_comments(Some(prev), param.span.start); - out.push(format!( - "{comments}{}{}{}", - self.binding(¶m.pattern)?, - if param.optional { "?" } else { "" }, - self.annotation(param.type_annotation.as_deref())? - )); - prev = param.span.end; - } - if let Some(rest) = ¶ms.rest { - let comments = self.leading_comments(Some(prev), rest.span.start); - out.push(format!( - "{comments}...{}{}", - self.binding(&rest.rest.argument)?, - self.annotation(rest.type_annotation.as_deref())? - )); } - Some(format!("({})", out.join(", "))) } - /// A parameter name or destructuring pattern. - fn binding(&mut self, pattern: &BindingPattern<'_>) -> Option { - match pattern { - BindingPattern::BindingIdentifier(id) => Some(id.name.to_string()), - BindingPattern::ObjectPattern(o) if o.properties.is_empty() && o.rest.is_none() => { - Some("{}".into()) - } - BindingPattern::ObjectPattern(o) => { - let mut items = Vec::new(); - let mut prev = o.span.start; - for property in &o.properties { - let value = self.binding(&property.value)?; - let item = if property.shorthand { - value - } else { - format!("{}: {value}", self.key(&property.key, property.computed)?) - }; - items.push(self.leading_comments(Some(prev), property.span.start) + &item); - prev = property.span.end; - } - if let Some(rest) = &o.rest { - let comments = self.leading_comments(Some(prev), rest.span.start); - items.push(format!("{comments}...{}", self.binding(&rest.argument)?)); + /// `emitTrailingCommentsOfPosition(pos)` with the default callback — + /// comments on `pos`'s line, unfiltered, each followed by a space (or a + /// newline for `hasTrailingNewLine`, which `//` and U+2028-following + /// `/* */` have). This is what prints `/* */` comments the JSDoc-filtered + /// paths drop — and prints them again for each enclosing list. + fn emit_intervening(&self, out: &mut String, pos: usize) { + for c in comment_ranges(self.source, pos, true) { + self.emit_comment_text(out, &c); + if c.trailing_newline { + if !out.ends_with('\n') { + out.push('\n'); } - Some(format!("{{ {} }}", items.join(", "))) + } else { + out.push(' '); } - BindingPattern::ArrayPattern(a) => { - let mut items = Vec::new(); - let mut prev = a.span.start; - for element in &a.elements { - let Some(element) = element else { - items.push(String::new()); - continue; - }; - let comments = self.leading_comments(Some(prev), element.span().start); - items.push(comments + &self.binding(element)?); - prev = element.span().end; - } - if let Some(rest) = &a.rest { - let comments = self.leading_comments(Some(prev), rest.span.start); - items.push(format!("{comments}...{}", self.binding(&rest.argument)?)); - } - Some(format!("[{}]", items.join(", "))) - } - // Default values aren't allowed in a type. - BindingPattern::AssignmentPattern(_) => None, } } - /// Prints each element of a list, with the comments TypeScript keeps in - /// front of it. `open` is where the list's opening token (`<`, `(`, `{`, - /// `[`, a leading `|`) starts; `None` when the list has none, so the token - /// before the first element is looked for backwards. - fn elements( - &mut self, - open: Option, - items: &[T], - mut print: impl FnMut(&mut Self, &T) -> Option, - ) -> Option> { - let mut prev = open; - items - .iter() - .map(|item| { - let span = item.span(); - let comments = self.leading_comments(prev, span.start); - prev = Some(span.end); - Some(comments + &print(self, item)?) - }) - .collect() + /// The declaration printer's `shouldWriteComment`: `/**`-style or `/*!` + /// (pinned) comments only — `/*` and `//` are dropped on node-level + /// leading/trailing paths. + fn is_jsdoc_comment(&self, pos: usize) -> bool { + let b = self.source.as_bytes(); + b.get(pos + 1) == Some(&b'*') + && (b.get(pos + 2) == Some(&b'!') + || (b.get(pos + 2) == Some(&b'*') && b.get(pos + 3) != Some(&b'/'))) } - /// The comments TypeScript's printer keeps in front of a list element: - /// those right after the token before it, on that token's line (it emits - /// them as trailing comments of that position). Other comments in the type - /// are dropped, as ngtsc does, except `/** */` ones after a type or on - /// their own line, which ngtsc keeps and oxc doesn't. - /// - /// `prev` is where to look for that token from: the end of the previous - /// element (the token is the delimiter, if any) or the opening bracket. - /// Without it the token is searched for backwards from `start`, which is - /// how a union's first constituent finds the token before the union. - fn leading_comments(&self, prev: Option, start: u32) -> String { - let start = start as usize; - let pos = match prev { - Some(prev) => after_token(self.source, prev as usize, start), - None => match token_end_before(self.source, start) { - Some(pos) => pos, - None => return String::new(), - }, + /// `writeCommentRange`: a `//` comment verbatim; a `/* */` with each line + /// trimmed, continuation lines re-indented relative to the line the + /// comment starts on, rebased to the writer's indent (one level here). + fn emit_comment_text(&self, out: &mut String, comment: &Comment) { + // `writeComment` goes through `writeText`, which indents a line + // started by a `writeLine`. + if out.ends_with('\n') { + out.push_str(INDENT); + } + let text = &self.source[comment.pos..comment.end]; + if comment.line { + out.push_str(text); + return; + } + let Some(first_break) = text.find(is_line_break) else { + out.push_str(text); + return; }; - same_line_comments(&self.source[pos..start]) + // The first line, trimmed; `writeTrimmedCurrentLine` writes a + // `writeLine` after it when the comment continues. + out.push_str(text[..first_break].trim()); + out.push('\n'); + // `calculateIndent` of the line the comment starts on (the whitespace + // before `/*`), then each continuation line's indent minus that, + // rebased to the writer's four spaces. + let first_indent = + indent_of(&self.source[line_start_of(self.source, comment.pos)..comment.pos]); + let mut rest = &text[first_break..]; + loop { + rest = &rest[line_break_width(rest)..]; + let (line, next) = match rest.find(is_line_break) { + Some(i) => (&rest[..i], &rest[i..]), + None => (rest, ""), + }; + let trimmed = line.trim(); + if trimmed.is_empty() { + // `writeTrimmedCurrentLine` writes a bare newline for an + // all-whitespace continuation. + out.push('\n'); + } else { + let spaces = 4isize - first_indent as isize + indent_of(line) as isize; + for _ in 0..spaces.max(0) { + out.push(' '); + } + out.push_str(trimmed); + if !next.is_empty() { + out.push('\n'); + } + } + if next.is_empty() { + break; + } + rest = next; + } } - fn slice(&self, span: oxc_span::Span) -> String { + fn slice(&self, span: Span) -> String { span.source_text(self.source).to_string() } } @@ -636,7 +1577,7 @@ fn entity_name(name: &TSTypeName<'_>) -> Option { /// A type name's parts, head first (`["A", "B", "T"]` for `A.B.T`). `None` /// for one starting with `this`. -fn entity_parts<'n>(name: &'n TSTypeName<'_>, out: &mut Vec<&'n str>) -> Option<()> { +fn entity_parts<'a, 'n>(name: &'n TSTypeName<'a>, out: &mut Vec<&'n str>) -> Option<()> { match name { TSTypeName::IdentifierReference(id) => out.push(id.name.as_str()), TSTypeName::QualifiedName(q) => { @@ -671,43 +1612,45 @@ enum Resolved<'p> { Name(std::vec::Vec<&'p str>, bool), } +/// TypeScript's `isLineBreak`: `\r`, `\n`, U+2028, U+2029. fn is_line_break(c: char) -> bool { matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') } +/// TypeScript's `isWhiteSpaceSingleLine`: space, tab, VT, FF, NBSP, the +/// Unicode space separators and the BOM — but not U+0085 (NEL), which +/// TypeScript doesn't treat as whitespace (Rust's `is_whitespace` does). fn is_space(c: char) -> bool { - c.is_whitespace() && !is_line_break(c) + (c.is_whitespace() && c != '\u{85}' && !is_line_break(c)) || c == '\u{FEFF}' } -/// The end of the first token at or after `from`, when one comes before -/// `start`; `from` itself otherwise. -fn after_token(source: &str, from: usize, start: usize) -> usize { - let text = &source[from..start]; - let mut rest = text; +/// `skipTrivia`: the position of the first token character at or after +/// `pos`, past whitespace and comments. +fn skip_trivia(source: &str, mut pos: usize) -> usize { loop { - rest = rest.trim_start(); - if let Some(body) = rest.strip_prefix("/*") { - match body.find("*/") { - Some(end) => rest = &body[end + 2..], - None => return from, + let Some(c) = char_at(source, pos) else { return pos }; + if is_whitespace_like(c) { + pos += c.len_utf8(); + } else if source[pos..].starts_with("/*") { + match source[pos + 2..].find("*/") { + Some(end) => pos += 2 + end + 2, + None => return source.len(), } - } else if rest.starts_with("//") { - match rest.find(is_line_break) { - Some(end) => rest = &rest[end..], - None => return from, + } else if source[pos..].starts_with("//") { + match source[pos..].find(is_line_break) { + Some(end) => pos += end, + None => return source.len(), } } else { - return match rest.chars().next() { - Some(c) => from + (text.len() - rest.len()) + c.len_utf8(), - None => from, - }; + return pos; } } } /// Where the token before `start` ends, skipping whitespace and comments -/// backwards. `None` when that crosses a line with `//` on it: a line comment -/// can't be told apart from code when reading backwards. +/// backwards. A `//` on the line above makes that line end at the `//` — +/// anything after it is comment — so the token ends at the `//` (or earlier, +/// on the line before it). fn token_end_before(source: &str, start: usize) -> Option { let mut end = start; loop { @@ -719,8 +1662,10 @@ fn token_end_before(source: &str, start: usize) -> Option { .char_indices() .rfind(|&(_, c)| is_line_break(c)) .map_or(0, |(i, c)| i + c.len_utf8()); - if trimmed[line_start..].contains("//") { - return None; + if let Some(slashes) = trimmed[line_start..].find("//") { + // The line ends at the comment; keep looking before it. + end = line_start + slashes; + continue; } } end = trimmed.len(); @@ -731,36 +1676,165 @@ fn token_end_before(source: &str, start: usize) -> Option { } } -/// The comments at the start of `text`, up to the first line break, as -/// TypeScript prints them there: `/* */` followed by a space, `//` by a new -/// line (indented like the `.d.ts` class members). Comments spanning lines, -/// which TypeScript re-indents, are dropped with the rest. -fn same_line_comments(text: &str) -> String { - let mut out = String::new(); - let mut rest = text; - loop { - rest = rest.trim_start_matches(is_space); - if let Some(body) = rest.strip_prefix("/*") { - let Some(end) = body.find("*/") else { break }; - let comment = &rest[..end + 4]; - if comment.contains(is_line_break) { - return String::new(); - } - out.push_str(comment); - out.push(' '); - rest = &rest[comment.len()..]; - } else { - if rest.starts_with("//") { - let end = rest.find(is_line_break).unwrap_or(rest.len()); - out.push_str(&rest[..end]); - out.push_str("\n "); +/// A comment's source range, as TypeScript's `iterateCommentRanges` reports +/// it. +struct Comment { + pos: usize, + end: usize, + /// `//`: the comment ends at a line break. + line: bool, + /// A line break follows the comment (`hasTrailingNewLine`): always true + /// for `//`, true for `/* */` when a `\r`, `\n`, U+2028 or U+2029 + /// follows while the scan collects it. + trailing_newline: bool, +} + +/// `getLeadingCommentRanges` / `getTrailingCommentRanges` (`scanner.ts` +/// `iterateCommentRanges`): the comments in the trivia starting at `pos`. +/// +/// A trailing scan collects comments up to the first `\r`/`\n` — those on +/// `pos`'s line. A leading scan skips that same-line prefix and collects +/// everything after it. The quirk ngtsc's doubled comments ride on: +/// U+2028/U+2029 are whitespace here (a trailing scan crosses them, and they +/// don't start collecting for a leading one), while the line map counts them +/// as line breaks. +fn comment_ranges(source: &str, start: usize, trailing: bool) -> Vec { + let mut out = Vec::new(); + // Pending = a comment seen while collecting; flushed when the next + // comment starts or the scan ends. + let mut pending: Option = None; + let mut collecting = trailing || start == 0; + let bytes = source.as_bytes(); + let mut pos = start; + while pos < bytes.len() { + let b = bytes[pos]; + match b { + b'\r' | b'\n' => { + if b == b'\r' && bytes.get(pos + 1) == Some(&b'\n') { + pos += 1; + } + pos += 1; + if trailing { + break; + } + collecting = true; + if let Some(c) = &mut pending { + c.trailing_newline = true; + } + } + b' ' | b'\t' | 0x0B | 0x0C => pos += 1, + b'/' => { + let next = bytes.get(pos + 1); + let is_line = next == Some(&b'/'); + if next != Some(&b'/') && next != Some(&b'*') { + break; + } + let comment_start = pos; + pos += 2; + let mut trailing_newline = false; + if is_line { + while let Some(c) = char_at(source, pos) { + if is_line_break(c) { + trailing_newline = true; + break; + } + pos += c.len_utf8(); + } + } else { + while pos < bytes.len() { + if bytes[pos] == b'*' && bytes.get(pos + 1) == Some(&b'/') { + pos += 2; + break; + } + pos += 1; + } + } + if collecting { + if let Some(c) = pending.take() { + out.push(c); + } + pending = Some(Comment { + pos: comment_start, + end: pos, + line: is_line, + trailing_newline, + }); + } + } + _ => { + // Non-ASCII whitespace — including U+2028/U+2029, which are + // whitespace here (a trailing scan crosses them; they don't + // start a leading scan collecting) while the line map still + // counts them as line breaks. + match char_at(source, pos) { + Some(c) if (c as u32) > 0x7F && is_whitespace_like(c) => { + if is_line_break(c) + && let Some(p) = &mut pending + { + p.trailing_newline = true; + } + pos += c.len_utf8(); + } + _ => break, + } } - break; } } + if let Some(c) = pending { + out.push(c); + } out } +/// TypeScript's `isWhiteSpaceLike`: single-line whitespace or a line break. +fn is_whitespace_like(c: char) -> bool { + is_space(c) || is_line_break(c) +} + +/// The character at byte `pos`, or `None` mid-character/past the end. +fn char_at(source: &str, pos: usize) -> Option { + source.get(pos..)?.chars().next() +} + +/// The 1-based-agnostic line index of `pos` (number of line breaks before +/// it), where U+2028/U+2029 count — matching TypeScript's line map. +fn line_of(source: &str, pos: usize) -> usize { + source[..pos].chars().filter(|&c| is_line_break(c)).count() +} + +/// Byte index where `pos`'s line starts (just past the last line break). +fn line_start_of(source: &str, pos: usize) -> usize { + source[..pos] + .char_indices() + .rfind(|&(_, c)| is_line_break(c)) + .map_or(0, |(i, c)| i + c.len_utf8()) +} + +/// `calculateIndent`: the width of `text`'s leading whitespace, tabs rounded +/// to the next multiple of 4. +fn indent_of(text: &str) -> usize { + let mut indent = 0; + for c in text.chars() { + if c == '\t' { + indent += 4 - indent % 4; + } else if is_space(c) { + indent += 1; + } else { + break; + } + } + indent +} + +/// Width in bytes of the line break `text` starts with. +fn line_break_width(text: &str) -> usize { + match text.as_bytes().first() { + Some(b'\r') if text.as_bytes().get(1) == Some(&b'\n') => 2, + Some(b'\r') | Some(b'\n') => 1, + _ => text.chars().next().map_or(0, char::len_utf8), + } +} + /// A string literal as TypeScript prints a synthesized one: double quotes, /// escapes for control characters and everything outside ASCII (as UTF-16 /// code units). diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index e718e1b78..3378e63f2 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -3571,6 +3571,8 @@ pub fn input_transform_types<'a>( source, core_ns: core_namespace, other_module: false, + container_pos: u32::MAX, + container_end: u32::MAX, }; let ty = printer.print(ty); if printer.other_module { diff --git a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs index 0f9721561..06b80a480 100644 --- a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs +++ b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs @@ -71,51 +71,32 @@ fn matches_ngtsc() { check(MATCH); } -/// Comments ngtsc keeps that oxc drops: `/** */` comments after a type or -/// on their own line (TypeScript's printer keeps only those two kinds of -/// comment around nodes), and comments spanning lines. Each row is the type, -/// what ngtsc writes and what oxc writes; they differ only by the comment. -const COMMENTS_NOT_KEPT: &[(&str, &str, &str)] = &[ - ("{\n /** doc */\n a: 1\n}", "{ \n /** doc */\n a: 1; }", "{ a: 1; }"), - ("string | /* a\n b */ number", "string | /* a\n b */ number", "string | number"), - ("string /** d */ | number", "string /** d */ | number", "string | number"), - ("{ a: 1 /** y */ }", "{ a: 1; /** y */ }", "{ a: 1; }"), - ("(x: string /** y */) => void", "(x: string /** y */) => void", "(x: string) => void"), - ("Array", "Array", "Array"), - ("string |\n /** j */ number", "string | \n /** j */ number", "string | number"), +/// Comments ngtsc keeps around a type: `/** */` comments after a type or on +/// their own line (TypeScript's printer keeps only those two kinds of comment +/// around nodes), `/* */` comments a list scan picks up, and comments +/// spanning lines. Each row is the type and what ngtsc writes. +const COMMENTS_KEPT: &[(&str, &str)] = &[ + ("string /** doc */", "string /** doc */"), + ("string | number /** doc */", "string | number /** doc */"), + ("\n/** doc */\nstring", "\n /** doc */\n string"), + ("Array", "Array"), + ("{\n /** doc */\n a: 1\n}", "{ \n /** doc */\n a: 1; }"), + ("string | /* a\n b */ number", "string | /* a\n b */ number"), + ("string /** d */ | number", "string /** d */ | number"), + ("{ a: 1 /** y */ }", "{ a: 1; /** y */ }"), + ("(x: string /** y */) => void", "(x: string /** y */) => void"), + ("Array", "Array"), + ("string |\n /** j */ number", "string | \n /** j */ number"), // TypeScript reads past a U+2028 / U+2029 for the comments after `<` (it - // only stops at `\n` and `\r`), and prints this one twice. - ( - "Array<\u{2028}/* c */\nstring | number>", - "Array", - "Array", - ), - ( - "Array<\u{2029}/* c */\nstring | number>", - "Array", - "Array", - ), + // only stops at `\n` and `\r`), and prints this one twice: once for the + // type-arguments list, once for the union's constituents list. + ("Array<\u{2028}/* c */\nstring | number>", "Array"), + ("Array<\u{2029}/* c */\nstring | number>", "Array"), ]; #[test] -fn comments_not_kept() { - // Without comments and whitespace, ngtsc's text is oxc's. - let bare = |text: &str| { - let mut out = String::new(); - let mut rest = text; - while let Some(at) = rest.find("/*") { - out.push_str(&rest[..at]); - rest = &rest[at + rest[at..].find("*/").unwrap() + 2..]; - } - out.push_str(rest); - out.split_whitespace().collect::() - }; - for (ty, ngtsc, oxc) in COMMENTS_NOT_KEPT { - assert_eq!(bare(ngtsc), bare(oxc), "{ty:?}"); - } - let cases: Vec<(&str, &str)> = - COMMENTS_NOT_KEPT.iter().map(|(ty, _, oxc)| (*ty, *oxc)).collect(); - check(&cases); +fn comments_kept() { + check(COMMENTS_KEPT); } /// ngtsc adds `import * as i1 from './other'` and writes `i1.Other`; oxc From f465f61c2f6a17a700356607a3e028ff73c397a2 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 02:26:30 +0800 Subject: [PATCH 02/10] fix(dts): comment-scan edges in the dts type printer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-ups from review of the emitter port: - `token_end_before` misread `//` inside strings/comments and paired a trailing `*/` with the last `/*` instead of the first unpaired one, corrupting output like `{ a: 'x//y' ...}` and `/** a /** b */`. Both scans are now string/comment aware. - Type-reference names are synthesized nodes upstream (pos/end -1), so they keep no comments: `Signal /** j */ ` prints `i0.Signal`. - A tuple's `]` scans from the element list's `end`, past a trailing comma; mapped-type `±readonly`/`±?` are two tokens each; the `...` of a binding rest and `${` of a template literal are emitted tokens; a `typeof` entity name is emitted part by part. Each picks up the comments TypeScript's own scans keep. - `isWhiteSpaceSingleLine` is TypeScript's exact codepoint list (adds NEL, ZWSP and friends), and `ListFormat.Indented` applies unconditionally, indenting index-signature parameters' comments one extra level. Also documents the known multi-line gap: ngtsc's `disposeEmitNodes` clears `SingleLine` marks on earlier-emitted fields, so bare tuple/type literal/mapped types can print multi-line there. That needs the field-order mechanism and predates the comment port. --- .../src/directive/dts_type.rs | 357 +++++++++++++----- .../src/directive/evaluator.rs | 1 + .../tests/dts_input_transform_type_test.rs | 20 + 3 files changed, 287 insertions(+), 91 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs index 785e69630..fab0be749 100644 --- a/crates/oxc_angular_compiler/src/directive/dts_type.rs +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -21,10 +21,16 @@ //! as line breaks. //! //! ngtsc's `markForEmitAsSingleLine` marks the whole translated type -//! subtree, so under ngtsc 22.1.7 every list uses its single-line format. -//! The comment paths still need the writer's line tracking: a `//` comment -//! or a `/**` with `hasTrailingNewLine` ends the line, and the next write -//! indents it. +//! subtree. This printer always uses the single-line formats — but ngtsc's +//! `ts.transform` calls `disposeEmitNodes`, which wipes the marks on nodes +//! visited before the last `emitType` run, so in real ngtsc output a field +//! whose type is a bare tuple, type literal or mapped type can print +//! multi-line (while `i0.Foo<…>` references stay single-line via the +//! checker's reuse path). That divergence predates the comment support and +//! needs the field-order mechanism to fix; the single-line path is what the +//! fixtures exercise. Either way the comment paths need the writer's line +//! tracking: a `//` comment or a `/**` with `hasTrailingNewLine` ends the +//! line, and the next write indents it. use oxc_ast::ast::{ BigIntLiteral, BindingPattern, BindingProperty, BindingRestElement, Expression, @@ -109,6 +115,10 @@ pub(crate) struct TypePrinter<'s, 'a> { /// enclosing node's range while [`Self::emit_node`] prints one. pub container_pos: u32, pub container_end: u32, + /// The writer's indent level (`createTextWriter`'s `indent`): `static` + /// members sit at one level, and a `ListFormat.Indented` list adds one + /// more while its items print. + pub indent: u32, } /// A list element for [`TypePrinter::emit_list`]: a node with a span plus the @@ -221,7 +231,9 @@ impl<'a> TypePrinter<'_, 'a> { /// which comments emit). fn write(&self, out: &mut String, s: &str) { if out.ends_with('\n') { - out.push_str(INDENT); + for _ in 0..self.indent { + out.push_str(INDENT); + } } out.push_str(s); } @@ -276,7 +288,8 @@ impl<'a> TypePrinter<'_, 'a> { /// `emitNodeListItems`, always on the single-line path: every list this /// printer uses is `SingleLine`-flagged, so the leading, separating and - /// closing line-terminator counts are all `0` and `Indented` never runs. + /// closing line-terminator counts are all `0`. `Indented` still applies — + /// it's unconditional in TypeScript (index-signature parameters carry it). fn emit_list_items( &mut self, out: &mut String, @@ -289,6 +302,12 @@ impl<'a> TypePrinter<'_, 'a> { if format & fmt::SPACE_BETWEEN_BRACES != 0 { self.write(out, " "); } + // `Indented` applies unconditionally (index-signature parameters + // carry it), unlike the line-terminator counts that only exist for + // multi-line formats. + if format & fmt::INDENTED != 0 { + self.indent += 1; + } let mut prev_end: Option = None; for item in items { let span = item.span(); @@ -326,6 +345,9 @@ impl<'a> TypePrinter<'_, 'a> { // token. self.emit_leading_of_pos(out, prev_end as usize); } + if format & fmt::INDENTED != 0 { + self.indent -= 1; + } if format & (fmt::SPACE_AFTER_LIST | fmt::SPACE_BETWEEN_BRACES) != 0 { self.write(out, " "); } @@ -390,19 +412,17 @@ impl<'a> TypePrinter<'_, 'a> { TSType::TSVoidKeyword(_) => self.write(out, "void"), TSType::TSThisType(_) => self.write(out, "this"), TSType::TSTypeReference(r) => { - // `emit(node.typeName)`: the whole entity name as one node — - // TypeScript emits qualified names part by part, but the - // comments each part would keep land the same either way. + // ngtsc synthesizes the name (`createTypeReferenceNode`): + // a synthesized node's `pos`/`end` are -1, so it keeps no + // comments of its own — `Signal /** j */ ` prints + // `i0.Signal`. (Upstream may reuse a declaration's + // identifier and leak *its* trivia; a reference only knows + // the use site, so comments it would carry are dropped.) let text = self.type_name(&r.type_name)?; - self.emit_node( - out, - self.pos_of(r.type_name.span().start), - r.type_name.span().end, - |s, o| { - s.write(o, &text); - Some(()) - }, - )?; + self.emit_node(out, 0, 0, |s, o| { + s.write(o, &text); + Some(()) + })?; if let Some(args) = &r.type_arguments { self.type_args(out, args)?; } @@ -452,7 +472,15 @@ impl<'a> TypePrinter<'_, 'a> { )?; let items: Vec = t.element_types.iter().map(El::TupleEl).collect(); let list_pos = t.span.start as usize + 1; - let list_end = t.element_types.last().map_or(list_pos, |e| e.span().end as usize); + // A `NodeArray`'s `end` is past a trailing comma. + let list_end = t + .element_types + .last() + .map(|e| { + let after = skip_trivia(self.source, e.span().end as usize); + if self.source[after..].starts_with(',') { after + 1 } else { after } + }) + .unwrap_or(list_pos); self.emit_list( out, &items, @@ -464,8 +492,9 @@ impl<'a> TypePrinter<'_, 'a> { None, )?; // `emitTokenWithComment(CloseBracket, elements.end, node)`: - // the leading scan runs at the last element's end; `]`'s own - // end is the tuple's, so no trailing scan. + // the leading scan runs at the element list's end — past a + // trailing comma, so `[a: string,\n /** j */]` keeps the + // comment; `]`'s own end is the tuple's, so no trailing scan. self.token_with_comment(out, "]", list_end as u32, t.span.end - 1, t.span.end)?; } TSType::TSNamedTupleMember(m) => self.named_tuple_member_body(out, m, None)?, @@ -475,7 +504,17 @@ impl<'a> TypePrinter<'_, 'a> { for (i, quasi) in t.quasis.iter().enumerate() { self.write(out, quasi.value.raw.as_str()); if let Some(ty) = t.types.get(i) { - self.write(out, "${"); + // `${` sits right after the quasi's cooked text and + // keeps its same-line JSDoc comments: + // `` `${ /** j */ string}` `` prints as written. + let dollar = quasi.span.end; + self.token_with_comment( + out, + "${", + self.pos_of(dollar), + dollar + 2, + t.span.end, + )?; self.ty_node(out, ty)?; self.write(out, "}"); } @@ -483,20 +522,11 @@ impl<'a> TypePrinter<'_, 'a> { self.write(out, "`"); } TSType::TSTypeQuery(q) => { - // `typeof x` names a value; ngtsc leaves it as written. + // `typeof x` names a value; ngtsc emits the entity name part + // by part (`emitEntityName`), so comments inside the name are + // kept (`typeof val.a.\n/** j */\nb`). self.write(out, "typeof "); - let (span, text) = match &q.expr_name { - TSTypeQueryExprName::IdentifierReference(id) => (id.span, id.name.to_string()), - TSTypeQueryExprName::QualifiedName(name) => { - (name.span, format!("{}.{}", entity_name(&name.left)?, name.right.name)) - } - TSTypeQueryExprName::ThisExpression(e) => (e.span, "this".to_string()), - TSTypeQueryExprName::TSImportType(_) => return None, - }; - self.emit_node(out, self.pos_of(span.start), span.end, |s, o| { - s.write(o, &text); - Some(()) - })?; + self.emit_query_name(out, &q.expr_name)?; if let Some(args) = &q.type_arguments { self.type_args(out, args)?; } @@ -520,19 +550,31 @@ impl<'a> TypePrinter<'_, 'a> { // `SingleLine` is always set: `{ ...; }` on one line. self.write(out, "{ "); if let Some(readonly) = &m.readonly { - let (sign, len) = match readonly { - TSMappedTypeModifierOperator::True => ("", "readonly".len()), - TSMappedTypeModifierOperator::Plus => ("+", "+readonly".len()), - TSMappedTypeModifierOperator::Minus => ("-", "-readonly".len()), - }; + // `readonlyToken` is `ReadonlyKeyword | PlusToken + // ReadonlyKeyword | MinusToken ReadonlyKeyword` — the sign + // and `readonly` are separate tokens (`{ - /** j */ + // readonly … }` prints `- /** j */readonly`). + let mut start = skip_trivia(self.source, m.span.start as usize + 1); + if readonly != &TSMappedTypeModifierOperator::True { + let sign = + if readonly == &TSMappedTypeModifierOperator::Plus { "+" } else { "-" }; + self.emit_node( + out, + self.pos_of(start as u32), + start as u32 + 1, + |s, o| { + s.write(o, sign); + Some(()) + }, + )?; + start = skip_trivia(self.source, start + 1); + } // `emit(node.readonlyToken)` + `writeSpace`. - let start = skip_trivia(self.source, m.span.start as usize + 1); self.emit_node( out, self.pos_of(start as u32), - (start + len) as u32, + (start + "readonly".len()) as u32, |s, o| { - s.write(o, sign); s.write(o, "readonly"); Some(()) }, @@ -562,32 +604,31 @@ impl<'a> TypePrinter<'_, 'a> { after = name_type.span().end; } self.write(out, "]"); - if m.optional.is_some() { - let (sign, len) = match m.optional.unwrap() { - TSMappedTypeModifierOperator::True => ("", 1), - TSMappedTypeModifierOperator::Plus - | TSMappedTypeModifierOperator::Minus => ( - if m.optional == Some(TSMappedTypeModifierOperator::Plus) { - "+" - } else { - "-" - }, - 2, - ), - }; - // `emit(node.questionToken)`: the `?`/`+?`/`-?` after `]`. - let start = + if let Some(optional) = &m.optional { + // `questionToken` is `QuestionToken | PlusToken + // QuestionToken | MinusToken QuestionToken` — separate + // tokens, like `readonlyToken` above. + let mut start = skip_trivia(self.source, skip_trivia(self.source, after as usize) + 1); - self.emit_node( - out, - self.pos_of(start as u32), - (start + len) as u32, - |s, o| { - s.write(o, sign); - s.write(o, "?"); - Some(()) - }, - )?; + if optional != &TSMappedTypeModifierOperator::True { + let sign = + if optional == &TSMappedTypeModifierOperator::Plus { "+" } else { "-" }; + self.emit_node( + out, + self.pos_of(start as u32), + start as u32 + 1, + |s, o| { + s.write(o, sign); + Some(()) + }, + )?; + start = skip_trivia(self.source, start + 1); + } + // `emit(node.questionToken)`: the `?` after `]`. + self.emit_node(out, self.pos_of(start as u32), start as u32 + 1, |s, o| { + s.write(o, "?"); + Some(()) + })?; } self.write(out, ": "); if let Some(value) = &m.type_annotation { @@ -1283,9 +1324,10 @@ impl<'a> TypePrinter<'_, 'a> { ) } - /// A `RestElement`: `...` then its argument. + /// A `RestElement`: `...` then its argument (`emitRestElement` emits the + /// `dotDotDotToken` with its comments). fn binding_rest_body(&mut self, out: &mut String, rest: &BindingRestElement<'a>) -> Option<()> { - self.write(out, "..."); + self.token_with_comment(out, "...", rest.span.start, rest.span.start + 3, rest.span.end)?; self.emit_node( out, self.pos_of(rest.argument.span().start), @@ -1294,6 +1336,60 @@ impl<'a> TypePrinter<'_, 'a> { ) } + /// `emitEntityName` for a `typeof` operand: each part is `emit`ted (so + /// comments between parts survive), `.` between them. + fn emit_query_name(&mut self, out: &mut String, name: &TSTypeQueryExprName<'a>) -> Option<()> { + match name { + TSTypeQueryExprName::IdentifierReference(id) => { + self.emit_node(out, self.pos_of(id.span.start), id.span.end, |s, o| { + s.write(o, id.name.as_str()); + Some(()) + }) + } + TSTypeQueryExprName::QualifiedName(q) => { + self.emit_name_left(out, &q.left)?; + self.write(out, "."); + self.emit_node(out, self.pos_of(q.right.span.start), q.right.span.end, |s, o| { + s.write(o, q.right.name.as_str()); + Some(()) + }) + } + TSTypeQueryExprName::ThisExpression(e) => { + self.emit_node(out, self.pos_of(e.span.start), e.span.end, |s, o| { + s.write(o, "this"); + Some(()) + }) + } + TSTypeQueryExprName::TSImportType(_) => None, + } + } + + /// `emitEntityName`'s `left` recursion. + fn emit_name_left(&mut self, out: &mut String, name: &TSTypeName<'a>) -> Option<()> { + match name { + TSTypeName::IdentifierReference(id) => { + self.emit_node(out, self.pos_of(id.span.start), id.span.end, |s, o| { + s.write(o, id.name.as_str()); + Some(()) + }) + } + TSTypeName::QualifiedName(q) => { + self.emit_name_left(out, &q.left)?; + self.write(out, "."); + self.emit_node(out, self.pos_of(q.right.span.start), q.right.span.end, |s, o| { + s.write(o, q.right.name.as_str()); + Some(()) + }) + } + TSTypeName::ThisExpression(e) => { + self.emit_node(out, self.pos_of(e.span.start), e.span.end, |s, o| { + s.write(o, "this"); + Some(()) + }) + } + } + } + /// `@angular/core` names become `i0.Name`; local and global names stay as /// written; names from other modules set `other_module`. /// @@ -1506,12 +1602,14 @@ impl<'a> TypePrinter<'_, 'a> { /// `writeCommentRange`: a `//` comment verbatim; a `/* */` with each line /// trimmed, continuation lines re-indented relative to the line the - /// comment starts on, rebased to the writer's indent (one level here). + /// comment starts on, rebased to the writer's indent. fn emit_comment_text(&self, out: &mut String, comment: &Comment) { // `writeComment` goes through `writeText`, which indents a line // started by a `writeLine`. if out.ends_with('\n') { - out.push_str(INDENT); + for _ in 0..self.indent { + out.push_str(INDENT); + } } let text = &self.source[comment.pos..comment.end]; if comment.line { @@ -1528,7 +1626,7 @@ impl<'a> TypePrinter<'_, 'a> { out.push('\n'); // `calculateIndent` of the line the comment starts on (the whitespace // before `/*`), then each continuation line's indent minus that, - // rebased to the writer's four spaces. + // rebased to the writer's indent. let first_indent = indent_of(&self.source[line_start_of(self.source, comment.pos)..comment.pos]); let mut rest = &text[first_break..]; @@ -1544,7 +1642,8 @@ impl<'a> TypePrinter<'_, 'a> { // all-whitespace continuation. out.push('\n'); } else { - let spaces = 4isize - first_indent as isize + indent_of(line) as isize; + let spaces = + 4 * self.indent as isize - first_indent as isize + indent_of(line) as isize; for _ in 0..spaces.max(0) { out.push(' '); } @@ -1565,16 +1664,6 @@ impl<'a> TypePrinter<'_, 'a> { } } -/// A `typeof` operand or computed key: `a.b.c`, as ngtsc prints it (without -/// the source's spacing). -fn entity_name(name: &TSTypeName<'_>) -> Option { - match name { - TSTypeName::IdentifierReference(id) => Some(id.name.to_string()), - TSTypeName::QualifiedName(q) => Some(format!("{}.{}", entity_name(&q.left)?, q.right.name)), - TSTypeName::ThisExpression(_) => Some("this".into()), - } -} - /// A type name's parts, head first (`["A", "B", "T"]` for `A.B.T`). `None` /// for one starting with `this`. fn entity_parts<'a, 'n>(name: &'n TSTypeName<'a>, out: &mut Vec<&'n str>) -> Option<()> { @@ -1617,11 +1706,17 @@ fn is_line_break(c: char) -> bool { matches!(c, '\n' | '\r' | '\u{2028}' | '\u{2029}') } -/// TypeScript's `isWhiteSpaceSingleLine`: space, tab, VT, FF, NBSP, the -/// Unicode space separators and the BOM — but not U+0085 (NEL), which -/// TypeScript doesn't treat as whitespace (Rust's `is_whitespace` does). +/// TypeScript's `isWhiteSpaceSingleLine`: a fixed list — space, tab, VT, FF, +/// NBSP, NEL, ogham, the U+2000–U+200B range (enQuad through zeroWidthSpace), +/// narrow NBSP, mathematical space, ideographic space and the BOM. Rust's +/// `is_whitespace` differs on both ends (it lacks NEL and ZWSP and has +/// U+001C–U+001F), so the list is spelled out. fn is_space(c: char) -> bool { - (c.is_whitespace() && c != '\u{85}' && !is_line_break(c)) || c == '\u{FEFF}' + matches!( + c, + ' ' | '\t' | '\u{B}' | '\u{C}' | '\u{A0}' | '\u{85}' | '\u{1680}' | '\u{2000}' + ..='\u{200B}' | '\u{202F}' | '\u{205F}' | '\u{3000}' | '\u{FEFF}' + ) } /// `skipTrivia`: the position of the first token character at or after @@ -1650,7 +1745,9 @@ fn skip_trivia(source: &str, mut pos: usize) -> usize { /// Where the token before `start` ends, skipping whitespace and comments /// backwards. A `//` on the line above makes that line end at the `//` — /// anything after it is comment — so the token ends at the `//` (or earlier, -/// on the line before it). +/// on the line before it). `//` inside a string or a `/* */` doesn't count, +/// and the `/*` a trailing `*/` pairs with is the first one after the +/// previous `*/`, not the last. fn token_end_before(source: &str, start: usize) -> Option { let mut end = start; loop { @@ -1662,7 +1759,7 @@ fn token_end_before(source: &str, start: usize) -> Option { .char_indices() .rfind(|&(_, c)| is_line_break(c)) .map_or(0, |(i, c)| i + c.len_utf8()); - if let Some(slashes) = trimmed[line_start..].find("//") { + if let Some(slashes) = line_comment_start(&trimmed[line_start..]) { // The line ends at the comment; keep looking before it. end = line_start + slashes; continue; @@ -1670,12 +1767,90 @@ fn token_end_before(source: &str, start: usize) -> Option { } end = trimmed.len(); match trimmed.strip_suffix("*/") { - Some(inner) => end = inner.rfind("/*")?, + Some(inner) => end = block_comment_start(inner)?, None => return Some(end), } } } +/// The `//` starting a line comment in `line`, or `None` when the only `//`s +/// are inside strings or `/* */` comments. (A `//` inside a `${}` of a +/// template spanning lines can slip through; types don't produce those.) +fn line_comment_start(line: &str) -> Option { + let bytes = line.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + let quote = bytes[i]; + i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + i += 1; + } else if bytes[i] == quote { + break; + } + i += 1; + } + i += 1; + } + b'/' if bytes.get(i + 1) == Some(&b'/') => return Some(i), + b'/' if bytes.get(i + 1) == Some(&b'*') => { + // Unterminated on this line means the rest is comment. + match line[i + 2..].find("*/") { + Some(end) => i += 2 + end + 2, + None => return None, + } + } + _ => i += 1, + } + } + None +} + +/// The `/*` that opens the comment closed by the `*/` just cut off `inner`: +/// the first `/*` whose own `*/` isn't inside `inner` (a `/*` with one is a +/// comment of its own — or comment text — and is skipped whole). Strings and +/// `//` are skipped so their bytes don't count. +fn block_comment_start(inner: &str) -> Option { + let bytes = inner.as_bytes(); + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\'' | b'"' | b'`' => { + let quote = bytes[i]; + i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + i += 1; + } else if bytes[i] == quote { + break; + } + i += 1; + } + i += 1; + } + b'/' if bytes.get(i + 1) == Some(&b'/') => { + // `//` runs to the next line break; `inner` may span lines. + match inner[i + 2..].find(is_line_break) { + Some(end) => i += 2 + end, + None => return None, + } + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + match inner[i + 2..].find("*/") { + // A `/*` that closes inside `inner` isn't the one the + // stripped `*/` pairs with. + Some(end) => i += 2 + end + 2, + None => return Some(i), + } + } + _ => i += 1, + } + } + None +} + /// A comment's source range, as TypeScript's `iterateCommentRanges` reports /// it. struct Comment { diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index 3378e63f2..e08376ede 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -3573,6 +3573,7 @@ pub fn input_transform_types<'a>( other_module: false, container_pos: u32::MAX, container_end: u32::MAX, + indent: 1, }; let ty = printer.print(ty); if printer.other_module { diff --git a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs index 06b80a480..1efb0e1de 100644 --- a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs +++ b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs @@ -92,6 +92,26 @@ const COMMENTS_KEPT: &[(&str, &str)] = &[ // type-arguments list, once for the union's constituents list. ("Array<\u{2028}/* c */\nstring | number>", "Array"), ("Array<\u{2029}/* c */\nstring | number>", "Array"), + // `//` and `/*` inside strings and comments are comment text, not trivia. + ("{ a: 'x//y'\n b: 1 }", "{ a: \"x//y\"; b: 1; }"), + ("string | /* a // b */\n number", "string | /* a // b */ number"), + ("string | /** a /** b */\nnumber", "string | /** a /** b */ number"), + // ngtsc synthesizes the type name of a reference (its `pos`/`end` are -1), + // so a comment between the name and the type arguments is dropped. + ("Signal /** j */ ", "i0.Signal"), + // `]` after a trailing comma scans from the list's end (past the comma). + ("[a: string,\n /** j */]", "[a: string\n /** j */ ]"), + // Mapped-type `±readonly` and `±?` are two tokens each. + ("{ - /** j */ readonly [K in string]: 1 }", "{ - /** j */readonly [K in string]: 1; }"), + ("{ [K in string] - /** j */ ?: 1 }", "{ [K in string]- /** j */?: 1; }"), + // The `...` of a binding rest and the `${` of a template literal are + // emitted tokens too. + ("({ ... /** j */ rest }: Box) => void", "({ ... /** j */rest }: Box) => void"), + ("`${ /** j */ string}`", "`${ /** j */string}`"), + // A `typeof` name's parts are emitted separately. + ("typeof val.a.\n/** j */\nb", "typeof val.a.\n /** j */\n b"), + // NEL (U+0085) is whitespace in TypeScript's trivia scans. + ("(/* a */\u{85}/** b */ x: string) => void", "(/* a */ /** b */ x: string) => void"), ]; #[test] From 303b7a2d01645fb07ecd3c9ce7510289abe0e220 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 02:50:45 +0800 Subject: [PATCH 03/10] fix(dts): one write per template quasi; `//` inside `/* */` is comment text - TSTemplateLiteralType emits each quasi chunk (`` `text${` ``, `}text${`, `}text``) as a single write like TypeScript's emitLiteral, so a line break inside the text never gets re-indented: `` `a\n${string}` `` prints `${` at column 0. The chunk stays inside emit_node, so the head's trailing `/** j */` scan still fires. - line_comment_start now scans the whole text with string/block-comment state, so a `//` on the continuation line of a `/* */` no longer truncates the line: `string | /* a\n // b */\n number` keeps the comment like ngtsc. --- .../src/directive/dts_type.rs | 67 ++++++++++--------- .../tests/dts_input_transform_type_test.rs | 8 +++ 2 files changed, 45 insertions(+), 30 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs index fab0be749..68d9d4c76 100644 --- a/crates/oxc_angular_compiler/src/directive/dts_type.rs +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -500,26 +500,28 @@ impl<'a> TypePrinter<'_, 'a> { TSType::TSNamedTupleMember(m) => self.named_tuple_member_body(out, m, None)?, TSType::TSLiteralType(l) => self.literal_body(out, &l.literal)?, TSType::TSTemplateLiteralType(t) => { - self.write(out, "`"); + // `emitTemplateType` + `emitLiteral`: each quasi goes out as + // ONE `writeStringLiteral` — `` `text${` ``, `}text${`, + // `}text`` `` — so a line break inside the text never starts + // a write (no indent): `` `a\n${string}` `` prints `${` at + // column 0. `quasi.span` covers only the cooked text, so a + // chunk is the source slice from the `` ` ``/`}` before it + // to past the `${`/`` ` `` after it. Emitting each chunk as + // a node keeps the comment scans — the `/** j */` in + // `` `${ /** j */ string}` `` is the head's trailing + // comment. for (i, quasi) in t.quasis.iter().enumerate() { - self.write(out, quasi.value.raw.as_str()); + let start = quasi.span.start as usize - 1; + let end = quasi.span.end as usize + if i + 1 == t.quasis.len() { 1 } else { 2 }; + let chunk = self.source[start..end].to_string(); + self.emit_node(out, self.pos_of(start as u32), end as u32, |s, o| { + s.write(o, &chunk); + Some(()) + })?; if let Some(ty) = t.types.get(i) { - // `${` sits right after the quasi's cooked text and - // keeps its same-line JSDoc comments: - // `` `${ /** j */ string}` `` prints as written. - let dollar = quasi.span.end; - self.token_with_comment( - out, - "${", - self.pos_of(dollar), - dollar + 2, - t.span.end, - )?; self.ty_node(out, ty)?; - self.write(out, "}"); } } - self.write(out, "`"); } TSType::TSTypeQuery(q) => { // `typeof x` names a value; ngtsc emits the entity name part @@ -1754,14 +1756,9 @@ fn token_end_before(source: &str, start: usize) -> Option { let before = &source[..end]; let trimmed = before.trim_end(); if before[trimmed.len()..].contains(is_line_break) { - // After the line break, which can be more than one byte (U+2028). - let line_start = trimmed - .char_indices() - .rfind(|&(_, c)| is_line_break(c)) - .map_or(0, |(i, c)| i + c.len_utf8()); - if let Some(slashes) = line_comment_start(&trimmed[line_start..]) { + if let Some(slashes) = line_comment_start(trimmed) { // The line ends at the comment; keep looking before it. - end = line_start + slashes; + end = slashes; continue; } } @@ -1773,15 +1770,19 @@ fn token_end_before(source: &str, start: usize) -> Option { } } -/// The `//` starting a line comment in `line`, or `None` when the only `//`s -/// are inside strings or `/* */` comments. (A `//` inside a `${}` of a -/// template spanning lines can slip through; types don't produce those.) -fn line_comment_start(line: &str) -> Option { - let bytes = line.as_bytes(); +/// The `//` opening a line comment that runs to the end of `code` — so it +/// sits on the last line and bounds the trivia — or `None`. Strings and +/// `/* */` comments are skipped, so `//`s inside them don't count (a `/*` +/// opened on an earlier line keeps its `//`s from counting; an unterminated +/// one means the rest is comment text, and a `//` inside a template's `${}` +/// hole is scanned as template text — types don't produce those). +fn line_comment_start(code: &str) -> Option { + let bytes = code.as_bytes(); let mut i = 0; while i < bytes.len() { match bytes[i] { b'\'' | b'"' | b'`' => { + // Unterminated means the rest is string text: no `//` counts. let quote = bytes[i]; i += 1; while i < bytes.len() { @@ -1794,11 +1795,17 @@ fn line_comment_start(line: &str) -> Option { } i += 1; } - b'/' if bytes.get(i + 1) == Some(&b'/') => return Some(i), + b'/' if bytes.get(i + 1) == Some(&b'/') => { + match code[i + 2..].find(is_line_break) { + // On an earlier line: skip it and keep looking. + Some(end) => i += 2 + end, + None => return Some(i), + } + } b'/' if bytes.get(i + 1) == Some(&b'*') => { - // Unterminated on this line means the rest is comment. - match line[i + 2..].find("*/") { + match code[i + 2..].find("*/") { Some(end) => i += 2 + end + 2, + // Unterminated: the rest is comment text. None => return None, } } diff --git a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs index 1efb0e1de..c0b3ae335 100644 --- a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs +++ b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs @@ -108,6 +108,14 @@ const COMMENTS_KEPT: &[(&str, &str)] = &[ // emitted tokens too. ("({ ... /** j */ rest }: Box) => void", "({ ... /** j */rest }: Box) => void"), ("`${ /** j */ string}`", "`${ /** j */string}`"), + // A quasi goes out as one literal write (TypeScript's `emitLiteral`), so + // line breaks inside its text never start a write: nothing is indented. + ("`a\n${string}`", "`a\n${string}`"), + ("`a\n${string}b\n${number}`", "`a\n${string}b\n${number}`"), + ("`a\n`", "`a\n`"), + ("`${string}x\n`", "`${string}x\n`"), + // A `//` on a `/*`'s continuation line is comment text. + ("string | /* a\n // b */\n number", "string | /* a\n // b */ number"), // A `typeof` name's parts are emitted separately. ("typeof val.a.\n/** j */\nb", "typeof val.a.\n /** j */\n b"), // NEL (U+0085) is whitespace in TypeScript's trivia scans. From f14c736bb11b8d99356a4900bc9075126df4cf14 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 03:19:02 +0800 Subject: [PATCH 04/10] fix(dts): real elision positions and trailing commas in binding lists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - An OmittedExpression is zero-width right after its preceding comma, not at prev_end + 1 (which landed inside the previous element's trivia). Track a cursor past each element's terminating comma instead. - Both binding list formats carry AllowTrailingComma: `[a, ,]` and `{a,}` keep their trailing comma (emitTokenWithComment on the last element — an elision suppresses the comma's leading scan since pos == startPos — plus the comma's own same-line trailing scan, and the closing scan then runs at the list's end). --- .../src/directive/dts_type.rs | 114 ++++++++++++++---- .../tests/dts_input_transform_type_test.rs | 20 +++ 2 files changed, 113 insertions(+), 21 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs index 68d9d4c76..bb0105333 100644 --- a/crates/oxc_angular_compiler/src/directive/dts_type.rs +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -55,6 +55,7 @@ mod fmt { pub const BAR: u32 = 4; pub const AMPERSAND: u32 = 8; pub const COMMA: u32 = 16; + pub const ALLOW_TRAILING_COMMA: u32 = 64; pub const INDENTED: u32 = 128; pub const SPACE_BETWEEN_BRACES: u32 = 256; pub const SPACE_BETWEEN_SIBLINGS: u32 = 512; @@ -85,10 +86,14 @@ mod fmt { /// `SingleLineTupleTypeElements` (`NoSpaceIfEmpty` is added at the site). pub const TUPLE_ELEMENTS: u32 = COMMA | SPACE_BETWEEN_SIBLINGS; /// `ObjectBindingPatternElements`. - pub const OBJECT_BINDING: u32 = - SPACE_BETWEEN_BRACES | COMMA | SPACE_BETWEEN_SIBLINGS | NO_SPACE_IF_EMPTY; + pub const OBJECT_BINDING: u32 = SPACE_BETWEEN_BRACES + | COMMA + | ALLOW_TRAILING_COMMA + | SPACE_BETWEEN_SIBLINGS + | NO_SPACE_IF_EMPTY; /// `ArrayBindingPatternElements`. - pub const ARRAY_BINDING: u32 = COMMA | SPACE_BETWEEN_SIBLINGS | NO_SPACE_IF_EMPTY; + pub const ARRAY_BINDING: u32 = + COMMA | ALLOW_TRAILING_COMMA | SPACE_BETWEEN_SIBLINGS | NO_SPACE_IF_EMPTY; /// `Modifiers`: a space-separated, comment-free list. pub const MODIFIERS: u32 = SPACE_BETWEEN_SIBLINGS | NO_INTERVENING | SPACE_AFTER_LIST; } @@ -242,10 +247,12 @@ impl<'a> TypePrinter<'_, 'a> { /// /// `items` are the list's children; `list_pos`/`list_end` are the /// `NodeArray`'s trivia range (inside the brackets — used only for the - /// empty-list comment scans); `parent_end` is the enclosing node's `end` + /// empty-list comment scans, and `list_end` again for the closing scan + /// after a trailing comma); `parent_end` is the enclosing node's `end` /// — the gate that keeps the comments after the last element out when the - /// parent ends there too. `open`/`close` are the bracket texts for - /// `BracketsMask` formats. + /// parent ends there too. `trailing_comma` is the `NodeArray`'s + /// `hasTrailingComma` — written only when the format allows it. + /// `open`/`close` are the bracket texts for `BracketsMask` formats. fn emit_list( &mut self, out: &mut String, @@ -254,6 +261,7 @@ impl<'a> TypePrinter<'_, 'a> { parent_end: u32, list_pos: usize, list_end: usize, + trailing_comma: bool, open: Option<&'static str>, close: Option<&'static str>, ) -> Option<()> { @@ -274,7 +282,7 @@ impl<'a> TypePrinter<'_, 'a> { self.write(out, " "); } } else { - self.emit_list_items(out, items, format, parent_end)?; + self.emit_list_items(out, items, format, parent_end, list_end, trailing_comma)?; } if format & fmt::BRACKETS != 0 { if items.is_empty() { @@ -296,6 +304,8 @@ impl<'a> TypePrinter<'_, 'a> { items: &[El<'_, 'a>], format: u32, parent_end: u32, + list_end: usize, + trailing_comma: bool, ) -> Option<()> { let may_intervene = format & fmt::NO_INTERVENING == 0; let mut should_intervene = may_intervene; @@ -335,15 +345,38 @@ impl<'a> TypePrinter<'_, 'a> { self.emit_item(out, item, pos)?; prev_end = Some(span.end); } - // `emitTrailingComma`: no format here has both `AllowTrailingComma` - // and `CommaDelimited`, so it's never emitted. + // `emitTrailingComma`: the comma itself goes through + // `emitTokenWithComment` on the last element — the comments before it + // run only when that element has a real range (a zero-width elision + // has `pos == end`, which suppresses the leading scan upstream), and + // the comments after it are the comma's same-line scan. With a + // trailing comma the closing scan runs at the list's own `end` (the + // `NodeArray` ends inside the brackets), not the last element's. + let emit_trailing_comma = + trailing_comma && format & fmt::ALLOW_TRAILING_COMMA != 0 && format & fmt::COMMA != 0; + if let Some(prev) = items.last() + && emit_trailing_comma + && let Some(prev_end) = prev_end + { + let comma = skip_trivia(self.source, prev_end as usize) + 1; + if prev.span().start != prev.span().end && prev_end != parent_end { + self.emit_leading_of_pos(out, prev_end as usize); + } + self.write(out, ","); + if prev_end != parent_end { + self.emit_trailing_filtered(out, comma); + } + } if let Some(prev_end) = prev_end && prev_end != parent_end && format & fmt::DELIMITERS != 0 { // The JSDoc comments after the last element, before the closing // token. - self.emit_leading_of_pos(out, prev_end as usize); + self.emit_leading_of_pos( + out, + if emit_trailing_comma { list_end } else { prev_end as usize }, + ); } if format & fmt::INDENTED != 0 { self.indent -= 1; @@ -429,11 +462,21 @@ impl<'a> TypePrinter<'_, 'a> { } TSType::TSUnionType(u) => { let items: Vec = u.types.iter().map(El::Ty).collect(); - self.emit_list(out, &items, fmt::UNION, u.span.end, 0, 0, None, None)?; + self.emit_list(out, &items, fmt::UNION, u.span.end, 0, 0, false, None, None)?; } TSType::TSIntersectionType(i) => { let items: Vec = i.types.iter().map(El::Ty).collect(); - self.emit_list(out, &items, fmt::INTERSECTION, i.span.end, 0, 0, None, None)?; + self.emit_list( + out, + &items, + fmt::INTERSECTION, + i.span.end, + 0, + 0, + false, + None, + None, + )?; } TSType::TSParenthesizedType(p) => { self.write(out, "("); @@ -488,6 +531,7 @@ impl<'a> TypePrinter<'_, 'a> { t.span.end, list_pos, list_end, + false, None, None, )?; @@ -543,6 +587,7 @@ impl<'a> TypePrinter<'_, 'a> { l.span.end, l.span.start as usize + 1, l.span.end as usize - 1, + false, None, None, )?; @@ -660,6 +705,7 @@ impl<'a> TypePrinter<'_, 'a> { c.span.end, start + 8, start + 8, + false, None, None, )?; @@ -856,6 +902,7 @@ impl<'a> TypePrinter<'_, 'a> { args.span.end, args.span.start as usize + 1, args.span.end as usize - 1, + false, Some("<"), Some(">"), ) @@ -875,6 +922,7 @@ impl<'a> TypePrinter<'_, 'a> { params.span.end, params.span.start as usize + 1, params.span.end as usize - 1, + false, Some("<"), Some(">"), ) @@ -898,7 +946,7 @@ impl<'a> TypePrinter<'_, 'a> { } } if !mods.is_empty() { - self.emit_list(out, &mods, fmt::MODIFIERS, param.span.end, at, at, None, None)?; + self.emit_list(out, &mods, fmt::MODIFIERS, param.span.end, at, at, false, None, None)?; } self.emit_node(out, self.pos_of(param.name.span.start), param.name.span.end, |s, o| { s.write(o, ¶m.name.name); @@ -943,6 +991,7 @@ impl<'a> TypePrinter<'_, 'a> { parent_end, params.span.start as usize + 1, params.span.end as usize - 1, + false, Some("("), Some(")"), ) @@ -961,6 +1010,7 @@ impl<'a> TypePrinter<'_, 'a> { param.span.end, start + 8, start + 8, + false, None, None, )?; @@ -1045,6 +1095,7 @@ impl<'a> TypePrinter<'_, 'a> { parent_end, list_pos, param.span.end as usize, + false, Some("["), Some("]"), ) @@ -1160,7 +1211,7 @@ impl<'a> TypePrinter<'_, 'a> { El::Token { span: Span::new(start as u32, start as u32 + len), text } }) .collect(); - self.emit_list(out, &items, fmt::MODIFIERS, end, 0, 0, None, None) + self.emit_list(out, &items, fmt::MODIFIERS, end, 0, 0, false, None, None) } /// A property name as one node — `emitNodeWithWriter(node.name)` — or a @@ -1255,6 +1306,11 @@ impl<'a> TypePrinter<'_, 'a> { if let Some(rest) = &o.rest { items.push(El::BindingRest(rest)); } + // `ObjectBindingPatternElements` allows a trailing comma: + // `{a,}` prints `{ a, }`. + let trailing = items + .last() + .is_some_and(|el| self.trailing_comma_after(el.span().end as usize)); self.emit_list( out, &items, @@ -1262,6 +1318,7 @@ impl<'a> TypePrinter<'_, 'a> { o.span.end, o.span.start as usize + 1, o.span.end as usize - 1, + trailing, None, None, )?; @@ -1270,24 +1327,32 @@ impl<'a> TypePrinter<'_, 'a> { BindingPattern::ArrayPattern(a) => { self.write(out, "["); let mut items: Vec = Vec::new(); - let mut prev_end = a.span.start; + // Each element ends at a comma (or `]`). An elision is an + // `OmittedExpression`: zero width right after the comma that + // precedes it — trivia before that comma belongs to the + // previous element, trivia after it is the elision's own. + let mut cursor = a.span.start as usize + 1; + let mut trailing = false; for element in &a.elements { match element { Some(element) => { items.push(El::Binding(element)); - prev_end = element.span().end; + cursor = element.span().end as usize; } None => { - // An elision is an `OmittedExpression`: zero - // width at the comma after the previous element - // (or after `[` when first). - let at = if items.is_empty() { a.span.start + 1 } else { prev_end + 1 }; - items.push(El::Token { span: Span::new(at, at), text: "" }); + items.push(El::Token { + span: Span::new(cursor as u32, cursor as u32), + text: "", + }); } } + let after = skip_trivia(self.source, cursor); + trailing = self.source[after..].starts_with(','); + cursor = after + 1; } if let Some(rest) = &a.rest { items.push(El::BindingRest(rest)); + trailing = self.trailing_comma_after(rest.span.end as usize); } self.emit_list( out, @@ -1296,6 +1361,7 @@ impl<'a> TypePrinter<'_, 'a> { a.span.end, a.span.start as usize + 1, a.span.end as usize - 1, + trailing, None, None, )?; @@ -1661,6 +1727,12 @@ impl<'a> TypePrinter<'_, 'a> { } } + /// `NodeArray.hasTrailingComma`: a `,` between the last element's end + /// and the closing token. + fn trailing_comma_after(&self, end: usize) -> bool { + self.source[skip_trivia(self.source, end)..].starts_with(',') + } + fn slice(&self, span: Span) -> String { span.source_text(self.source).to_string() } diff --git a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs index c0b3ae335..910fd6272 100644 --- a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs +++ b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs @@ -116,6 +116,26 @@ const COMMENTS_KEPT: &[(&str, &str)] = &[ ("`${string}x\n`", "`${string}x\n`"), // A `//` on a `/*`'s continuation line is comment text. ("string | /* a\n // b */\n number", "string | /* a\n // b */ number"), + // An elision is zero-width right after its preceding comma: comments + // before the comma stay with the previous element, comments after it + // are the elision's intervening trivia. + ("([a /** j */, , b]: any[]) => void", "([a /** j */, , b]: any[]) => void"), + ("([a, , /* x */, , b]: any[]) => void", "([a, , /* x */ , , b]: any[]) => void"), + ( + "([a /** j */, , /* x */, , b]: any[]) => void", + "([a /** j */, , /* x */ , , b]: any[]) => void", + ), + ("([, , a]: any[]) => void", "([, , a]: any[]) => void"), + // `AllowTrailingComma` is on both binding formats: `[a, ,]` keeps its + // trailing comma, and the comments around it are the elision's and the + // comma's own scans. + ("([a, ,]: any[]) => void", "([a, ,]: any[]) => void"), + ("([a,]: any[]) => void", "([a,]: any[]) => void"), + ("({a,}: any) => void", "({ a, }: any) => void"), + ("([a /** j */,]: any[]) => void", "([a /** j */,]: any[]) => void"), + ("([a, /** j */,]: any[]) => void", "([a, /** j */ ,]: any[]) => void"), + ("([a, , /** j */,]: any[]) => void", "([a, , /** j */ ,]: any[]) => void"), + ("([a, , /** j */, b]: any[]) => void", "([a, , /** j */ , b]: any[]) => void"), // A `typeof` name's parts are emitted separately. ("typeof val.a.\n/** j */\nb", "typeof val.a.\n /** j */\n b"), // NEL (U+0085) is whitespace in TypeScript's trivia scans. From ee29e3218ce557f62419be3b6b957eeeba316faa Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 03:34:08 +0800 Subject: [PATCH 05/10] fix(dts): `${ }` holes in a template lex as code in the trivia scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A backward trivia scan that starts inside a template placeholder saw the opening backtick as an unterminated string and gave up, so `` `${\n/** doc */\nstring}` `` lost the JSDoc that ngtsc keeps. Template text is now skipped as text while `${ }` regions are lexed as code — comments inside them count, braces track `{ T: U }` members, and nested templates recurse — in both `line_comment_start` and `block_comment_start`. --- .../src/directive/dts_type.rs | 131 ++++++++++++++---- .../tests/dts_input_transform_type_test.rs | 7 + 2 files changed, 111 insertions(+), 27 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs index bb0105333..4b1ed8ab0 100644 --- a/crates/oxc_angular_compiler/src/directive/dts_type.rs +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -1846,26 +1846,19 @@ fn token_end_before(source: &str, start: usize) -> Option { /// sits on the last line and bounds the trivia — or `None`. Strings and /// `/* */` comments are skipped, so `//`s inside them don't count (a `/*` /// opened on an earlier line keeps its `//`s from counting; an unterminated -/// one means the rest is comment text, and a `//` inside a template's `${}` -/// hole is scanned as template text — types don't produce those). +/// one means the rest is comment text). `${}` holes in a template literal +/// are code, not string text, so comments inside them count normally. fn line_comment_start(code: &str) -> Option { let bytes = code.as_bytes(); let mut i = 0; while i < bytes.len() { match bytes[i] { - b'\'' | b'"' | b'`' => { - // Unterminated means the rest is string text: no `//` counts. - let quote = bytes[i]; - i += 1; - while i < bytes.len() { - if bytes[i] == b'\\' { - i += 1; - } else if bytes[i] == quote { - break; - } - i += 1; - } - i += 1; + b'\'' | b'"' => i = str_end(code, i), + b'`' => { + i = match template_end(code, i, CommentKind::Line) { + Ok(i) => i, + Err(hit) => return hit, + }; } b'/' if bytes.get(i + 1) == Some(&b'/') => { match code[i + 2..].find(is_line_break) { @@ -1896,18 +1889,12 @@ fn block_comment_start(inner: &str) -> Option { let mut i = 0; while i < bytes.len() { match bytes[i] { - b'\'' | b'"' | b'`' => { - let quote = bytes[i]; - i += 1; - while i < bytes.len() { - if bytes[i] == b'\\' { - i += 1; - } else if bytes[i] == quote { - break; - } - i += 1; - } - i += 1; + b'\'' | b'"' => i = str_end(inner, i), + b'`' => { + i = match template_end(inner, i, CommentKind::Block) { + Ok(i) => i, + Err(hit) => return hit, + }; } b'/' if bytes.get(i + 1) == Some(&b'/') => { // `//` runs to the next line break; `inner` may span lines. @@ -1930,6 +1917,96 @@ fn block_comment_start(inner: &str) -> Option { None } +/// What a comment found while skipping a lexeme means to the caller: +/// `line_comment_start` wants the `//`, `block_comment_start` wants the `/*`. +#[derive(Clone, Copy)] +enum CommentKind { + /// `//` running to the window's end. + Line, + /// `/*` never closing inside the window — the rest is comment text. + Block, +} + +/// The index past the `'`/`"` string starting at `open`. An unterminated +/// string runs to the end — every byte after it is text, so nothing in this +/// window counts. +fn str_end(code: &str, open: usize) -> usize { + let bytes = code.as_bytes(); + let quote = bytes[open]; + let mut i = open + 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + i += 1; + } else if bytes[i] == quote { + return i + 1; + } + i += 1; + } + bytes.len() +} + +/// The index past the template literal starting at `open`, or `Err(None)` +/// when it runs to the window's end. `${ }` holes are code — comments, +/// strings, and nested templates inside them lex normally — and a comment +/// `kind` finds inside a hole surfaces as `Err(Some(_))`, since the caller's +/// wanted comment can sit between the `${` and the token after it. +fn template_end(code: &str, open: usize, kind: CommentKind) -> Result> { + let bytes = code.as_bytes(); + let mut i = open + 1; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 1, + b'`' => return Ok(i + 1), + b'$' if bytes.get(i + 1) == Some(&b'{') => i = hole_end(code, i + 2, kind)?, + _ => {} + } + i += 1; + } + Err(None) +} + +/// The index past the `}` closing a `${` hole whose contents start at `pos` +/// (a `{`-depth tracker: nested braces come from `{ T: U }` members). Same +/// `Result` channels as [`template_end`]. +fn hole_end(code: &str, pos: usize, kind: CommentKind) -> Result> { + let bytes = code.as_bytes(); + let mut i = pos; + let mut depth = 1usize; + while i < bytes.len() { + match bytes[i] { + b'{' => { + depth += 1; + i += 1; + } + b'}' => { + depth -= 1; + i += 1; + if depth == 0 { + return Ok(i); + } + } + b'\'' | b'"' => i = str_end(code, i), + b'`' => i = template_end(code, i, kind)?, + b'/' if bytes.get(i + 1) == Some(&b'/') => match code[i + 2..].find(is_line_break) { + Some(end) => i += 2 + end, + None => match kind { + CommentKind::Line => return Err(Some(i)), + CommentKind::Block => return Err(None), + }, + }, + b'/' if bytes.get(i + 1) == Some(&b'*') => match code[i + 2..].find("*/") { + Some(end) => i += 2 + end + 2, + None => match kind { + CommentKind::Line => return Err(None), + CommentKind::Block => return Err(Some(i)), + }, + }, + _ => i += 1, + } + } + Err(None) +} + /// A comment's source range, as TypeScript's `iterateCommentRanges` reports /// it. struct Comment { diff --git a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs index 910fd6272..26d451979 100644 --- a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs +++ b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs @@ -136,6 +136,13 @@ const COMMENTS_KEPT: &[(&str, &str)] = &[ ("([a, /** j */,]: any[]) => void", "([a, /** j */ ,]: any[]) => void"), ("([a, , /** j */,]: any[]) => void", "([a, , /** j */ ,]: any[]) => void"), ("([a, , /** j */, b]: any[]) => void", "([a, , /** j */ , b]: any[]) => void"), + // Comments inside a template's `${}` holes count during the backward + // trivia scan — the hole is code; the opening backtick isn't an + // unterminated string. + ("`${ /** j */\n string}`", "`${ /** j */string}`"), + ("`${\n/** doc */\nstring}`", "`${\n /** doc */\n string}`"), + ("`x${string /** j */ }y`", "`x${string /** j */}y`"), + ("`${string}t${ /** k */ number}z`", "`${string}t${ /** k */number}z`"), // A `typeof` name's parts are emitted separately. ("typeof val.a.\n/** j */\nb", "typeof val.a.\n /** j */\n b"), // NEL (U+0085) is whitespace in TypeScript's trivia scans. From 8e2ad8df0afc2c6f97fc2f54ca3a222f681d6c1c Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 03:48:59 +0800 Subject: [PATCH 06/10] fix(dts): lex the source's comments once; skip regex literals Two changes in the backward trivia scan's lexer: - `lex_comments` builds the file's `//`/`/* */` table in one pass and `pos_of` queries it, instead of rescanning the whole prefix per node. - The lexer skips regex literals (operand heuristic + `[...]`/escape aware), so a `/[/*]/` earlier in the file can't pair its `/*` with a comment's `*/` inside the type and drop the comment ngtsc keeps. --- .../src/directive/dts_type.rs | 282 ++++++++++-------- .../src/directive/evaluator.rs | 1 + .../tests/dts_input_transform_type_test.rs | 3 + 3 files changed, 169 insertions(+), 117 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs index 4b1ed8ab0..f75fa8654 100644 --- a/crates/oxc_angular_compiler/src/directive/dts_type.rs +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -124,6 +124,9 @@ pub(crate) struct TypePrinter<'s, 'a> { /// members sit at one level, and a `ListFormat.Indented` list adds one /// more while its items print. pub indent: u32, + /// The source's comment table, lexed on first use: the backward trivia + /// scan queries it instead of reparsing the whole prefix per node. + pub(crate) lexed: std::cell::OnceCell, } /// A list element for [`TypePrinter::emit_list`]: a node with a span plus the @@ -191,8 +194,12 @@ impl<'a> TypePrinter<'_, 'a> { /// A node's `pos` (start of its leading trivia): the end of the previous /// token. + fn lexed(&self) -> &Lexed { + self.lexed.get_or_init(|| lex_comments(self.source)) + } + fn pos_of(&self, start: u32) -> u32 { - token_end_before(self.source, start as usize).unwrap_or(start as usize) as u32 + token_end_before(self.lexed(), self.source, start as usize).unwrap_or(start as usize) as u32 } /// Emit a node like TypeScript's `emit(node)`: its leading comments, the @@ -1070,8 +1077,9 @@ impl<'a> TypePrinter<'_, 'a> { param: &TSIndexSignatureName<'a>, ) -> Option<()> { let name_start = skip_trivia(self.source, param.span.start as usize); - let name_end = token_end_before(self.source, param.type_annotation.span.start as usize) - .unwrap_or(param.type_annotation.span.start as usize); + let name_end = + token_end_before(self.lexed(), self.source, param.type_annotation.span.start as usize) + .unwrap_or(param.type_annotation.span.start as usize); self.emit_node(out, self.pos_of(name_start as u32), name_end as u32, |s, o| { s.write(o, param.name.as_str()); Some(()) @@ -1220,9 +1228,11 @@ impl<'a> TypePrinter<'_, 'a> { if computed { // `ComputedPropertyName`'s range: from the trivia before `[` to // `]`'s end. oxc's key span covers the expression only. - let bracket_end = token_end_before(self.source, key.span().start as usize) - .unwrap_or(key.span().start as usize); - let pos = token_end_before(self.source, bracket_end - 1).unwrap_or(bracket_end - 1); + let bracket_end = + token_end_before(self.lexed(), self.source, key.span().start as usize) + .unwrap_or(key.span().start as usize); + let pos = token_end_before(self.lexed(), self.source, bracket_end - 1) + .unwrap_or(bracket_end - 1); let end = skip_trivia(self.source, key.span().end as usize) + 1; let text = self.key_text(key)?; self.emit_node(out, pos as u32, end as u32, |s, o| { @@ -1822,13 +1832,13 @@ fn skip_trivia(source: &str, mut pos: usize) -> usize { /// on the line before it). `//` inside a string or a `/* */` doesn't count, /// and the `/*` a trailing `*/` pairs with is the first one after the /// previous `*/`, not the last. -fn token_end_before(source: &str, start: usize) -> Option { +fn token_end_before(lexed: &Lexed, source: &str, start: usize) -> Option { let mut end = start; loop { let before = &source[..end]; let trimmed = before.trim_end(); if before[trimmed.len()..].contains(is_line_break) { - if let Some(slashes) = line_comment_start(trimmed) { + if let Some(slashes) = line_comment_start(lexed, trimmed) { // The line ends at the comment; keep looking before it. end = slashes; continue; @@ -1836,100 +1846,62 @@ fn token_end_before(source: &str, start: usize) -> Option { } end = trimmed.len(); match trimmed.strip_suffix("*/") { - Some(inner) => end = block_comment_start(inner)?, + Some(inner) => end = block_comment_start(lexed, inner)?, None => return Some(end), } } } -/// The `//` opening a line comment that runs to the end of `code` — so it -/// sits on the last line and bounds the trivia — or `None`. Strings and -/// `/* */` comments are skipped, so `//`s inside them don't count (a `/*` -/// opened on an earlier line keeps its `//`s from counting; an unterminated -/// one means the rest is comment text). `${}` holes in a template literal -/// are code, not string text, so comments inside them count normally. -fn line_comment_start(code: &str) -> Option { - let bytes = code.as_bytes(); - let mut i = 0; - while i < bytes.len() { - match bytes[i] { - b'\'' | b'"' => i = str_end(code, i), - b'`' => { - i = match template_end(code, i, CommentKind::Line) { - Ok(i) => i, - Err(hit) => return hit, - }; - } - b'/' if bytes.get(i + 1) == Some(&b'/') => { - match code[i + 2..].find(is_line_break) { - // On an earlier line: skip it and keep looking. - Some(end) => i += 2 + end, - None => return Some(i), - } - } - b'/' if bytes.get(i + 1) == Some(&b'*') => { - match code[i + 2..].find("*/") { - Some(end) => i += 2 + end + 2, - // Unterminated: the rest is comment text. - None => return None, - } - } - _ => i += 1, - } - } - None +/// The source's comments, once: sorted `(start, end)` ranges of every `//` +/// and `/* */` — `end` of a `//` is the next line break (or the file's end), +/// of `/* */` past the closer. Lexing once keeps the backward trivia scan +/// from reparsing the whole prefix for every node, and skipping strings, +/// template text, and regexes keeps their bytes from pairing as comments — +/// a `/[/*]/` earlier in the file would otherwise swallow a real `/*` in +/// the type. `${ }` holes lex as code (the comments inside them count) and +/// nested templates recurse. +#[derive(Default)] +pub(crate) struct Lexed { + line: Vec<(usize, usize)>, + block: Vec<(usize, usize)>, } -/// The `/*` that opens the comment closed by the `*/` just cut off `inner`: -/// the first `/*` whose own `*/` isn't inside `inner` (a `/*` with one is a -/// comment of its own — or comment text — and is skipped whole). Strings and -/// `//` are skipped so their bytes don't count. -fn block_comment_start(inner: &str) -> Option { - let bytes = inner.as_bytes(); +fn lex_comments(source: &str) -> Lexed { + let mut out = Lexed::default(); + // The last non-trivia byte lexed; `/` after an operand-ish byte is + // division, anywhere else it can open a regex (the `}` case guesses + // "expression", so a `}` closing a block is a known miss). + let mut last = b';'; + let bytes = source.as_bytes(); let mut i = 0; while i < bytes.len() { - match bytes[i] { - b'\'' | b'"' => i = str_end(inner, i), - b'`' => { - i = match template_end(inner, i, CommentKind::Block) { - Ok(i) => i, - Err(hit) => return hit, - }; + let b = bytes[i]; + match b { + b'\'' | b'"' => { + last = b'"'; + i = str_end(source, i); } - b'/' if bytes.get(i + 1) == Some(&b'/') => { - // `//` runs to the next line break; `inner` may span lines. - match inner[i + 2..].find(is_line_break) { - Some(end) => i += 2 + end, - None => return None, - } + b'`' => { + last = b'"'; + i = template_end(source, i, &mut out, &mut last); } - b'/' if bytes.get(i + 1) == Some(&b'*') => { - match inner[i + 2..].find("*/") { - // A `/*` that closes inside `inner` isn't the one the - // stripped `*/` pairs with. - Some(end) => i += 2 + end + 2, - None => return Some(i), + b'/' => i = slash_step(source, i, &mut out, &mut last), + _ => { + if b < 128 && !is_space(b as char) { + // Any non-whitespace ASCII byte counts as operand-ish. + last = b; + } else if b >= 128 { + last = b'"'; } + i += 1; } - _ => i += 1, } } - None -} - -/// What a comment found while skipping a lexeme means to the caller: -/// `line_comment_start` wants the `//`, `block_comment_start` wants the `/*`. -#[derive(Clone, Copy)] -enum CommentKind { - /// `//` running to the window's end. - Line, - /// `/*` never closing inside the window — the rest is comment text. - Block, + out } -/// The index past the `'`/`"` string starting at `open`. An unterminated -/// string runs to the end — every byte after it is text, so nothing in this -/// window counts. +/// Skip a `'`/`"` string starting at `open`: the index past its closer, or +/// the text's end when unterminated. fn str_end(code: &str, open: usize) -> usize { let bytes = code.as_bytes(); let quote = bytes[open]; @@ -1945,30 +1917,28 @@ fn str_end(code: &str, open: usize) -> usize { bytes.len() } -/// The index past the template literal starting at `open`, or `Err(None)` -/// when it runs to the window's end. `${ }` holes are code — comments, -/// strings, and nested templates inside them lex normally — and a comment -/// `kind` finds inside a hole surfaces as `Err(Some(_))`, since the caller's -/// wanted comment can sit between the `${` and the token after it. -fn template_end(code: &str, open: usize, kind: CommentKind) -> Result> { +/// Skip a template literal starting at `open`: the index past its closing +/// backtick, or the text's end. The text is trivia-less, but its `${ }` +/// holes are lexed as code by [`hole_end`]. +fn template_end(code: &str, open: usize, out: &mut Lexed, last: &mut u8) -> usize { let bytes = code.as_bytes(); let mut i = open + 1; while i < bytes.len() { match bytes[i] { b'\\' => i += 1, - b'`' => return Ok(i + 1), - b'$' if bytes.get(i + 1) == Some(&b'{') => i = hole_end(code, i + 2, kind)?, + b'`' => return i + 1, + b'$' if bytes.get(i + 1) == Some(&b'{') => i = hole_end(code, i + 2, out, last), _ => {} } i += 1; } - Err(None) + bytes.len() } -/// The index past the `}` closing a `${` hole whose contents start at `pos` -/// (a `{`-depth tracker: nested braces come from `{ T: U }` members). Same -/// `Result` channels as [`template_end`]. -fn hole_end(code: &str, pos: usize, kind: CommentKind) -> Result> { +/// Lex a `${ }` hole's contents from `pos` to past the `}`: a `{`/`}` depth +/// tracker, so `{ T: U }` members inside don't end the hole. Runs to the +/// text's end when the hole never closes. +fn hole_end(code: &str, pos: usize, out: &mut Lexed, last: &mut u8) -> usize { let bytes = code.as_bytes(); let mut i = pos; let mut depth = 1usize; @@ -1982,29 +1952,107 @@ fn hole_end(code: &str, pos: usize, kind: CommentKind) -> Result i = str_end(code, i), - b'`' => i = template_end(code, i, kind)?, - b'/' if bytes.get(i + 1) == Some(&b'/') => match code[i + 2..].find(is_line_break) { - Some(end) => i += 2 + end, - None => match kind { - CommentKind::Line => return Err(Some(i)), - CommentKind::Block => return Err(None), - }, - }, - b'/' if bytes.get(i + 1) == Some(&b'*') => match code[i + 2..].find("*/") { - Some(end) => i += 2 + end + 2, - None => match kind { - CommentKind::Line => return Err(None), - CommentKind::Block => return Err(Some(i)), - }, - }, - _ => i += 1, + b'/' => i = slash_step(code, i, out, last), + _ => i = lex_byte(code, i, out, last), + } + } + i +} + +/// One non-`/`, non-`{}` byte inside a `${ }` hole. +fn lex_byte(code: &str, i: usize, out: &mut Lexed, last: &mut u8) -> usize { + let b = code.as_bytes()[i]; + match b { + b'\'' | b'"' => { + *last = b'"'; + str_end(code, i) + } + b'`' => { + *last = b'"'; + template_end(code, i, out, last) + } + _ => { + if b < 128 && !is_space(b as char) { + *last = b; + } else if b >= 128 { + *last = b'"'; + } + i + 1 + } + } +} + +/// One `/` at `i`: `//` and `/* */` go into the comment table; a regex +/// literal is skipped so its `*/`/`//`-looking bytes stay literal text; +/// anything else is division and the byte advances one. +fn slash_step(code: &str, i: usize, out: &mut Lexed, last: &mut u8) -> usize { + let bytes = code.as_bytes(); + if bytes.get(i + 1) == Some(&b'/') { + let end = code[i + 2..].find(is_line_break).map(|e| i + 2 + e).unwrap_or(bytes.len()); + out.line.push((i, end)); + end + } else if bytes.get(i + 1) == Some(&b'*') { + let end = code[i + 2..].find("*/").map(|e| i + 2 + e + 2).unwrap_or(bytes.len()); + out.block.push((i, end)); + end + } else { + let next = if regex_can_start(*last) { regex_end(code, i) } else { None }; + *last = b'/'; + next.unwrap_or(i + 1) + } +} + +/// Whether `/` can open a regex after `prev`, the last non-trivia byte. +/// False after operands (identifier bytes, `)`, `]`, `}`, quotes), true +/// after operators and at the start. +fn regex_can_start(prev: u8) -> bool { + !matches!(prev, b')' | b']' | b'}' | b'_' | b'$' | b'\'' | b'"' | b'`' | b'/') + && !prev.is_ascii_alphanumeric() +} + +/// The index past a regex literal opening at `open`, or `None` when it is +/// really division — no closing `/` before a line break (classes can hold +/// `//` and `*/`, escapes can hold `]` and `/`). +fn regex_end(code: &str, open: usize) -> Option { + let bytes = code.as_bytes(); + let mut i = open + 1; + let mut class = false; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 1, + b'[' => class = true, + b']' => class = false, + b'/' if !class => return Some(i + 1), + b'\n' | b'\r' => return None, + _ => {} } + i += 1; } - Err(None) + None +} + +/// The `//` opening a line comment that runs to the end of `code` — so it +/// sits on the last line and bounds the trivia — or `None`. +fn line_comment_start(lexed: &Lexed, code: &str) -> Option { + lexed + .line + .iter() + .find(|(start, end)| *start < code.len() && *end >= code.len()) + .map(|(start, _)| *start) +} + +/// The `/*` that opens the comment closed by the `*/` just cut off `inner`: +/// the first `/*` whose own `*/` isn't inside `inner` (a `/*` with one is a +/// comment of its own — or comment text — and is skipped whole). +fn block_comment_start(lexed: &Lexed, inner: &str) -> Option { + lexed + .block + .iter() + .find(|(start, end)| *start < inner.len() && *end > inner.len()) + .map(|(start, _)| *start) } /// A comment's source range, as TypeScript's `iterateCommentRanges` reports diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index e08376ede..0bde6a403 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -3574,6 +3574,7 @@ pub fn input_transform_types<'a>( container_pos: u32::MAX, container_end: u32::MAX, indent: 1, + lexed: Default::default(), }; let ty = printer.print(ty); if printer.other_module { diff --git a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs index 26d451979..0a0f69473 100644 --- a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs +++ b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs @@ -22,6 +22,7 @@ export interface Gen { v: T } export const val = {a: 1}; export const kc = 'kc'; export function gen(x: T) { return x; } +const re = /[/*]/; @Directive({selector: '[t]'}) export class T { @Input({transform: (v: %TYPE%) => 1}) t: any; @@ -116,6 +117,8 @@ const COMMENTS_KEPT: &[(&str, &str)] = &[ ("`${string}x\n`", "`${string}x\n`"), // A `//` on a `/*`'s continuation line is comment text. ("string | /* a\n // b */\n number", "string | /* a\n // b */ number"), + // The `/*` inside the file's `/[/*]/` regex doesn't pair with this `*/`. + ("string | /* a\n b */ number", "string | /* a\n b */ number"), // An elision is zero-width right after its preceding comma: comments // before the comma stay with the previous element, comments after it // are the elision's intervening trivia. From 1cc950e5a6ec5c894d3de9a80527cadb1492bdb1 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 03:57:02 +0800 Subject: [PATCH 07/10] fix(dts): hole scan resume, keyword regexes, TS trivia trim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `template_end` resumed past `hole_end`'s return plus one more byte, skipping the closing backtick of `` `${string}` `` and swallowing the rest of the file as template text. - A `/` after a keyword (`return`, `throw`, `case`, `typeof`, …) opens a regex — the operand heuristic only saw the identifier's last byte and called it division, so `return /[/*]/` leaked a `/*` into the comment table. - `token_end_before` trims trivia with `is_whitespace_like` (`isWhiteSpaceSingleLine` + line breaks) instead of `char::is_whitespace`: U+200B/U+FEFF count as trivia and U+2028/9 stay line breaks. --- .../src/directive/dts_type.rs | 125 +++++++++++++----- .../tests/dts_input_transform_type_test.rs | 11 +- 2 files changed, 100 insertions(+), 36 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs index f75fa8654..e8bdfbc98 100644 --- a/crates/oxc_angular_compiler/src/directive/dts_type.rs +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -1836,15 +1836,20 @@ fn token_end_before(lexed: &Lexed, source: &str, start: usize) -> Option let mut end = start; loop { let before = &source[..end]; - let trimmed = before.trim_end(); - if before[trimmed.len()..].contains(is_line_break) { - if let Some(slashes) = line_comment_start(lexed, trimmed) { + // TypeScript's trivia, not Rust's Unicode whitespace: the trim must + // take `isWhiteSpaceSingleLine` bytes like U+200B/U+FEFF and leave + // the line breaks (`\u{2028}` included — a `//` after one still + // counts for the same check above). + let trimmed_len = before.trim_end_matches(is_whitespace_like).len(); + if before[trimmed_len..].contains(is_line_break) { + if let Some(slashes) = line_comment_start(lexed, &before[..trimmed_len]) { // The line ends at the comment; keep looking before it. end = slashes; continue; } } - end = trimmed.len(); + end = trimmed_len; + let trimmed = &before[..trimmed_len]; match trimmed.strip_suffix("*/") { Some(inner) => end = block_comment_start(lexed, inner)?, None => return Some(end), @@ -1866,32 +1871,47 @@ pub(crate) struct Lexed { block: Vec<(usize, usize)>, } +/// The last non-trivia byte lexed and where it sits — `pos` lets +/// [`slash_step`] pull the word a trailing identifier belongs to, since +/// keywords like `return` also end in identifier bytes but a `/` after them +/// opens a regex. +struct Prev { + byte: u8, + pos: usize, +} + +impl Prev { + fn set(&mut self, byte: u8, pos: usize) { + *self = Self { byte, pos }; + } +} + fn lex_comments(source: &str) -> Lexed { let mut out = Lexed::default(); - // The last non-trivia byte lexed; `/` after an operand-ish byte is - // division, anywhere else it can open a regex (the `}` case guesses - // "expression", so a `}` closing a block is a known miss). - let mut last = b';'; + // `/` after an operand-ish byte is division, anywhere else it can open + // a regex (the `}` case guesses "expression", so a `}` closing a block + // is a known miss). + let mut prev = Prev { byte: b';', pos: 0 }; let bytes = source.as_bytes(); let mut i = 0; while i < bytes.len() { let b = bytes[i]; match b { b'\'' | b'"' => { - last = b'"'; + prev.set(b'"', i); i = str_end(source, i); } b'`' => { - last = b'"'; - i = template_end(source, i, &mut out, &mut last); + prev.set(b'"', i); + i = template_end(source, i, &mut out, &mut prev); } - b'/' => i = slash_step(source, i, &mut out, &mut last), + b'/' => i = slash_step(source, i, &mut out, &mut prev), _ => { if b < 128 && !is_space(b as char) { // Any non-whitespace ASCII byte counts as operand-ish. - last = b; + prev.set(b, i); } else if b >= 128 { - last = b'"'; + prev.set(b'"', i); } i += 1; } @@ -1920,17 +1940,19 @@ fn str_end(code: &str, open: usize) -> usize { /// Skip a template literal starting at `open`: the index past its closing /// backtick, or the text's end. The text is trivia-less, but its `${ }` /// holes are lexed as code by [`hole_end`]. -fn template_end(code: &str, open: usize, out: &mut Lexed, last: &mut u8) -> usize { +fn template_end(code: &str, open: usize, out: &mut Lexed, prev: &mut Prev) -> usize { let bytes = code.as_bytes(); let mut i = open + 1; while i < bytes.len() { match bytes[i] { b'\\' => i += 1, b'`' => return i + 1, - b'$' if bytes.get(i + 1) == Some(&b'{') => i = hole_end(code, i + 2, out, last), - _ => {} + // `hole_end` already returns past the `}` — resume there, or + // the closing backtick (`` `${string}` ``) is skipped and the + // template swallows the rest of the file. + b'$' if bytes.get(i + 1) == Some(&b'{') => i = hole_end(code, i + 2, out, prev), + _ => i += 1, } - i += 1; } bytes.len() } @@ -1938,7 +1960,7 @@ fn template_end(code: &str, open: usize, out: &mut Lexed, last: &mut u8) -> usiz /// Lex a `${ }` hole's contents from `pos` to past the `}`: a `{`/`}` depth /// tracker, so `{ T: U }` members inside don't end the hole. Runs to the /// text's end when the hole never closes. -fn hole_end(code: &str, pos: usize, out: &mut Lexed, last: &mut u8) -> usize { +fn hole_end(code: &str, pos: usize, out: &mut Lexed, prev: &mut Prev) -> usize { let bytes = code.as_bytes(); let mut i = pos; let mut depth = 1usize; @@ -1946,49 +1968,71 @@ fn hole_end(code: &str, pos: usize, out: &mut Lexed, last: &mut u8) -> usize { match bytes[i] { b'{' => { depth += 1; + prev.set(b'{', i); i += 1; } b'}' => { depth -= 1; + prev.set(b'}', i); i += 1; if depth == 0 { return i; } } - b'/' => i = slash_step(code, i, out, last), - _ => i = lex_byte(code, i, out, last), + b'/' => i = slash_step(code, i, out, prev), + _ => i = lex_byte(code, i, out, prev), } } i } /// One non-`/`, non-`{}` byte inside a `${ }` hole. -fn lex_byte(code: &str, i: usize, out: &mut Lexed, last: &mut u8) -> usize { +fn lex_byte(code: &str, i: usize, out: &mut Lexed, prev: &mut Prev) -> usize { let b = code.as_bytes()[i]; match b { b'\'' | b'"' => { - *last = b'"'; + prev.set(b'"', i); str_end(code, i) } b'`' => { - *last = b'"'; - template_end(code, i, out, last) + prev.set(b'"', i); + template_end(code, i, out, prev) } _ => { if b < 128 && !is_space(b as char) { - *last = b; + prev.set(b, i); } else if b >= 128 { - *last = b'"'; + prev.set(b'"', i); } i + 1 } } } +/// Keywords that start an expression: a `/` right after one opens a regex, +/// which the last-byte-alone heuristic would call division (`return /x/`). +const REGEX_CONTEXT_WORDS: &[&str] = &[ + "return", + "throw", + "case", + "typeof", + "instanceof", + "new", + "delete", + "void", + "do", + "else", + "yield", + "await", + "default", + "in", + "of", +]; + /// One `/` at `i`: `//` and `/* */` go into the comment table; a regex /// literal is skipped so its `*/`/`//`-looking bytes stay literal text; /// anything else is division and the byte advances one. -fn slash_step(code: &str, i: usize, out: &mut Lexed, last: &mut u8) -> usize { +fn slash_step(code: &str, i: usize, out: &mut Lexed, prev: &mut Prev) -> usize { let bytes = code.as_bytes(); if bytes.get(i + 1) == Some(&b'/') { let end = code[i + 2..].find(is_line_break).map(|e| i + 2 + e).unwrap_or(bytes.len()); @@ -1999,18 +2043,29 @@ fn slash_step(code: &str, i: usize, out: &mut Lexed, last: &mut u8) -> usize { out.block.push((i, end)); end } else { - let next = if regex_can_start(*last) { regex_end(code, i) } else { None }; - *last = b'/'; + let next = if regex_can_start(code, prev) { regex_end(code, i) } else { None }; + prev.set(b'/', i); next.unwrap_or(i + 1) } } /// Whether `/` can open a regex after `prev`, the last non-trivia byte. -/// False after operands (identifier bytes, `)`, `]`, `}`, quotes), true -/// after operators and at the start. -fn regex_can_start(prev: u8) -> bool { - !matches!(prev, b')' | b']' | b'}' | b'_' | b'$' | b'\'' | b'"' | b'`' | b'/') - && !prev.is_ascii_alphanumeric() +/// False after operands (identifier bytes, `)`, `]`, `}`, quotes) — unless +/// the identifier is a keyword that starts an expression — true after +/// operators and at the start. +fn regex_can_start(code: &str, prev: &Prev) -> bool { + match prev.byte { + b')' | b']' | b'}' | b'_' | b'$' | b'\'' | b'"' | b'`' | b'/' => false, + b if b.is_ascii_alphanumeric() => { + // The identifier `prev` ends: a keyword takes a regex after it. + let word = &code[..prev.pos + 1]; + let start = word + .rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$')) + .map_or(0, |p| p + 1); + REGEX_CONTEXT_WORDS.contains(&&word[start..]) + } + _ => true, + } } /// The index past a regex literal opening at `open`, or `None` when it is diff --git a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs index 0a0f69473..cf8f4da30 100644 --- a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs +++ b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs @@ -23,6 +23,8 @@ export const val = {a: 1}; export const kc = 'kc'; export function gen(x: T) { return x; } const re = /[/*]/; +function f() { return /[/*]/; } +const tpl = `${re}`; @Directive({selector: '[t]'}) export class T { @Input({transform: (v: %TYPE%) => 1}) t: any; @@ -117,8 +119,15 @@ const COMMENTS_KEPT: &[(&str, &str)] = &[ ("`${string}x\n`", "`${string}x\n`"), // A `//` on a `/*`'s continuation line is comment text. ("string | /* a\n // b */\n number", "string | /* a\n // b */ number"), - // The `/*` inside the file's `/[/*]/` regex doesn't pair with this `*/`. + // The `/*` inside the file's `/[/*]/` regexes (after `=` and after the + // `return` keyword) doesn't pair with this `*/`. ("string | /* a\n b */ number", "string | /* a\n b */ number"), + // A template ending in a hole (`${re}` in the file) doesn't hide later + // comments. + ("string | /* c */ number", "string | /* c */ number"), + ("[`${string}`, /** x */ number]", "[`${string}`, /** x */ number]"), + // `isWhiteSpaceSingleLine` covers U+200B: the JSDoc stays `number`'s. + ("string | /** doc */\u{200B} number", "string | /** doc */ number"), // An elision is zero-width right after its preceding comma: comments // before the comma stay with the previous element, comments after it // are the elision's intervening trivia. From ac69e011db5bc55bc8225f8f23adf3063769a5bd Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 04:05:59 +0800 Subject: [PATCH 08/10] fix(dts): build the comment table from the parser's own comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hand-rolled lexer was chasing lexing edge cases (`\`` escapes, regexes after `return`, regexes as statement bodies — `if (ok) /re/.test(x)`) and re-lexed the whole file for every printed type. `program.comments` already has the file's comment ranges — lexed correctly once — so `Lexed` is now built from it and shared across a class's printers via `Rc`, deleting the lexer (and its regex/keyword/prev-token machinery) outright. --- .../src/component/transform.rs | 2 + .../src/directive/dts_type.rs | 278 +++--------------- .../src/directive/evaluator.rs | 4 +- 3 files changed, 42 insertions(+), 242 deletions(-) diff --git a/crates/oxc_angular_compiler/src/component/transform.rs b/crates/oxc_angular_compiler/src/component/transform.rs index 934427735..8637eee4a 100644 --- a/crates/oxc_angular_compiler/src/component/transform.rs +++ b/crates/oxc_angular_compiler/src/component/transform.rs @@ -3725,6 +3725,7 @@ pub fn transform_angular_file( class, &string_consts, source, + &parser_ret.program.comments, dts_core_namespace.as_str(), ); result.dts_declarations.push(dts::generate_component_dts( @@ -3875,6 +3876,7 @@ pub fn transform_angular_file( class, &string_consts, source, + &parser_ret.program.comments, dts_core_namespace.as_str(), ); result.dts_declarations.push(dts::generate_directive_dts( diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs index e8bdfbc98..ce0092a69 100644 --- a/crates/oxc_angular_compiler/src/directive/dts_type.rs +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -33,12 +33,13 @@ //! line, and the next write indents it. use oxc_ast::ast::{ - BigIntLiteral, BindingPattern, BindingProperty, BindingRestElement, Expression, - FormalParameter, FormalParameterRest, FormalParameters, PropertyKey, StringLiteral, - TSIndexSignatureName, TSLiteral, TSMappedTypeModifierOperator, TSMethodSignatureKind, - TSNamedTupleMember, TSSignature, TSThisParameter, TSTupleElement, TSType, TSTypeAnnotation, - TSTypeName, TSTypeOperatorOperator, TSTypeParameter, TSTypeParameterDeclaration, - TSTypeParameterInstantiation, TSTypePredicateName, TSTypeQueryExprName, UnaryOperator, + BigIntLiteral, BindingPattern, BindingProperty, BindingRestElement, Comment as OxcComment, + CommentKind, Expression, FormalParameter, FormalParameterRest, FormalParameters, PropertyKey, + StringLiteral, TSIndexSignatureName, TSLiteral, TSMappedTypeModifierOperator, + TSMethodSignatureKind, TSNamedTupleMember, TSSignature, TSThisParameter, TSTupleElement, + TSType, TSTypeAnnotation, TSTypeName, TSTypeOperatorOperator, TSTypeParameter, + TSTypeParameterDeclaration, TSTypeParameterInstantiation, TSTypePredicateName, + TSTypeQueryExprName, UnaryOperator, }; use oxc_span::{GetSpan, Span}; @@ -124,9 +125,10 @@ pub(crate) struct TypePrinter<'s, 'a> { /// members sit at one level, and a `ListFormat.Indented` list adds one /// more while its items print. pub indent: u32, - /// The source's comment table, lexed on first use: the backward trivia - /// scan queries it instead of reparsing the whole prefix per node. - pub(crate) lexed: std::cell::OnceCell, + /// The source's comment table, shared across the printers of one + /// class: the backward trivia scan queries it instead of reparsing the + /// whole prefix per node. + pub(crate) lexed: std::rc::Rc, } /// A list element for [`TypePrinter::emit_list`]: a node with a span plus the @@ -194,12 +196,8 @@ impl<'a> TypePrinter<'_, 'a> { /// A node's `pos` (start of its leading trivia): the end of the previous /// token. - fn lexed(&self) -> &Lexed { - self.lexed.get_or_init(|| lex_comments(self.source)) - } - fn pos_of(&self, start: u32) -> u32 { - token_end_before(self.lexed(), self.source, start as usize).unwrap_or(start as usize) as u32 + token_end_before(&self.lexed, self.source, start as usize).unwrap_or(start as usize) as u32 } /// Emit a node like TypeScript's `emit(node)`: its leading comments, the @@ -1078,7 +1076,7 @@ impl<'a> TypePrinter<'_, 'a> { ) -> Option<()> { let name_start = skip_trivia(self.source, param.span.start as usize); let name_end = - token_end_before(self.lexed(), self.source, param.type_annotation.span.start as usize) + token_end_before(&self.lexed, self.source, param.type_annotation.span.start as usize) .unwrap_or(param.type_annotation.span.start as usize); self.emit_node(out, self.pos_of(name_start as u32), name_end as u32, |s, o| { s.write(o, param.name.as_str()); @@ -1228,10 +1226,9 @@ impl<'a> TypePrinter<'_, 'a> { if computed { // `ComputedPropertyName`'s range: from the trivia before `[` to // `]`'s end. oxc's key span covers the expression only. - let bracket_end = - token_end_before(self.lexed(), self.source, key.span().start as usize) - .unwrap_or(key.span().start as usize); - let pos = token_end_before(self.lexed(), self.source, bracket_end - 1) + let bracket_end = token_end_before(&self.lexed, self.source, key.span().start as usize) + .unwrap_or(key.span().start as usize); + let pos = token_end_before(&self.lexed, self.source, bracket_end - 1) .unwrap_or(bracket_end - 1); let end = skip_trivia(self.source, key.span().end as usize) + 1; let text = self.key_text(key)?; @@ -1857,236 +1854,36 @@ fn token_end_before(lexed: &Lexed, source: &str, start: usize) -> Option } } -/// The source's comments, once: sorted `(start, end)` ranges of every `//` -/// and `/* */` — `end` of a `//` is the next line break (or the file's end), -/// of `/* */` past the closer. Lexing once keeps the backward trivia scan -/// from reparsing the whole prefix for every node, and skipping strings, -/// template text, and regexes keeps their bytes from pairing as comments — -/// a `/[/*]/` earlier in the file would otherwise swallow a real `/*` in -/// the type. `${ }` holes lex as code (the comments inside them count) and -/// nested templates recurse. +/// The source's comments: sorted `(start, end)` ranges of every `//` and +/// `/* */`, with delimiters — `end` of a `//` is the line break (or the +/// file's end), of `/* */` past the closer. Built from the parser's own +/// comment list, so strings, template text, and regex literals are already +/// skipped: a `/[/*]/` — after `=`, after `return`, after `)`, anywhere — +/// can't pair its `/*` with a comment's `*/` inside the type, and the +/// whole file is lexed once rather than re-scanned per `pos_of` call. #[derive(Default)] pub(crate) struct Lexed { + /// `//` comment ranges, sorted by start. line: Vec<(usize, usize)>, + /// `/* */` comment ranges, sorted by start. block: Vec<(usize, usize)>, } -/// The last non-trivia byte lexed and where it sits — `pos` lets -/// [`slash_step`] pull the word a trailing identifier belongs to, since -/// keywords like `return` also end in identifier bytes but a `/` after them -/// opens a regex. -struct Prev { - byte: u8, - pos: usize, -} - -impl Prev { - fn set(&mut self, byte: u8, pos: usize) { - *self = Self { byte, pos }; - } -} - -fn lex_comments(source: &str) -> Lexed { - let mut out = Lexed::default(); - // `/` after an operand-ish byte is division, anywhere else it can open - // a regex (the `}` case guesses "expression", so a `}` closing a block - // is a known miss). - let mut prev = Prev { byte: b';', pos: 0 }; - let bytes = source.as_bytes(); - let mut i = 0; - while i < bytes.len() { - let b = bytes[i]; - match b { - b'\'' | b'"' => { - prev.set(b'"', i); - i = str_end(source, i); +impl Lexed { + /// Builds the table from the parser's comment list for one source file. + pub(crate) fn from_comments(comments: &[OxcComment]) -> Self { + let mut out = Self::default(); + for c in comments { + let range = (c.span.start as usize, c.span.end as usize); + match c.kind { + CommentKind::Line => out.line.push(range), + _ => out.block.push(range), } - b'`' => { - prev.set(b'"', i); - i = template_end(source, i, &mut out, &mut prev); - } - b'/' => i = slash_step(source, i, &mut out, &mut prev), - _ => { - if b < 128 && !is_space(b as char) { - // Any non-whitespace ASCII byte counts as operand-ish. - prev.set(b, i); - } else if b >= 128 { - prev.set(b'"', i); - } - i += 1; - } - } - } - out -} - -/// Skip a `'`/`"` string starting at `open`: the index past its closer, or -/// the text's end when unterminated. -fn str_end(code: &str, open: usize) -> usize { - let bytes = code.as_bytes(); - let quote = bytes[open]; - let mut i = open + 1; - while i < bytes.len() { - if bytes[i] == b'\\' { - i += 1; - } else if bytes[i] == quote { - return i + 1; } - i += 1; + out.line.sort_unstable(); + out.block.sort_unstable(); + out } - bytes.len() -} - -/// Skip a template literal starting at `open`: the index past its closing -/// backtick, or the text's end. The text is trivia-less, but its `${ }` -/// holes are lexed as code by [`hole_end`]. -fn template_end(code: &str, open: usize, out: &mut Lexed, prev: &mut Prev) -> usize { - let bytes = code.as_bytes(); - let mut i = open + 1; - while i < bytes.len() { - match bytes[i] { - b'\\' => i += 1, - b'`' => return i + 1, - // `hole_end` already returns past the `}` — resume there, or - // the closing backtick (`` `${string}` ``) is skipped and the - // template swallows the rest of the file. - b'$' if bytes.get(i + 1) == Some(&b'{') => i = hole_end(code, i + 2, out, prev), - _ => i += 1, - } - } - bytes.len() -} - -/// Lex a `${ }` hole's contents from `pos` to past the `}`: a `{`/`}` depth -/// tracker, so `{ T: U }` members inside don't end the hole. Runs to the -/// text's end when the hole never closes. -fn hole_end(code: &str, pos: usize, out: &mut Lexed, prev: &mut Prev) -> usize { - let bytes = code.as_bytes(); - let mut i = pos; - let mut depth = 1usize; - while i < bytes.len() { - match bytes[i] { - b'{' => { - depth += 1; - prev.set(b'{', i); - i += 1; - } - b'}' => { - depth -= 1; - prev.set(b'}', i); - i += 1; - if depth == 0 { - return i; - } - } - b'/' => i = slash_step(code, i, out, prev), - _ => i = lex_byte(code, i, out, prev), - } - } - i -} - -/// One non-`/`, non-`{}` byte inside a `${ }` hole. -fn lex_byte(code: &str, i: usize, out: &mut Lexed, prev: &mut Prev) -> usize { - let b = code.as_bytes()[i]; - match b { - b'\'' | b'"' => { - prev.set(b'"', i); - str_end(code, i) - } - b'`' => { - prev.set(b'"', i); - template_end(code, i, out, prev) - } - _ => { - if b < 128 && !is_space(b as char) { - prev.set(b, i); - } else if b >= 128 { - prev.set(b'"', i); - } - i + 1 - } - } -} - -/// Keywords that start an expression: a `/` right after one opens a regex, -/// which the last-byte-alone heuristic would call division (`return /x/`). -const REGEX_CONTEXT_WORDS: &[&str] = &[ - "return", - "throw", - "case", - "typeof", - "instanceof", - "new", - "delete", - "void", - "do", - "else", - "yield", - "await", - "default", - "in", - "of", -]; - -/// One `/` at `i`: `//` and `/* */` go into the comment table; a regex -/// literal is skipped so its `*/`/`//`-looking bytes stay literal text; -/// anything else is division and the byte advances one. -fn slash_step(code: &str, i: usize, out: &mut Lexed, prev: &mut Prev) -> usize { - let bytes = code.as_bytes(); - if bytes.get(i + 1) == Some(&b'/') { - let end = code[i + 2..].find(is_line_break).map(|e| i + 2 + e).unwrap_or(bytes.len()); - out.line.push((i, end)); - end - } else if bytes.get(i + 1) == Some(&b'*') { - let end = code[i + 2..].find("*/").map(|e| i + 2 + e + 2).unwrap_or(bytes.len()); - out.block.push((i, end)); - end - } else { - let next = if regex_can_start(code, prev) { regex_end(code, i) } else { None }; - prev.set(b'/', i); - next.unwrap_or(i + 1) - } -} - -/// Whether `/` can open a regex after `prev`, the last non-trivia byte. -/// False after operands (identifier bytes, `)`, `]`, `}`, quotes) — unless -/// the identifier is a keyword that starts an expression — true after -/// operators and at the start. -fn regex_can_start(code: &str, prev: &Prev) -> bool { - match prev.byte { - b')' | b']' | b'}' | b'_' | b'$' | b'\'' | b'"' | b'`' | b'/' => false, - b if b.is_ascii_alphanumeric() => { - // The identifier `prev` ends: a keyword takes a regex after it. - let word = &code[..prev.pos + 1]; - let start = word - .rfind(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '$')) - .map_or(0, |p| p + 1); - REGEX_CONTEXT_WORDS.contains(&&word[start..]) - } - _ => true, - } -} - -/// The index past a regex literal opening at `open`, or `None` when it is -/// really division — no closing `/` before a line break (classes can hold -/// `//` and `*/`, escapes can hold `]` and `/`). -fn regex_end(code: &str, open: usize) -> Option { - let bytes = code.as_bytes(); - let mut i = open + 1; - let mut class = false; - while i < bytes.len() { - match bytes[i] { - b'\\' => i += 1, - b'[' => class = true, - b']' => class = false, - b'/' if !class => return Some(i + 1), - b'\n' | b'\r' => return None, - _ => {} - } - i += 1; - } - None } /// The `//` opening a line comment that runs to the end of `code` — so it @@ -2109,7 +1906,6 @@ fn block_comment_start(lexed: &Lexed, inner: &str) -> Option { .find(|(start, end)| *start < inner.len() && *end > inner.len()) .map(|(start, _)| *start) } - /// A comment's source range, as TypeScript's `iterateCommentRanges` reports /// it. struct Comment { diff --git a/crates/oxc_angular_compiler/src/directive/evaluator.rs b/crates/oxc_angular_compiler/src/directive/evaluator.rs index 0bde6a403..b4e27834a 100644 --- a/crates/oxc_angular_compiler/src/directive/evaluator.rs +++ b/crates/oxc_angular_compiler/src/directive/evaluator.rs @@ -3504,6 +3504,7 @@ pub fn input_transform_types<'a>( class: &'a Class<'a>, consts: &super::StringConsts<'a>, source: &'a str, + comments: &'a [oxc_ast::ast::Comment], core_namespace: &'a str, ) -> HashMap { let evaluator = Evaluator::new(consts); @@ -3557,6 +3558,7 @@ pub fn input_transform_types<'a>( } } + let lexed = std::rc::Rc::new(super::dts_type::Lexed::from_comments(comments)); transforms .into_iter() .filter_map(|(name, transform)| { @@ -3574,7 +3576,7 @@ pub fn input_transform_types<'a>( container_pos: u32::MAX, container_end: u32::MAX, indent: 1, - lexed: Default::default(), + lexed: lexed.clone(), }; let ty = printer.print(ty); if printer.other_module { From c7433f21cc44b9221075e0cb7482a67b16890b3e Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 04:11:53 +0800 Subject: [PATCH 09/10] fix(dts): binary-search the comment table in the trivia scan MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sorted, disjoint comment ranges admit a partition_point lookup: the only candidate covering a window end is the last range starting before it — O(log n) instead of a linear scan per pos_of call. --- .../src/directive/dts_type.rs | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs index ce0092a69..96138e779 100644 --- a/crates/oxc_angular_compiler/src/directive/dts_type.rs +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -1887,24 +1887,27 @@ impl Lexed { } /// The `//` opening a line comment that runs to the end of `code` — so it -/// sits on the last line and bounds the trivia — or `None`. +/// sits on the last line and bounds the trivia — or `None`. The ranges are +/// sorted and disjoint, so the only `//` that can cover `code.len()` is the +/// last one starting before it. fn line_comment_start(lexed: &Lexed, code: &str) -> Option { - lexed - .line - .iter() - .find(|(start, end)| *start < code.len() && *end >= code.len()) - .map(|(start, _)| *start) + let i = lexed.line.partition_point(|(start, _)| *start < code.len()); + match lexed.line.get(i.wrapping_sub(1)) { + Some(&(start, end)) if i > 0 && end >= code.len() => Some(start), + _ => None, + } } /// The `/*` that opens the comment closed by the `*/` just cut off `inner`: -/// the first `/*` whose own `*/` isn't inside `inner` (a `/*` with one is a -/// comment of its own — or comment text — and is skipped whole). +/// the `/*` whose own `*/` isn't inside `inner` (a `/*` with one is a +/// comment of its own — or comment text — and is skipped whole). Same +/// binary-search shape as [`line_comment_start`]. fn block_comment_start(lexed: &Lexed, inner: &str) -> Option { - lexed - .block - .iter() - .find(|(start, end)| *start < inner.len() && *end > inner.len()) - .map(|(start, _)| *start) + let i = lexed.block.partition_point(|(start, _)| *start < inner.len()); + match lexed.block.get(i.wrapping_sub(1)) { + Some(&(start, end)) if i > 0 && end > inner.len() => Some(start), + _ => None, + } } /// A comment's source range, as TypeScript's `iterateCommentRanges` reports /// it. From d5e7aa48b39a11bae9c1acb63e74120da887afd9 Mon Sep 17 00:00:00 2001 From: LongYinan Date: Fri, 9 Oct 2026 04:19:31 +0800 Subject: [PATCH 10/10] fix(dts): signed mapped modifiers emit only the sign token `readonlyToken`/`questionToken` are composite: `emit` prints the sign and `writeKeyword`/`writePunctuation` prints `readonly`/`?` with no comment scan, so a comment after a line break between them is dropped upstream. Wrapping the keyword in emit_node preserved it. Same-line comments still print as the sign's own trailing comments. --- .../src/directive/dts_type.rs | 47 ++++++++++++------- .../tests/dts_input_transform_type_test.rs | 6 ++- 2 files changed, 35 insertions(+), 18 deletions(-) diff --git a/crates/oxc_angular_compiler/src/directive/dts_type.rs b/crates/oxc_angular_compiler/src/directive/dts_type.rs index 96138e779..1f0539bec 100644 --- a/crates/oxc_angular_compiler/src/directive/dts_type.rs +++ b/crates/oxc_angular_compiler/src/directive/dts_type.rs @@ -619,18 +619,23 @@ impl<'a> TypePrinter<'_, 'a> { Some(()) }, )?; - start = skip_trivia(self.source, start + 1); + // `writeKeyword("readonly")`: a plain write — the + // comments between sign and keyword are never + // scanned (same-line ones are already the sign's + // trailing comments). + self.write(out, "readonly"); + } else { + // `emit(node.readonlyToken)` + `writeSpace`. + self.emit_node( + out, + self.pos_of(start as u32), + (start + "readonly".len()) as u32, + |s, o| { + s.write(o, "readonly"); + Some(()) + }, + )?; } - // `emit(node.readonlyToken)` + `writeSpace`. - self.emit_node( - out, - self.pos_of(start as u32), - (start + "readonly".len()) as u32, - |s, o| { - s.write(o, "readonly"); - Some(()) - }, - )?; self.write(out, " "); } self.write(out, "["); @@ -674,13 +679,21 @@ impl<'a> TypePrinter<'_, 'a> { Some(()) }, )?; - start = skip_trivia(self.source, start + 1); + // `writePunctuation("?")`: a plain write — same + // reason as `readonly` above. + self.write(out, "?"); + } else { + // `emit(node.questionToken)`: the `?` after `]`. + self.emit_node( + out, + self.pos_of(start as u32), + start as u32 + 1, + |s, o| { + s.write(o, "?"); + Some(()) + }, + )?; } - // `emit(node.questionToken)`: the `?` after `]`. - self.emit_node(out, self.pos_of(start as u32), start as u32 + 1, |s, o| { - s.write(o, "?"); - Some(()) - })?; } self.write(out, ": "); if let Some(value) = &m.type_annotation { diff --git a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs index cf8f4da30..ecc039b42 100644 --- a/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs +++ b/crates/oxc_angular_compiler/tests/dts_input_transform_type_test.rs @@ -104,8 +104,12 @@ const COMMENTS_KEPT: &[(&str, &str)] = &[ ("Signal /** j */ ", "i0.Signal"), // `]` after a trailing comma scans from the list's end (past the comma). ("[a: string,\n /** j */]", "[a: string\n /** j */ ]"), - // Mapped-type `±readonly` and `±?` are two tokens each. + // Mapped-type `±readonly` and `±?` are two tokens each. The keyword is + // a plain write after a sign — a comment after a line break is never + // scanned. ("{ - /** j */ readonly [K in string]: 1 }", "{ - /** j */readonly [K in string]: 1; }"), + ("{ -\n/** j */\nreadonly [K in string]: 1 }", "{ -readonly [K in string]: 1; }"), + ("{ [K in string]-\n/** j */\n?: 1 }", "{ [K in string]-?: 1; }"), ("{ [K in string] - /** j */ ?: 1 }", "{ [K in string]- /** j */?: 1; }"), // The `...` of a binding rest and the `${` of a template literal are // emitted tokens too.