diff --git a/src/angular-parser.ts b/src/angular-parser.ts index b9a307c8..c9b22554 100644 --- a/src/angular-parser.ts +++ b/src/angular-parser.ts @@ -11,22 +11,32 @@ import { import { type CommentLine } from './types.ts'; import { sourceSpanToLocationInformation } from './utils.ts'; +let parseSourceSpan: ParseSourceSpan; // https://github.com/angular/angular/blob/5e9707dc84e6590ec8c9d41e7d3be7deb2fa7c53/packages/compiler/test/expression_parser/utils/span.ts -function getFakeSpan(fileName = 'test.html') { - const file = new ParseSourceFile('', fileName); - const location = new ParseLocation(file, 0, 0, 0); - return new ParseSourceSpan(location, location); +function getParseSourceSpan() { + if (!parseSourceSpan) { + const file = new ParseSourceFile('', 'test.html'); + const location = new ParseLocation(file, -1, -1, -1); + parseSourceSpan = new ParseSourceSpan(location, location); + } + + return parseSourceSpan; +} + +let parser: Parser; +function getParser() { + return (parser ??= new Parser(new Lexer())); } const getCommentStart = (text: string): number | null => // @ts-expect-error -- need to call private _commentStart Parser.prototype._commentStart(text); -function extractComments(text: string, shouldExtractComment: boolean) { - const commentStart = shouldExtractComment ? getCommentStart(text) : null; +function extractComments(text: string) { + const commentStart = getCommentStart(text); if (commentStart === null) { - return { text, comments: [] }; + return []; } const comment: CommentLine = { @@ -38,53 +48,48 @@ function extractComments(text: string, shouldExtractComment: boolean) { }), }; - return { text, comments: [comment] }; + return [comment]; } -function createAngularParseFunction< - T extends ASTWithSource | TemplateBindingParseResult, ->(parse: (text: string, parser: Parser) => T, shouldExtractComment = true) { - return (originalText: string) => { - const lexer = new Lexer(); - const parser = new Parser(lexer); - - const { text, comments } = extractComments( - originalText, - shouldExtractComment, +function throwErrors< + ResultType extends ASTWithSource | TemplateBindingParseResult, +>(result: ResultType) { + if (result.errors.length !== 0) { + const [{ message }] = result.errors; + throw new SyntaxError( + message.replace(/^Parser Error: | at column \d+ in [^]*$/g, ''), ); - const result = parse(text, parser); - - if (result.errors.length !== 0) { - const [{ message }] = result.errors; - throw new SyntaxError( - message.replace(/^Parser Error: | at column \d+ in [^]*$/g, ''), - ); - } + } - return { result, comments, text }; - }; + return result; } -export const parseBinding = createAngularParseFunction((text, parser) => - parser.parseBinding(text, getFakeSpan(), 0), +const createAstParser = + ( + name: + | 'parseBinding' + | 'parseSimpleBinding' + | 'parseAction' + | 'parseInterpolationExpression', + ) => + (text: string) => ({ + result: throwErrors( + getParser()[name](text, getParseSourceSpan(), 0), + ), + text, + comments: extractComments(text), + }); + +export const parseAction = createAstParser('parseAction'); +export const parseBinding = createAstParser('parseBinding'); +export const parseSimpleBinding = createAstParser('parseSimpleBinding'); +export const parseInterpolationExpression = createAstParser( + 'parseInterpolationExpression', ); - -export const parseSimpleBinding = createAngularParseFunction((text, parser) => - parser.parseSimpleBinding(text, getFakeSpan(), 0), -); - -export const parseAction = createAngularParseFunction((text, parser) => - parser.parseAction(text, getFakeSpan(), 0), -); - -export const parseInterpolationExpression = createAngularParseFunction( - (text, parser) => parser.parseInterpolationExpression(text, getFakeSpan(), 0), -); - -export const parseTemplateBindings = createAngularParseFunction( - (text, parser) => parser.parseTemplateBindings('', text, getFakeSpan(), 0, 0), - /* shouldExtractComment */ false, -); - -export type AstParseResult = ReturnType; -export type MicroSyntaxParseResult = ReturnType; +export const parseTemplateBindings = (text: string) => ({ + result: throwErrors( + getParser().parseTemplateBindings('', text, getParseSourceSpan(), 0, 0), + ), + text, + comments: [], +}); diff --git a/src/ast-transform/index.ts b/src/ast-transform/index.ts new file mode 100644 index 00000000..c87784e0 --- /dev/null +++ b/src/ast-transform/index.ts @@ -0,0 +1 @@ +export { transformAst, transformAstNode } from './transform.ts'; diff --git a/src/ast-transform/transform-array-expression.ts b/src/ast-transform/transform-array-expression.ts new file mode 100644 index 00000000..ead723ad --- /dev/null +++ b/src/ast-transform/transform-array-expression.ts @@ -0,0 +1,12 @@ +import { type LiteralArray } from '@angular/compiler'; +import type * as babel from '@babel/types'; + +import { type Transformer } from './transform.ts'; + +export const visitLiteralArray = ( + node: LiteralArray, + transformer: Transformer, +): babel.ArrayExpression => ({ + type: 'ArrayExpression', + elements: transformer.transformChildren(node.expressions), +}); diff --git a/src/ast-transform/transform-ast-with-source.ts b/src/ast-transform/transform-ast-with-source.ts new file mode 100644 index 00000000..d91e7909 --- /dev/null +++ b/src/ast-transform/transform-ast-with-source.ts @@ -0,0 +1,8 @@ +import { type ASTWithSource } from '@angular/compiler'; + +import { type Transformer } from './transform.ts'; + +export const visitASTWithSource = ( + node: ASTWithSource, + transformer: Transformer, +) => transformer.transformChild(node.ast); diff --git a/src/ast-transform/transform-binary-expression.ts b/src/ast-transform/transform-binary-expression.ts new file mode 100644 index 00000000..d67200e4 --- /dev/null +++ b/src/ast-transform/transform-binary-expression.ts @@ -0,0 +1,48 @@ +import { Binary } from '@angular/compiler'; +import type * as babel from '@babel/types'; + +import { type Transformer } from './transform.ts'; + +const isAssignmentOperator = ( + operator: Binary['operation'], +): operator is babel.AssignmentExpression['operator'] => + Binary.isAssignmentOperation(operator); + +const isLogicalOperator = ( + operator: Binary['operation'], +): operator is babel.LogicalExpression['operator'] => + operator === '&&' || operator === '||' || operator === '??'; + +export const visitBinary = ( + node: Binary, + transformer: Transformer, +): + | babel.LogicalExpression + | babel.AssignmentExpression + | babel.BinaryExpression => { + const { operation: operator } = node; + const [left, right] = transformer.transformChildren([ + node.left, + node.right, + ]); + + if (isLogicalOperator(operator)) { + return { type: 'LogicalExpression', operator, left, right }; + } + + if (isAssignmentOperator(operator)) { + return { + type: 'AssignmentExpression', + left: left as babel.MemberExpression, + right, + operator: operator, + }; + } + + return { + left, + right, + type: 'BinaryExpression', + operator: operator as babel.BinaryExpression['operator'], + }; +}; diff --git a/src/ast-transform/transform-call-expression.ts b/src/ast-transform/transform-call-expression.ts new file mode 100644 index 00000000..b7e18924 --- /dev/null +++ b/src/ast-transform/transform-call-expression.ts @@ -0,0 +1,45 @@ +import { type Call, type SafeCall } from '@angular/compiler'; +import type * as babel from '@babel/types'; + +import { type Transformer } from './transform.ts'; +import { isOptionalObjectOrCallee } from './utilities.ts'; + +const callOptions = { optional: false } as const; +const safeCallOptions = { optional: true } as const; + +type VisitorCall = { + node: Call; + options: typeof callOptions; +}; +type VisitorSafeCall = { + node: SafeCall; + options: typeof safeCallOptions; +}; + +const transformCall = + ({ + optional, + }: Visitor['options']) => + ( + node: Visitor['node'], + transformer: Transformer, + ): babel.CallExpression | babel.OptionalCallExpression => { + const arguments_ = transformer.transformChildren( + node.args, + ); + const callee = transformer.transformChild(node.receiver); + + if (optional || isOptionalObjectOrCallee(callee)) { + return { + type: 'OptionalCallExpression', + callee, + arguments: arguments_, + optional, + }; + } + + return { type: 'CallExpression', callee, arguments: arguments_ }; + }; + +export const visitCall = transformCall(callOptions); +export const visitSafeCall = transformCall(safeCallOptions); diff --git a/src/ast-transform/transform-chained-expression.ts b/src/ast-transform/transform-chained-expression.ts new file mode 100644 index 00000000..152404b3 --- /dev/null +++ b/src/ast-transform/transform-chained-expression.ts @@ -0,0 +1,15 @@ +import { type Chain } from '@angular/compiler'; +import type * as babel from '@babel/types'; + +import type { NGChainedExpression } from '../types.ts'; +import { type Transformer } from './transform.ts'; + +export const visitChain = ( + node: Chain, + transformer: Transformer, +): Omit => ({ + type: 'NGChainedExpression', + expressions: transformer.transformChildren( + node.expressions, + ), +}); diff --git a/src/ast-transform/transform-conditional-expression.ts b/src/ast-transform/transform-conditional-expression.ts new file mode 100644 index 00000000..215d5454 --- /dev/null +++ b/src/ast-transform/transform-conditional-expression.ts @@ -0,0 +1,23 @@ +import { type Conditional } from '@angular/compiler'; +import type * as babel from '@babel/types'; + +import { type Transformer } from './transform.ts'; + +export const visitConditional = ( + node: Conditional, + transformer: Transformer, +): babel.ConditionalExpression => { + const [test, consequent, alternate] = + transformer.transformChildren([ + node.condition, + node.trueExp, + node.falseExp, + ]); + + return { + type: 'ConditionalExpression', + test, + consequent, + alternate, + }; +}; diff --git a/src/ast-transform/transform-interpolation.ts b/src/ast-transform/transform-interpolation.ts new file mode 100644 index 00000000..526d4ac4 --- /dev/null +++ b/src/ast-transform/transform-interpolation.ts @@ -0,0 +1,17 @@ +import { type Interpolation } from '@angular/compiler'; + +import { type Transformer } from './transform.ts'; + +export const visitInterpolation = ( + node: Interpolation, + transformer: Transformer, +) => { + const { expressions } = node; + + /* c8 ignore next 3 @preserve */ + if (expressions.length !== 1) { + throw new Error("Unexpected 'Interpolation'"); + } + + return transformer.transformChild(expressions[0]); +}; diff --git a/src/ast-transform/transform-literal.ts b/src/ast-transform/transform-literal.ts new file mode 100644 index 00000000..e5817afa --- /dev/null +++ b/src/ast-transform/transform-literal.ts @@ -0,0 +1,41 @@ +import { + type LiteralPrimitive, + type RegularExpressionLiteral, +} from '@angular/compiler'; +import type * as babel from '@babel/types'; + +export const visitLiteralPrimitive = ( + node: LiteralPrimitive, +): + | babel.BooleanLiteral + | babel.NumericLiteral + | babel.NullLiteral + | babel.StringLiteral + | babel.Identifier => { + const { value } = node; + switch (typeof value) { + case 'boolean': + return { type: 'BooleanLiteral', value }; + case 'number': + return { type: 'NumericLiteral', value }; + case 'object': + return { type: 'NullLiteral' }; + case 'string': + return { type: 'StringLiteral', value }; + case 'undefined': + return { type: 'Identifier', name: 'undefined' }; + /* c8 ignore next 4 */ + default: + throw new Error( + `Unexpected 'LiteralPrimitive' value type ${typeof value}`, + ); + } +}; + +export const visitRegularExpressionLiteral = ( + node: RegularExpressionLiteral, +): babel.RegExpLiteral => ({ + type: 'RegExpLiteral', + pattern: node.body, + flags: node.flags ?? '', +}); diff --git a/src/ast-transform/transform-member-expression.ts b/src/ast-transform/transform-member-expression.ts new file mode 100644 index 00000000..a2ecf01e --- /dev/null +++ b/src/ast-transform/transform-member-expression.ts @@ -0,0 +1,106 @@ +import { + ImplicitReceiver, + type KeyedRead, + type PropertyRead, + type SafeKeyedRead, + type SafePropertyRead, +} from '@angular/compiler'; +import type * as babel from '@babel/types'; + +import { type Transformer } from './transform.ts'; +import { isOptionalObjectOrCallee } from './utilities.ts'; + +const keyedReadOptions = { computed: true, optional: false } as const; +const safeKeyedReadOptions = { computed: true, optional: true } as const; +const propertyReadOptions = { computed: false, optional: false } as const; +const safePropertyReadOptions = { computed: false, optional: true } as const; + +type VisitorKeyedRead = { + node: KeyedRead; + options: typeof keyedReadOptions; +}; +type VisitorSafeKeyedRead = { + node: SafeKeyedRead; + options: typeof safeKeyedReadOptions; +}; +type VisitorPropertyRead = { + node: PropertyRead; + options: typeof propertyReadOptions; +}; +type VisitorSafePropertyRead = { + node: SafePropertyRead; + options: typeof safePropertyReadOptions; +}; + +const transformMemberExpression = + < + Visitor extends + | VisitorKeyedRead + | VisitorSafeKeyedRead + | VisitorPropertyRead + | VisitorSafePropertyRead, + >({ + computed, + optional, + }: Visitor['options']) => + ( + node: Visitor['node'], + transformer: Transformer, + ): + | babel.OptionalMemberExpression + | babel.MemberExpression + | babel.Identifier => { + const { receiver } = node; + + let property; + if (computed) { + const { key } = node as KeyedRead | SafeKeyedRead; + property = transformer.transformChild(key); + } else { + const isImplicitReceiver = receiver instanceof ImplicitReceiver; + const { name, nameSpan } = node as PropertyRead | SafePropertyRead; + property = transformer.createNode( + { type: 'Identifier', name: name }, + nameSpan, + isImplicitReceiver ? transformer.ancestors : [], + ); + + if (isImplicitReceiver) { + return property; + } + } + + const object = transformer.transformChild(receiver); + + const isOptionalObject = isOptionalObjectOrCallee(object); + + if (optional || isOptionalObject) { + return { + type: 'OptionalMemberExpression', + optional: optional || !isOptionalObject, + computed, + property, + object, + }; + } + + if (computed) { + return { type: 'MemberExpression', property, object, computed: true }; + } + + return { + type: 'MemberExpression', + object, + property: property as babel.MemberExpressionNonComputed['property'], + computed: false, + }; + }; + +export const visitKeyedRead = + transformMemberExpression(keyedReadOptions); +export const visitSafeKeyedRead = + transformMemberExpression(safeKeyedReadOptions); +export const visitPropertyRead = + transformMemberExpression(propertyReadOptions); +export const visitSafePropertyRead = + transformMemberExpression(safePropertyReadOptions); diff --git a/src/ast-transform/transform-non-null-expression.ts b/src/ast-transform/transform-non-null-expression.ts new file mode 100644 index 00000000..6bb53f0d --- /dev/null +++ b/src/ast-transform/transform-non-null-expression.ts @@ -0,0 +1,12 @@ +import { type NonNullAssert } from '@angular/compiler'; +import type * as babel from '@babel/types'; + +import { type Transformer } from './transform.ts'; + +export const visitNonNullAssert = ( + node: NonNullAssert, + transformer: Transformer, +): babel.TSNonNullExpression => ({ + type: 'TSNonNullExpression', + expression: transformer.transformChild(node.expression), +}); diff --git a/src/ast-transform/transform-object-expression.ts b/src/ast-transform/transform-object-expression.ts new file mode 100644 index 00000000..db084a35 --- /dev/null +++ b/src/ast-transform/transform-object-expression.ts @@ -0,0 +1,44 @@ +import type * as angular from '@angular/compiler'; +import type * as babel from '@babel/types'; + +import type { NGNode, RawNGSpan } from '../types.ts'; +import { type Transformer } from './transform.ts'; + +export const visitLiteralMap = ( + node: angular.LiteralMap, + transformer: Transformer, +): babel.ObjectExpression => { + const { keys, values } = node; + const createChild = ( + properties: Partial & { type: T['type'] }, + location: angular.AST | RawNGSpan | [number, number] = node, + ) => + transformer.create(properties, location, [node, ...transformer.ancestors]); + + return { + type: 'ObjectExpression', + properties: keys.map((keyNode, index) => { + const valueNode = values[index]; + const shorthand = Boolean(keyNode.isShorthandInitialized); + const key = createChild( + keyNode.quoted + ? { type: 'StringLiteral', value: keyNode.key } + : { type: 'Identifier', name: keyNode.key }, + keyNode.sourceSpan, + ); + + return createChild( + { + type: 'ObjectProperty', + key, + value: transformer.transformChild(valueNode), + shorthand, + computed: false, + // @ts-expect-error -- Missed in types + method: false, + }, + [keyNode.sourceSpan.start, valueNode.sourceSpan.end], + ); + }), + }; +}; diff --git a/src/ast-transform/transform-parenthesized-expression.ts b/src/ast-transform/transform-parenthesized-expression.ts new file mode 100644 index 00000000..adea04b2 --- /dev/null +++ b/src/ast-transform/transform-parenthesized-expression.ts @@ -0,0 +1,8 @@ +import { type ParenthesizedExpression } from '@angular/compiler'; + +import { type Transformer } from './transform.ts'; + +export const visitParenthesizedExpression = ( + node: ParenthesizedExpression, + transformer: Transformer, +) => transformer.transformChild(node.expression); diff --git a/src/ast-transform/transform-pipe-expression.ts b/src/ast-transform/transform-pipe-expression.ts new file mode 100644 index 00000000..0ea63d0a --- /dev/null +++ b/src/ast-transform/transform-pipe-expression.ts @@ -0,0 +1,18 @@ +import { type BindingPipe } from '@angular/compiler'; +import type * as babel from '@babel/types'; + +import type { NGPipeExpression } from '../types.ts'; +import { type Transformer } from './transform.ts'; + +export const visitPipe = ( + node: BindingPipe, + transformer: Transformer, +): Omit => ({ + type: 'NGPipeExpression', + left: transformer.transformChild(node.exp), + right: transformer.createNode( + { type: 'Identifier', name: node.name }, + node.nameSpan, + ), + arguments: transformer.transformChildren(node.args), +}); diff --git a/src/ast-transform/transform-template-literal.ts b/src/ast-transform/transform-template-literal.ts new file mode 100644 index 00000000..dcdbeca4 --- /dev/null +++ b/src/ast-transform/transform-template-literal.ts @@ -0,0 +1,50 @@ +import { + type TaggedTemplateLiteral, + type TemplateLiteral, + type TemplateLiteralElement, +} from '@angular/compiler'; +import type * as babel from '@babel/types'; + +import { type Transformer } from './transform.ts'; + +export const visitTaggedTemplateLiteral = ( + node: TaggedTemplateLiteral, + transformer: Transformer, +) => + transformer.createNode({ + type: 'TaggedTemplateExpression', + tag: transformer.transformChild(node.tag), + quasi: transformer.transformChild(node.template), + }); + +export const visitTemplateLiteral = ( + node: TemplateLiteral, + transformer: Transformer, +) => + transformer.createNode({ + type: 'TemplateLiteral', + quasis: transformer.transformChildren(node.elements), + expressions: transformer.transformChildren(node.expressions), + }); + +export const visitTemplateLiteralElement = ( + node: TemplateLiteralElement, + transformer: Transformer, +) => { + const [parent] = transformer.ancestors; + const { elements } = parent as TemplateLiteral; + const elementIndex = elements.indexOf(node); + const isFirst = elementIndex === 0; + const isLast = elementIndex === elements.length - 1; + + const end = node.sourceSpan.end - (isLast ? 1 : 0); + const start = node.sourceSpan.start + (isFirst ? 1 : 0); + const raw = transformer.text.slice(start, end); + + return { + type: 'TemplateElement', + value: { cooked: node.text, raw }, + tail: isLast, + range: [start, end], + }; +}; diff --git a/src/ast-transform/transform-this-expression.ts b/src/ast-transform/transform-this-expression.ts new file mode 100644 index 00000000..fdc8ab47 --- /dev/null +++ b/src/ast-transform/transform-this-expression.ts @@ -0,0 +1,5 @@ +import type * as babel from '@babel/types'; + +export const visitThisReceiver = (): babel.ThisExpression => ({ + type: 'ThisExpression', +}); diff --git a/src/ast-transform/transform-unary-expression.ts b/src/ast-transform/transform-unary-expression.ts new file mode 100644 index 00000000..8bf0fde6 --- /dev/null +++ b/src/ast-transform/transform-unary-expression.ts @@ -0,0 +1,53 @@ +import { + type PrefixNot, + type TypeofExpression, + type Unary, + type VoidExpression, +} from '@angular/compiler'; +import type * as babel from '@babel/types'; + +import { type Transformer } from './transform.ts'; + +type VisitorPrefixNot = { + node: PrefixNot; + operator: '!'; +}; +type VisitorTypeofExpression = { + node: TypeofExpression; + operator: 'typeof'; +}; +type VisitorVoidExpression = { + node: VoidExpression; + operator: 'void'; +}; + +const transformUnaryExpression = + < + Visitor extends + | VisitorPrefixNot + | VisitorTypeofExpression + | VisitorVoidExpression, + >( + operator: Visitor['operator'], + ) => + (node: Visitor['node'], transformer: Transformer): babel.UnaryExpression => ({ + type: 'UnaryExpression', + prefix: true, + operator, + argument: transformer.transformChild(node.expression), + }); + +export const visitPrefixNot = transformUnaryExpression('!'); +export const visitTypeofExpression = + transformUnaryExpression('typeof'); +export const visitVoidExpression = + transformUnaryExpression('void'); +export const visitUnary = ( + node: Unary, + transformer: Transformer, +): babel.UnaryExpression => ({ + type: 'UnaryExpression', + prefix: true, + argument: transformer.transformChild(node.expr), + operator: node.operator as '-' | '+', +}); diff --git a/src/ast-transform/transform-unexpected-node.ts b/src/ast-transform/transform-unexpected-node.ts new file mode 100644 index 00000000..4031a398 --- /dev/null +++ b/src/ast-transform/transform-unexpected-node.ts @@ -0,0 +1,9 @@ +import { type ImplicitReceiver } from '@angular/compiler'; + +/* c8 ignore next @preserve */ +const transformUnexpectedNode = (node: T) => { + throw new Error(`Unexpected node type '${node.constructor.name}'`); +}; + +// Handled in `./transform-member-expression.ts` +export const visitImplicitReceiver = transformUnexpectedNode; diff --git a/src/ast-transform/transform.ts b/src/ast-transform/transform.ts new file mode 100644 index 00000000..a052cddf --- /dev/null +++ b/src/ast-transform/transform.ts @@ -0,0 +1,120 @@ +import { + type AST, + type ASTWithSource, + EmptyExpr, + ParenthesizedExpression, +} from '@angular/compiler'; + +import { Source } from '../source.ts'; +import type { NGEmptyExpression, NGNode, RawNGSpan } from '../types.ts'; +import { transformVisitor } from './visitor.ts'; + +class NodeTransformer extends Source { + node: AST; + ancestors: AST[]; + + constructor({ + node, + text, + ancestors, + }: { + node: AST; + text: string; + ancestors: AST[]; + }) { + super(text); + this.node = node; + this.ancestors = ancestors; + } + + create( + properties: Partial & { type: T['type'] }, + location: AST | RawNGSpan | [number, number], + ancestors: AST[], + ) { + const node = super.createNode(properties, location); + + if (ancestors[0] instanceof ParenthesizedExpression) { + node.extra = { + ...node.extra, + parenthesized: true, + }; + } + + return node; + } + + createNode( + properties: Partial & { type: T['type'] }, + location: AST | RawNGSpan | [number, number] = this.node, + ancestorsToCreate: AST[] = this.ancestors, + ) { + return this.create(properties, location, ancestorsToCreate); + } + + transformChild(child: AST) { + return new NodeTransformer({ + node: child, + ancestors: [this.node, ...this.ancestors], + text: this.text, + }).transform(); + } + + transformChildren(children: AST[]) { + return children.map((child) => this.transformChild(child)); + } + + transform() { + const { node } = this; + if (node instanceof EmptyExpr) { + return this.createNode( + { type: 'NGEmptyExpression' }, + node.sourceSpan, + ) as T; + } + + const properties = node.visit(transformVisitor, this); + + if (properties.range) { + properties.start ??= properties.range[0]; + properties.end ??= properties.range[1]; + return properties as T; + } + + const { location = node.sourceSpan, ...restProperties } = properties; + const estreeNode = this.createNode(restProperties, location); + + return estreeNode as T; + } + + static transform(node: AST, text: string) { + return new NodeTransformer({ node, text, ancestors: [] }).transform(); + } +} + +class AstTransformer { + #ast; + + constructor(ast: ASTWithSource) { + this.#ast = ast; + } + + transform() { + return NodeTransformer.transform(this.#ast, this.#ast.source!); + } +} + +const transformAstNode = (node: AST, text: string) => { + return NodeTransformer.transform(node, text); +}; + +const transformAst = (ast: ASTWithSource) => { + return new AstTransformer(ast).transform(); +}; + +export { + NodeTransformer, + transformAst, + transformAstNode, + NodeTransformer as Transformer, +}; diff --git a/src/ast-transform/transforms.ts b/src/ast-transform/transforms.ts new file mode 100644 index 00000000..ebe32c46 --- /dev/null +++ b/src/ast-transform/transforms.ts @@ -0,0 +1,17 @@ +export * from './transform-array-expression.ts'; +export * from './transform-ast-with-source.ts'; +export * from './transform-binary-expression.ts'; +export * from './transform-call-expression.ts'; +export * from './transform-chained-expression.ts'; +export * from './transform-conditional-expression.ts'; +export * from './transform-interpolation.ts'; +export * from './transform-literal.ts'; +export * from './transform-member-expression.ts'; +export * from './transform-non-null-expression.ts'; +export * from './transform-object-expression.ts'; +export * from './transform-parenthesized-expression.ts'; +export * from './transform-pipe-expression.ts'; +export * from './transform-template-literal.ts'; +export * from './transform-this-expression.ts'; +export * from './transform-unary-expression.ts'; +export * from './transform-unexpected-node.ts'; diff --git a/src/ast-transform/utilities.ts b/src/ast-transform/utilities.ts new file mode 100644 index 00000000..454438dc --- /dev/null +++ b/src/ast-transform/utilities.ts @@ -0,0 +1,17 @@ +import type { NGNode } from '../types.ts'; + +function isParenthesized(node: NGNode) { + return Boolean(node.extra?.parenthesized); +} + +export function isOptionalObjectOrCallee(node: NGNode): boolean { + if (node.type === 'TSNonNullExpression' && !isParenthesized(node)) { + return isOptionalObjectOrCallee(node.expression); + } + + return ( + (node.type === 'OptionalCallExpression' || + node.type === 'OptionalMemberExpression') && + !isParenthesized(node) + ); +} diff --git a/src/ast-transform/visitor.ts b/src/ast-transform/visitor.ts new file mode 100644 index 00000000..949692bb --- /dev/null +++ b/src/ast-transform/visitor.ts @@ -0,0 +1,7 @@ +import { type AstVisitor as AngularAstVisitor } from '@angular/compiler'; + +import * as transforms from './transforms.ts'; + +type AstVisitor = Required>; + +export const transformVisitor: AstVisitor = transforms; diff --git a/src/estree-parser.ts b/src/estree-parser.ts index a84bd67e..25776cbe 100644 --- a/src/estree-parser.ts +++ b/src/estree-parser.ts @@ -1,17 +1,30 @@ import * as angularParser from './angular-parser.ts'; -import { transformAstResult, transformMicrosyntaxResult } from './transform.ts'; +import { transformAst } from './ast-transform/index.ts'; +import { transformTemplateBindings } from './transform-template-binding.ts'; -export const parseBinding = (text: string) => - transformAstResult(angularParser.parseBinding(text)); - -export const parseSimpleBinding = (text: string) => - transformAstResult(angularParser.parseSimpleBinding(text)); - -export const parseInterpolationExpression = (text: string) => - transformAstResult(angularParser.parseInterpolationExpression(text)); - -export const parseAction = (text: string) => - transformAstResult(angularParser.parseAction(text)); +const createAstParser = + ( + parse: + | typeof angularParser.parseBinding + | typeof angularParser.parseSimpleBinding + | typeof angularParser.parseInterpolationExpression + | typeof angularParser.parseAction, + ) => + (text: string) => { + const { result, comments } = parse(text); + return Object.assign(transformAst(result), { comments }); + }; +export const parseAction = createAstParser(angularParser.parseAction); +export const parseBinding = createAstParser(angularParser.parseBinding); +export const parseSimpleBinding = createAstParser( + angularParser.parseSimpleBinding, +); +export const parseInterpolationExpression = createAstParser( + angularParser.parseInterpolationExpression, +); export const parseTemplateBindings = (text: string) => - transformMicrosyntaxResult(angularParser.parseTemplateBindings(text)); + transformTemplateBindings( + angularParser.parseTemplateBindings(text).result.templateBindings, + text, + ); diff --git a/src/transform-node.ts b/src/transform-node.ts deleted file mode 100644 index 73e182b5..00000000 --- a/src/transform-node.ts +++ /dev/null @@ -1,451 +0,0 @@ -import * as angular from '@angular/compiler'; -import type * as babel from '@babel/types'; - -import { Source } from './source.ts'; -import type { - LocationInformation, - NGChainedExpression, - NGEmptyExpression, - NGNode, - NGPipeExpression, - RawNGSpan, -} from './types.ts'; - -function isParenthesized(node: NGNode) { - return Boolean(node.extra?.parenthesized); -} - -function isOptionalObjectOrCallee(node: NGNode): boolean { - if (node.type === 'TSNonNullExpression' && !isParenthesized(node)) { - return isOptionalObjectOrCallee(node.expression); - } - - return ( - (node.type === 'OptionalCallExpression' || - node.type === 'OptionalMemberExpression') && - !isParenthesized(node) - ); -} - -type NodeTransformOptions = { - ancestors: angular.AST[]; -}; - -class Transformer extends Source { - constructor(text: string) { - super(text); - } - - transform( - node: angular.AST, - options?: NodeTransformOptions, - ) { - return this.#transform(node, options ?? { ancestors: [] }) as T & - LocationInformation; - } - - #create( - properties: Partial & { type: T['type'] }, - location: angular.AST | RawNGSpan | [number, number], - ancestors: angular.AST[], - ) { - const node = super.createNode(properties, location); - - if (ancestors[0] instanceof angular.ParenthesizedExpression) { - node.extra = { - ...node.extra, - parenthesized: true, - }; - } - - return node; - } - - #transform(node: angular.AST, options: NodeTransformOptions): NGNode { - const ancestors = options.ancestors; - const transformChild = (child: angular.AST) => - this.transform(child, { ancestors: [node, ...ancestors] }); - const transformChildren = (children: angular.AST[]) => - children.map((child) => transformChild(child)); - const createNode = ( - properties: Partial & { type: T['type'] }, - location: angular.AST | RawNGSpan | [number, number] = node, - ancestorsToCreate: angular.AST[] = ancestors, - ) => this.#create(properties, location, ancestorsToCreate); - - if (node instanceof angular.Interpolation) { - const { expressions } = node; - - /* c8 ignore next 3 @preserve */ - if (expressions.length !== 1) { - throw new Error("Unexpected 'Interpolation'"); - } - - return transformChild(expressions[0]); - } - - if (node instanceof angular.Unary) { - return createNode({ - type: 'UnaryExpression', - prefix: true, - argument: transformChild(node.expr), - operator: node.operator as '-' | '+', - }); - } - - if (node instanceof angular.Binary) { - const { operation: operator } = node; - const [left, right] = transformChildren([ - node.left, - node.right, - ]); - - if (operator === '&&' || operator === '||' || operator === '??') { - return createNode({ - type: 'LogicalExpression', - operator: operator as babel.LogicalExpression['operator'], - left, - right, - }); - } - - if (angular.Binary.isAssignmentOperation(operator)) { - return createNode({ - type: 'AssignmentExpression', - left: left as babel.MemberExpression, - right, - operator: operator as babel.AssignmentExpression['operator'], - }); - } - - return createNode({ - left, - right, - type: 'BinaryExpression', - operator: operator as babel.BinaryExpression['operator'], - }); - } - - if (node instanceof angular.BindingPipe) { - return createNode({ - type: 'NGPipeExpression', - left: transformChild(node.exp), - right: createNode( - { type: 'Identifier', name: node.name }, - node.nameSpan, - ), - arguments: transformChildren(node.args), - }); - } - - if (node instanceof angular.Chain) { - return createNode({ - type: 'NGChainedExpression', - expressions: transformChildren(node.expressions), - }); - } - - if (node instanceof angular.Conditional) { - const [test, consequent, alternate] = transformChildren( - [node.condition, node.trueExp, node.falseExp], - ); - - return createNode({ - type: 'ConditionalExpression', - test, - consequent, - alternate, - }); - } - - if (node instanceof angular.EmptyExpr) { - return createNode({ type: 'NGEmptyExpression' }); - } - - if (node instanceof angular.ThisReceiver) { - return createNode({ type: 'ThisExpression' }); - } - - if (node instanceof angular.LiteralArray) { - return createNode({ - type: 'ArrayExpression', - elements: transformChildren(node.expressions), - }); - } - - if (node instanceof angular.LiteralMap) { - const { keys, values } = node; - const createChild = ( - properties: Partial & { type: T['type'] }, - location: angular.AST | RawNGSpan | [number, number] = node, - ) => this.#create(properties, location, [node, ...ancestors]); - - return createNode({ - type: 'ObjectExpression', - properties: keys.map((keyNode, index) => { - const valueNode = values[index]; - const shorthand = Boolean(keyNode.isShorthandInitialized); - const key = createChild( - keyNode.quoted - ? { type: 'StringLiteral', value: keyNode.key } - : { type: 'Identifier', name: keyNode.key }, - keyNode.sourceSpan, - ); - - return createChild( - { - type: 'ObjectProperty', - key, - value: transformChild(valueNode), - shorthand, - computed: false, - // @ts-expect-error -- Missed in types - method: false, - }, - [keyNode.sourceSpan.start, valueNode.sourceSpan.end], - ); - }), - }); - } - - if (node instanceof angular.LiteralPrimitive) { - const { value } = node; - switch (typeof value) { - case 'boolean': - return createNode({ - type: 'BooleanLiteral', - value, - }); - case 'number': - return createNode({ - type: 'NumericLiteral', - value, - }); - case 'object': - return createNode({ type: 'NullLiteral' }); - case 'string': - return createNode({ - type: 'StringLiteral', - value, - }); - case 'undefined': - return createNode({ - type: 'Identifier', - name: 'undefined', - }); - /* c8 ignore next 4 */ - default: - throw new Error( - `Unexpected LiteralPrimitive value type ${typeof value}`, - ); - } - } - - if (node instanceof angular.RegularExpressionLiteral) { - return createNode({ - type: 'RegExpLiteral', - pattern: node.body, - flags: node.flags ?? '', - }); - } - - if (node instanceof angular.Call || node instanceof angular.SafeCall) { - const arguments_ = transformChildren(node.args); - const callee = transformChild(node.receiver); - const isOptionalReceiver = isOptionalObjectOrCallee(callee); - const isOptional = node instanceof angular.SafeCall; - const nodeType = - isOptional || isOptionalReceiver - ? 'OptionalCallExpression' - : 'CallExpression'; - return createNode({ - type: nodeType, - callee, - arguments: arguments_, - ...(nodeType === 'OptionalCallExpression' - ? { optional: isOptional } - : undefined), - }); - } - - if (node instanceof angular.NonNullAssert) { - return createNode({ - type: 'TSNonNullExpression', - expression: transformChild(node.expression), - }); - } - - if (node instanceof angular.PrefixNot) { - return createNode( - { - type: 'UnaryExpression', - prefix: true, - operator: '!', - argument: transformChild(node.expression), - }, - node.sourceSpan, - ); - } - - if (node instanceof angular.TypeofExpression) { - return createNode( - { - type: 'UnaryExpression', - prefix: true, - operator: 'typeof', - argument: transformChild(node.expression), - }, - node.sourceSpan, - ); - } - - if (node instanceof angular.VoidExpression) { - return createNode( - { - type: 'UnaryExpression', - prefix: true, - operator: 'void', - argument: transformChild(node.expression), - }, - node.sourceSpan, - ); - } - - if ( - node instanceof angular.KeyedRead || - node instanceof angular.SafeKeyedRead || - node instanceof angular.PropertyRead || - node instanceof angular.SafePropertyRead - ) { - const isComputed = - node instanceof angular.KeyedRead || - node instanceof angular.SafeKeyedRead; - const isOptional = - node instanceof angular.SafeKeyedRead || - node instanceof angular.SafePropertyRead; - const { receiver } = node; - const isImplicitReceiver = receiver instanceof angular.ImplicitReceiver; - - let property; - if (isComputed) { - property = transformChild(node.key); - } else { - property = createNode( - { type: 'Identifier', name: node.name }, - node.nameSpan, - isImplicitReceiver ? ancestors : [], - ); - } - - if (isImplicitReceiver) { - return property; - } - - const object = transformChild(receiver); - const isOptionalObject = isOptionalObjectOrCallee(object); - - if (isOptional || isOptionalObject) { - return createNode({ - type: 'OptionalMemberExpression', - optional: isOptional || !isOptionalObject, - computed: isComputed, - property, - object, - }); - } - - if (isComputed) { - return createNode({ - type: 'MemberExpression', - property, - object, - computed: true, - }); - } - - return createNode({ - type: 'MemberExpression', - object, - property: property as babel.MemberExpressionNonComputed['property'], - computed: false, - }); - } - - if (node instanceof angular.TaggedTemplateLiteral) { - return createNode({ - type: 'TaggedTemplateExpression', - tag: transformChild(node.tag), - quasi: transformChild(node.template), - }); - } - - if (node instanceof angular.TemplateLiteral) { - return createNode({ - type: 'TemplateLiteral', - quasis: transformChildren(node.elements), - expressions: transformChildren(node.expressions), - }); - } - - if (node instanceof angular.TemplateLiteralElement) { - const [parent] = ancestors; - const { elements } = parent as angular.TemplateLiteral; - const elementIndex = elements.indexOf(node); - const isFirst = elementIndex === 0; - const isLast = elementIndex === elements.length - 1; - - const end = node.sourceSpan.end - (isLast ? 1 : 0); - const start = node.sourceSpan.start + (isFirst ? 1 : 0); - const raw = this.text.slice(start, end); - - return createNode( - { - type: 'TemplateElement', - value: { cooked: node.text, raw }, - tail: isLast, - }, - [start, end], - ); - } - - if (node instanceof angular.ParenthesizedExpression) { - return transformChild(node.expression); - } - - /* c8 ignore next @preserve */ - throw new Error(`Unexpected node type '${node.constructor.name}'`); - } -} - -// See `convertAst` in `@angular/compiler` -type SupportedNodes = - | angular.ASTWithSource // Not handled - | angular.PropertyRead - | angular.Call - | angular.LiteralPrimitive - | angular.Unary - | angular.Binary - | angular.ThisReceiver - | angular.ImplicitReceiver - | angular.KeyedRead - | angular.Chain - | angular.LiteralMap - | angular.LiteralArray - | angular.Conditional - | angular.NonNullAssert - | angular.BindingPipe - | angular.SafeKeyedRead - | angular.SafePropertyRead - | angular.SafeCall - | angular.EmptyExpr - | angular.PrefixNot - | angular.TypeofExpression - | angular.VoidExpression - | angular.TemplateLiteral // Including `TemplateLiteralElement` - | angular.TaggedTemplateLiteral - | angular.ParenthesizedExpression; -function transform(node: SupportedNodes, text: string): NGNode { - return new Transformer(text).transform(node); -} - -export { transform, Transformer }; diff --git a/src/transform-template-binding.ts b/src/transform-template-binding.ts index 8a9c6004..3c0be941 100644 --- a/src/transform-template-binding.ts +++ b/src/transform-template-binding.ts @@ -4,7 +4,8 @@ import { VariableBinding as NGVariableBinding, } from '@angular/compiler'; -import { Transformer as NodeTransformer } from './transform-node.ts'; +import { transformAstNode } from './ast-transform/index.ts'; +import { Source } from './source.ts'; import type { NGMicrosyntax, NGMicrosyntaxAs, @@ -30,7 +31,7 @@ function isVariableBinding( return templateBinding instanceof NGVariableBinding; } -class TemplateBindingTransformer extends NodeTransformer { +class TemplateBindingTransformer extends Source { #rawTemplateBindings; #text; @@ -57,11 +58,11 @@ class TemplateBindingTransformer extends NodeTransformer { properties: Partial & { type: T['type'] }, location: angular.AST | RawNGSpan | [number, number], ) { - return this.createNode(properties, location); + return super.createNode(properties, location); } #transform(node: angular.AST) { - return super.transform(node) as T; + return transformAstNode(node, this.text) as T; } #removePrefix(string: string) { @@ -276,11 +277,11 @@ class TemplateBindingTransformer extends NodeTransformer { } } -function transform( +function transformTemplateBindings( rawTemplateBindings: angular.TemplateBinding[], text: string, ) { return new TemplateBindingTransformer(rawTemplateBindings, text).expressions; } -export { transform }; +export { transformTemplateBindings }; diff --git a/src/transform.ts b/src/transform.ts deleted file mode 100644 index 79313f76..00000000 --- a/src/transform.ts +++ /dev/null @@ -1,23 +0,0 @@ -import type { - AstParseResult, - MicroSyntaxParseResult, -} from './angular-parser.ts'; -import { transform as transformNode } from './transform-node.ts'; -import { transform as transformTemplateBindings } from './transform-template-binding.ts'; - -function transformAstResult({ - result: { ast }, - text, - comments, -}: AstParseResult) { - return Object.assign(transformNode(ast, text), { comments }); -} - -function transformMicrosyntaxResult({ - result: { templateBindings }, - text, -}: MicroSyntaxParseResult) { - return transformTemplateBindings(templateBindings, text); -} - -export { transformAstResult, transformMicrosyntaxResult };