diff --git a/packages/compiler/src/i18n/extractor_merger.ts b/packages/compiler/src/i18n/extractor_merger.ts index cc3902d14ce..dcbf9a42f4b 100644 --- a/packages/compiler/src/i18n/extractor_merger.ts +++ b/packages/compiler/src/i18n/extractor_merger.ts @@ -332,6 +332,8 @@ class _Visitor implements html.Visitor { visitBlockParameter(parameter: html.BlockParameter, context: any) {} + visitLetDeclaration(decl: html.LetDeclaration, context: any) {} + private _init(mode: _VisitorMode, interpolationConfig: InterpolationConfig): void { this._mode = mode; this._inI18nBlock = false; diff --git a/packages/compiler/src/i18n/i18n_parser.ts b/packages/compiler/src/i18n/i18n_parser.ts index 6287e22d0d8..c409c8b5b71 100644 --- a/packages/compiler/src/i18n/i18n_parser.ts +++ b/packages/compiler/src/i18n/i18n_parser.ts @@ -239,6 +239,10 @@ class _I18nVisitor implements html.Visitor { throw new Error('Unreachable code'); } + visitLetDeclaration(decl: html.LetDeclaration, context: any) { + return null; + } + /** * Convert, text and interpolated tokens up into text and placeholder pieces. * diff --git a/packages/compiler/src/i18n/serializers/xliff.ts b/packages/compiler/src/i18n/serializers/xliff.ts index f563b4beb32..d39b3d573a7 100644 --- a/packages/compiler/src/i18n/serializers/xliff.ts +++ b/packages/compiler/src/i18n/serializers/xliff.ts @@ -303,6 +303,8 @@ class XliffParser implements ml.Visitor { visitBlockParameter(parameter: ml.BlockParameter, context: any) {} + visitLetDeclaration(decl: ml.LetDeclaration, context: any) {} + private _addError(node: ml.Node, message: string): void { this._errors.push(new I18nError(node.sourceSpan, message)); } @@ -376,6 +378,8 @@ class XmlToI18n implements ml.Visitor { visitBlockParameter(parameter: ml.BlockParameter, context: any) {} + visitLetDeclaration(decl: ml.LetDeclaration, context: any) {} + private _addError(node: ml.Node, message: string): void { this._errors.push(new I18nError(node.sourceSpan, message)); } diff --git a/packages/compiler/src/i18n/serializers/xliff2.ts b/packages/compiler/src/i18n/serializers/xliff2.ts index ab887702caa..d74f3d7c65d 100644 --- a/packages/compiler/src/i18n/serializers/xliff2.ts +++ b/packages/compiler/src/i18n/serializers/xliff2.ts @@ -334,6 +334,8 @@ class Xliff2Parser implements ml.Visitor { visitBlockParameter(parameter: ml.BlockParameter, context: any) {} + visitLetDeclaration(decl: ml.LetDeclaration, context: any) {} + private _addError(node: ml.Node, message: string): void { this._errors.push(new I18nError(node.sourceSpan, message)); } @@ -428,6 +430,8 @@ class XmlToI18n implements ml.Visitor { visitBlockParameter(parameter: ml.BlockParameter, context: any) {} + visitLetDeclaration(decl: ml.LetDeclaration, context: any) {} + private _addError(node: ml.Node, message: string): void { this._errors.push(new I18nError(node.sourceSpan, message)); } diff --git a/packages/compiler/src/i18n/serializers/xtb.ts b/packages/compiler/src/i18n/serializers/xtb.ts index f134143323b..e2cedcd77a3 100644 --- a/packages/compiler/src/i18n/serializers/xtb.ts +++ b/packages/compiler/src/i18n/serializers/xtb.ts @@ -158,6 +158,8 @@ class XtbParser implements ml.Visitor { visitBlockParameter(block: ml.BlockParameter, context: any) {} + visitLetDeclaration(decl: ml.LetDeclaration, context: any) {} + private _addError(node: ml.Node, message: string): void { this._errors.push(new I18nError(node.sourceSpan, message)); } @@ -226,6 +228,8 @@ class XmlToI18n implements ml.Visitor { visitBlockParameter(block: ml.BlockParameter, context: any) {} + visitLetDeclaration(decl: ml.LetDeclaration, context: any) {} + private _addError(node: ml.Node, message: string): void { this._errors.push(new I18nError(node.sourceSpan, message)); } diff --git a/packages/compiler/src/ml_parser/ast.ts b/packages/compiler/src/ml_parser/ast.ts index 26199f4cb3c..7ab5b415a84 100644 --- a/packages/compiler/src/ml_parser/ast.ts +++ b/packages/compiler/src/ml_parser/ast.ts @@ -152,6 +152,20 @@ export class BlockParameter implements BaseNode { } } +export class LetDeclaration implements BaseNode { + constructor( + public name: string, + public value: string, + public sourceSpan: ParseSourceSpan, + readonly nameSpan: ParseSourceSpan, + public valueSpan: ParseSourceSpan, + ) {} + + visit(visitor: Visitor, context: any): any { + return visitor.visitLetDeclaration(this, context); + } +} + export interface Visitor { // Returning a truthy value from `visit()` will prevent `visitAll()` from the call to the typed // method and result returned will become the result included in `visitAll()`s result array. @@ -165,6 +179,7 @@ export interface Visitor { visitExpansionCase(expansionCase: ExpansionCase, context: any): any; visitBlock(block: Block, context: any): any; visitBlockParameter(parameter: BlockParameter, context: any): any; + visitLetDeclaration(decl: LetDeclaration, context: any): any; } export function visitAll(visitor: Visitor, nodes: Node[], context: any = null): any[] { @@ -213,6 +228,8 @@ export class RecursiveVisitor implements Visitor { visitBlockParameter(ast: BlockParameter, context: any): any {} + visitLetDeclaration(decl: LetDeclaration, context: any) {} + private visitChildren( context: any, cb: (visit: (children: V[] | undefined) => void) => void, diff --git a/packages/compiler/src/ml_parser/html_whitespaces.ts b/packages/compiler/src/ml_parser/html_whitespaces.ts index 917f38d982c..a4624cf69df 100644 --- a/packages/compiler/src/ml_parser/html_whitespaces.ts +++ b/packages/compiler/src/ml_parser/html_whitespaces.ts @@ -125,6 +125,10 @@ export class WhitespaceVisitor implements html.Visitor { visitBlockParameter(parameter: html.BlockParameter, context: any) { return parameter; } + + visitLetDeclaration(decl: html.LetDeclaration, context: any) { + return decl; + } } function createWhitespaceProcessedTextToken({type, parts, sourceSpan}: TextToken): TextToken { diff --git a/packages/compiler/src/ml_parser/icu_ast_expander.ts b/packages/compiler/src/ml_parser/icu_ast_expander.ts index 9a79999eee8..e3047165400 100644 --- a/packages/compiler/src/ml_parser/icu_ast_expander.ts +++ b/packages/compiler/src/ml_parser/icu_ast_expander.ts @@ -113,6 +113,10 @@ class _Expander implements html.Visitor { visitBlockParameter(parameter: html.BlockParameter, context: any) { return parameter; } + + visitLetDeclaration(decl: html.LetDeclaration, context: any) { + return decl; + } } // Plural forms are expanded to `NgPlural` and `NgPluralCase`s diff --git a/packages/compiler/src/ml_parser/parser.ts b/packages/compiler/src/ml_parser/parser.ts index c000685496c..fde051b5d4f 100644 --- a/packages/compiler/src/ml_parser/parser.ts +++ b/packages/compiler/src/ml_parser/parser.ts @@ -25,9 +25,13 @@ import { ExpansionCaseValueToken, ExpansionFormStartToken, IncompleteBlockOpenToken, + IncompleteLetToken, IncompleteTagOpenToken, InterpolatedAttributeToken, InterpolatedTextToken, + LetEndToken, + LetStartToken, + LetValueToken, TagCloseToken, TagOpenStartToken, TextToken, @@ -127,6 +131,12 @@ class _TreeBuilder { } else if (this._peek.type === TokenType.INCOMPLETE_BLOCK_OPEN) { this._closeVoidElement(); this._consumeIncompleteBlock(this._advance()); + } else if (this._peek.type === TokenType.LET_START) { + this._closeVoidElement(); + this._consumeLet(this._advance()); + } else if (this._peek.type === TokenType.INCOMPLETE_LET) { + this._closeVoidElement(); + this._consumeIncompleteLet(this._advance()); } else { // Skip all other tokens... this._advance(); @@ -627,6 +637,89 @@ class _TreeBuilder { ); } + private _consumeLet(startToken: LetStartToken) { + const name = startToken.parts[0]; + let valueToken: LetValueToken; + let endToken: LetEndToken; + + if (this._peek.type !== TokenType.LET_VALUE) { + this.errors.push( + TreeError.create( + startToken.parts[0], + startToken.sourceSpan, + `Invalid @let declaration "${name}". Declaration must have a value.`, + ), + ); + return; + } else { + valueToken = this._advance(); + } + + // Type cast is necessary here since TS narrowed the type of `peek` above. + if ((this._peek as Token).type !== TokenType.LET_END) { + this.errors.push( + TreeError.create( + startToken.parts[0], + startToken.sourceSpan, + `Unterminated @let declaration "${name}". Declaration must be terminated with a semicolon.`, + ), + ); + return; + } else { + endToken = this._advance(); + } + + const end = endToken.sourceSpan.fullStart; + const span = new ParseSourceSpan( + startToken.sourceSpan.start, + end, + startToken.sourceSpan.fullStart, + ); + + // The start token usually captures the `@let`. Construct a name span by + // offsetting the start by the length of any text before the name. + const startOffset = startToken.sourceSpan.toString().lastIndexOf(name); + const nameStart = startToken.sourceSpan.start.moveBy(startOffset); + const nameSpan = new ParseSourceSpan(nameStart, startToken.sourceSpan.end); + const node = new html.LetDeclaration( + name, + valueToken.parts[0], + span, + nameSpan, + valueToken.sourceSpan, + ); + + this._addToParent(node); + } + + private _consumeIncompleteLet(token: IncompleteLetToken) { + // Incomplete `@let` declaration may end up with an empty name. + const name = token.parts[0] ?? ''; + const nameString = name ? ` "${name}"` : ''; + + // If there's at least a name, we can salvage an AST node that can be used for completions. + if (name.length > 0) { + const startOffset = token.sourceSpan.toString().lastIndexOf(name); + const nameStart = token.sourceSpan.start.moveBy(startOffset); + const nameSpan = new ParseSourceSpan(nameStart, token.sourceSpan.end); + const valueSpan = new ParseSourceSpan( + token.sourceSpan.start, + token.sourceSpan.start.moveBy(0), + ); + const node = new html.LetDeclaration(name, '', token.sourceSpan, nameSpan, valueSpan); + this._addToParent(node); + } + + this.errors.push( + TreeError.create( + token.parts[0], + token.sourceSpan, + `Incomplete @let declaration${nameString}. ` + + `@let declarations must be written as \`@let = ;\``, + ), + ); + } + private _getContainer(): NodeContainer | null { return this._containerStack.length > 0 ? this._containerStack[this._containerStack.length - 1] diff --git a/packages/compiler/src/ml_parser/xml_parser.ts b/packages/compiler/src/ml_parser/xml_parser.ts index 188d87ca3a8..397911c8b76 100644 --- a/packages/compiler/src/ml_parser/xml_parser.ts +++ b/packages/compiler/src/ml_parser/xml_parser.ts @@ -16,7 +16,7 @@ export class XmlParser extends Parser { } override parse(source: string, url: string, options: TokenizeOptions = {}): ParseTreeResult { - // Blocks aren't supported in an XML context. - return super.parse(source, url, {...options, tokenizeBlocks: false}); + // Blocks and let declarations aren't supported in an XML context. + return super.parse(source, url, {...options, tokenizeBlocks: false, tokenizeLet: false}); } } diff --git a/packages/compiler/src/render3/r3_template_transform.ts b/packages/compiler/src/render3/r3_template_transform.ts index ec3e7b6f7f2..78eaaf49baa 100644 --- a/packages/compiler/src/render3/r3_template_transform.ts +++ b/packages/compiler/src/render3/r3_template_transform.ts @@ -389,6 +389,10 @@ class HtmlAstToIvyAst implements html.Visitor { return null; } + visitLetDeclaration(decl: html.LetDeclaration, context: any) { + throw new Error('TODO: implement R3 LetDeclaration'); + } + visitBlockParameter() { return null; } @@ -893,6 +897,10 @@ class NonBindableVisitor implements html.Visitor { visitBlockParameter(parameter: html.BlockParameter, context: any) { return null; } + + visitLetDeclaration(decl: html.LetDeclaration, context: any) { + throw new Error('TODO: implement R3 LetDeclaration'); + } } const NON_BINDABLE_VISITOR = new NonBindableVisitor(); diff --git a/packages/compiler/src/render3/view/i18n/meta.ts b/packages/compiler/src/render3/view/i18n/meta.ts index adf74b68d21..a40e7b98084 100644 --- a/packages/compiler/src/render3/view/i18n/meta.ts +++ b/packages/compiler/src/render3/view/i18n/meta.ts @@ -187,6 +187,10 @@ export class I18nMetaVisitor implements html.Visitor { return parameter; } + visitLetDeclaration(decl: html.LetDeclaration, context: any) { + return decl; + } + /** * Parse the general form `meta` passed into extract the explicit metadata needed to create a * `Message`. diff --git a/packages/compiler/test/ml_parser/ast_spec_utils.ts b/packages/compiler/test/ml_parser/ast_spec_utils.ts index 2e91081404e..ed6058ba0c4 100644 --- a/packages/compiler/test/ml_parser/ast_spec_utils.ts +++ b/packages/compiler/test/ml_parser/ast_spec_utils.ts @@ -114,6 +114,17 @@ class _Humanizer implements html.Visitor { this.result.push(this._appendContext(parameter, [html.BlockParameter, parameter.expression])); } + visitLetDeclaration(decl: html.LetDeclaration, context: any) { + const res = this._appendContext(decl, [html.LetDeclaration, decl.name, decl.value]); + + if (this.includeSourceSpan) { + res.push(decl.nameSpan?.toString() ?? null); + res.push(decl.valueSpan?.toString() ?? null); + } + + this.result.push(res); + } + private _appendContext(ast: html.Node, input: any[]): any[] { if (!this.includeSourceSpan) return input; input.push(ast.sourceSpan.toString()); diff --git a/packages/compiler/test/ml_parser/html_parser_spec.ts b/packages/compiler/test/ml_parser/html_parser_spec.ts index e9aff547a76..23dcf8a1c5d 100644 --- a/packages/compiler/test/ml_parser/html_parser_spec.ts +++ b/packages/compiler/test/ml_parser/html_parser_spec.ts @@ -1057,6 +1057,54 @@ describe('HtmlParser', () => { }); }); + describe('let declaration', () => { + function parseLet(input: string) { + return parser.parse(input, 'TestComp', {tokenizeLet: true}); + } + + it('should parse a let declaration', () => { + expect(humanizeDom(parseLet('@let foo = 123;'))).toEqual([ + [html.LetDeclaration, 'foo', '123'], + ]); + }); + + it('should parse a let declaration that is nested in a parent', () => { + expect(humanizeDom(parseLet('@grandparent {@parent {@let foo = 123;}}'))).toEqual([ + [html.Block, 'grandparent', 0], + [html.Block, 'parent', 1], + [html.LetDeclaration, 'foo', '123'], + ]); + }); + + it('should store the source location of a @let declaration', () => { + expect(humanizeDomSourceSpans(parseLet('@let foo = 123 + 456;'))).toEqual([ + [html.LetDeclaration, 'foo', '123 + 456', '@let foo = 123 + 456', 'foo', '123 + 456'], + ]); + }); + + it('should report an error for an incomplete let declaration', () => { + expect(humanizeErrors(parseLet('@let foo =').errors)).toEqual([ + [ + 'foo', + 'Incomplete @let declaration "foo". @let declarations must be written as `@let = ;`', + '0:0', + ], + ]); + }); + + it('should store the locations of an incomplete let declaration', () => { + const parseResult = parseLet('@let foo ='); + + // It's expected that errors will be reported for the incomplete declaration, + // but we still want to check the spans since they're important even for broken templates. + parseResult.errors = []; + + expect(humanizeDomSourceSpans(parseResult)).toEqual([ + [html.LetDeclaration, 'foo', '', '@let foo =', 'foo =', ''], + ]); + }); + }); + describe('source spans', () => { it('should store the location', () => { expect( @@ -1307,6 +1355,7 @@ describe('HtmlParser', () => { html.visitAll(this, block.children); } visitBlockParameter(parameter: html.BlockParameter, context: any) {} + visitLetDeclaration(decl: html.LetDeclaration, context: any) {} })(); html.visitAll(visitor, result.rootNodes); @@ -1350,6 +1399,9 @@ describe('HtmlParser', () => { visitBlockParameter(parameter: html.BlockParameter, context: any) { throw Error('Unexpected'); } + visitLetDeclaration(decl: html.LetDeclaration, context: any) { + throw Error('Unexpected'); + } })(); const result = parser.parse('
', 'TestComp'); const traversal = html.visitAll(visitor, result.rootNodes); diff --git a/packages/compiler/test/ml_parser/util/util.ts b/packages/compiler/test/ml_parser/util/util.ts index 1d2f9be8674..dda926ac8fb 100644 --- a/packages/compiler/test/ml_parser/util/util.ts +++ b/packages/compiler/test/ml_parser/util/util.ts @@ -50,6 +50,10 @@ class _SerializerVisitor implements html.Visitor { return parameter.expression; } + visitLetDeclaration(decl: html.LetDeclaration, context: any) { + return `@let ${decl.name} = ${decl.value};`; + } + private _visitAll(nodes: html.Node[], separator = '', prefix = ''): string { return nodes.length > 0 ? prefix + nodes.map((a) => a.visit(this, null)).join(separator) : ''; } diff --git a/packages/localize/tools/src/translate/translation_files/base_visitor.ts b/packages/localize/tools/src/translate/translation_files/base_visitor.ts index e447ef06856..2d1dce27878 100644 --- a/packages/localize/tools/src/translate/translation_files/base_visitor.ts +++ b/packages/localize/tools/src/translate/translation_files/base_visitor.ts @@ -13,6 +13,7 @@ import { Element, Expansion, ExpansionCase, + LetDeclaration, Text, Visitor, } from '@angular/compiler'; @@ -31,4 +32,5 @@ export class BaseVisitor implements Visitor { visitExpansionCase(_expansionCase: ExpansionCase, _context: any): any {} visitBlock(_block: Block, _context: any) {} visitBlockParameter(_parameter: BlockParameter, _context: any) {} + visitLetDeclaration(_decl: LetDeclaration, _context: any) {} }