refactor(compiler): implement let declarations in html ast (#55848)

Adds a new `LetDeclaration` node to the AST that captures the `LetStart`, `LetValue` and `LetEnd` tokens into a single node.

PR Close #55848
This commit is contained in:
Kristiyan Kostadinov 2024-04-30 14:33:08 +02:00 committed by Jessica Janiuk
parent 5ee235729c
commit d44c8ee98c
16 changed files with 219 additions and 2 deletions

View file

@ -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;

View file

@ -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.
*

View file

@ -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));
}

View file

@ -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));
}

View file

@ -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));
}

View file

@ -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<T extends Node>(
context: any,
cb: (visit: <V extends Node>(children: V[] | undefined) => void) => void,

View file

@ -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 {

View file

@ -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

View file

@ -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 <name> = <value>;\``,
),
);
}
private _getContainer(): NodeContainer | null {
return this._containerStack.length > 0
? this._containerStack[this._containerStack.length - 1]

View file

@ -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});
}
}

View file

@ -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();

View file

@ -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`.

View file

@ -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());

View file

@ -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 <name> = <value>;`',
'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('<div id="foo"></div><div id="bar"></div>', 'TestComp');
const traversal = html.visitAll(visitor, result.rootNodes);

View file

@ -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) : '';
}

View file

@ -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) {}
}