Skip to content

Commit 76d22ae

Browse files
crisbetothePunderWoman
authored andcommitted
refactor(compiler): add defer trigger parsing (#51050)
Adds the logic to parse the `when` and `on` triggers in a deferred block. PR Close #51050
1 parent 9e61616 commit 76d22ae

File tree

3 files changed

+634
-13
lines changed

3 files changed

+634
-13
lines changed

packages/compiler/src/render3/r3_deferred_triggers.ts

Lines changed: 299 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -6,43 +6,329 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9+
import * as chars from '../chars';
10+
import {Lexer, Token, TokenType} from '../expression_parser/lexer';
911
import * as html from '../ml_parser/ast';
10-
import {ParseError} from '../parse_util';
12+
import {ParseError, ParseSourceSpan} from '../parse_util';
1113
import {BindingParser} from '../template_parser/binding_parser';
1214

1315
import * as t from './r3_ast';
1416

1517
/** Pattern for a timing value in a trigger. */
1618
const TIME_PATTERN = /^\d+(ms|s)?$/;
1719

20+
/** Pattern for a separator between keywords in a trigger expression. */
21+
const SEPARATOR_PATTERN = /^\s$/;
22+
23+
/** Pairs of characters that form syntax that is comma-delimited. */
24+
const COMMA_DELIMITED_SYNTAX = new Map([
25+
[chars.$LBRACE, chars.$RBRACE], // Object literals
26+
[chars.$LBRACKET, chars.$RBRACKET], // Array literals
27+
[chars.$LPAREN, chars.$RPAREN], // Function calls
28+
]);
29+
30+
/** Possible types of `on` triggers. */
31+
enum OnTriggerType {
32+
IDLE = 'idle',
33+
TIMER = 'timer',
34+
INTERACTION = 'interaction',
35+
IMMEDIATE = 'immediate',
36+
HOVER = 'hover',
37+
VIEWPORT = 'viewport',
38+
}
39+
1840
/** Parses a `when` deferred trigger. */
1941
export function parseWhenTrigger(
2042
{expression, sourceSpan}: html.BlockParameter, bindingParser: BindingParser,
2143
errors: ParseError[]): t.BoundDeferredTrigger|null {
22-
// TODO
23-
return null;
44+
const whenIndex = expression.indexOf('when');
45+
46+
// This is here just to be safe, we shouldn't enter this function
47+
// in the first place if a block doesn't have the "when" keyword.
48+
if (whenIndex === -1) {
49+
errors.push(new ParseError(sourceSpan, `Could not find "when" keyword in expression`));
50+
return null;
51+
}
52+
53+
const start = getTriggerParametersStart(expression, whenIndex + 1);
54+
const parsed = bindingParser.parseBinding(
55+
expression.slice(start), false, sourceSpan, sourceSpan.start.offset + start);
56+
57+
return new t.BoundDeferredTrigger(parsed, sourceSpan);
2458
}
2559

2660
/** Parses an `on` trigger */
2761
export function parseOnTrigger(
2862
{expression, sourceSpan}: html.BlockParameter, errors: ParseError[]): t.DeferredTrigger[] {
29-
//
30-
return [];
63+
const onIndex = expression.indexOf('on');
64+
65+
// This is here just to be safe, we shouldn't enter this function
66+
// in the first place if a block doesn't have the "on" keyword.
67+
if (onIndex === -1) {
68+
errors.push(new ParseError(sourceSpan, `Could not find "on" keyword in expression`));
69+
return [];
70+
}
71+
72+
const start = getTriggerParametersStart(expression, onIndex + 1);
73+
return new OnTriggerParser(expression, start, sourceSpan, errors).parse();
3174
}
3275

33-
/** Gets the index within an expression at which the trigger parameters start. */
34-
export function getTriggerParametersStart(value: string, startPosition = 0): number {
35-
let start = value.indexOf(' ', startPosition);
76+
class OnTriggerParser {
77+
private index = 0;
78+
private tokens: Token[];
79+
private triggers: t.DeferredTrigger[] = [];
80+
81+
constructor(
82+
private expression: string, private start: number, private span: ParseSourceSpan,
83+
private errors: ParseError[]) {
84+
this.tokens = new Lexer().tokenize(expression.slice(start));
85+
}
86+
87+
parse(): t.DeferredTrigger[] {
88+
while (this.tokens.length > 0 && this.index < this.tokens.length) {
89+
const token = this.token();
90+
91+
if (!token.isIdentifier()) {
92+
this.unexpectedToken(token);
93+
break;
94+
}
95+
96+
// An identifier immediately followed by a comma or the end of
97+
// the expression cannot have parameters so we can exit early.
98+
if (this.isFollowedByOrLast(chars.$COMMA)) {
99+
this.consumeTrigger(token, []);
100+
this.advance();
101+
} else if (this.isFollowedByOrLast(chars.$LPAREN)) {
102+
this.advance(); // Advance to the opening paren.
103+
const prevErrors = this.errors.length;
104+
const parameters = this.consumeParameters();
105+
if (this.errors.length !== prevErrors) {
106+
break;
107+
}
108+
this.consumeTrigger(token, parameters);
109+
this.advance(); // Advance past the closing paren.
110+
} else if (this.index < this.tokens.length - 1) {
111+
this.unexpectedToken(this.tokens[this.index + 1]);
112+
}
113+
114+
this.advance();
115+
}
116+
117+
return this.triggers;
118+
}
36119

37-
if (start === -1) {
38-
return startPosition;
120+
private advance() {
121+
this.index++;
39122
}
40123

41-
while (value[start] === ' ') {
42-
start++;
124+
private isFollowedByOrLast(char: number): boolean {
125+
if (this.index === this.tokens.length - 1) {
126+
return true;
127+
}
128+
129+
return this.tokens[this.index + 1].isCharacter(char);
130+
}
131+
132+
private token(): Token {
133+
return this.tokens[Math.min(this.index, this.tokens.length - 1)];
134+
}
135+
136+
private consumeTrigger(identifier: Token, parameters: string[]) {
137+
const startSpan = this.span.start.moveBy(this.start + identifier.index - this.tokens[0].index);
138+
const endSpan = startSpan.moveBy(this.token().end - identifier.index);
139+
const sourceSpan = new ParseSourceSpan(startSpan, endSpan);
140+
141+
try {
142+
switch (identifier.toString()) {
143+
case OnTriggerType.IDLE:
144+
this.triggers.push(createIdleTrigger(parameters, sourceSpan));
145+
break;
146+
147+
case OnTriggerType.TIMER:
148+
this.triggers.push(createTimerTrigger(parameters, sourceSpan));
149+
break;
150+
151+
case OnTriggerType.INTERACTION:
152+
this.triggers.push(createInteractionTrigger(parameters, sourceSpan));
153+
break;
154+
155+
case OnTriggerType.IMMEDIATE:
156+
this.triggers.push(createImmediateTrigger(parameters, sourceSpan));
157+
break;
158+
159+
case OnTriggerType.HOVER:
160+
this.triggers.push(createHoverTrigger(parameters, sourceSpan));
161+
break;
162+
163+
case OnTriggerType.VIEWPORT:
164+
this.triggers.push(createViewportTrigger(parameters, sourceSpan));
165+
break;
166+
167+
default:
168+
throw new Error(`Unrecognized trigger type "${identifier}"`);
169+
}
170+
} catch (e) {
171+
this.error(identifier, (e as Error).message);
172+
}
173+
}
174+
175+
private consumeParameters(): string[] {
176+
const parameters: string[] = [];
177+
178+
if (!this.token().isCharacter(chars.$LPAREN)) {
179+
this.unexpectedToken(this.token());
180+
return parameters;
181+
}
182+
183+
this.advance();
184+
185+
const commaDelimStack: number[] = [];
186+
let current = '';
187+
188+
while (this.index < this.tokens.length) {
189+
const token = this.token();
190+
191+
// Stop parsing if we've hit the end character and we're outside of a comma-delimited syntax.
192+
// Note that we don't need to account for strings here since the lexer already parsed them
193+
// into string tokens.
194+
if (token.isCharacter(chars.$RPAREN) && commaDelimStack.length === 0) {
195+
if (current.length) {
196+
parameters.push(current);
197+
}
198+
break;
199+
}
200+
201+
// In the `on` microsyntax "top-level" commas (e.g. ones outside of an parameters) separate
202+
// the different triggers (e.g. `on idle,timer(500)`). This is problematic, because the
203+
// function-like syntax also implies that multiple parameters can be passed into the
204+
// individual trigger (e.g. `on foo(a, b)`). To avoid tripping up the parser with commas that
205+
// are part of other sorts of syntax (object literals, arrays), we treat anything inside
206+
// a comma-delimited syntax block as plain text.
207+
if (token.type === TokenType.Character && COMMA_DELIMITED_SYNTAX.has(token.numValue)) {
208+
commaDelimStack.push(COMMA_DELIMITED_SYNTAX.get(token.numValue)!);
209+
}
210+
211+
if (commaDelimStack.length > 0 &&
212+
token.isCharacter(commaDelimStack[commaDelimStack.length - 1])) {
213+
commaDelimStack.pop();
214+
}
215+
216+
// If we hit a comma outside of a comma-delimited syntax, it means
217+
// that we're at the top level and we're starting a new parameter.
218+
if (commaDelimStack.length === 0 && token.isCharacter(chars.$COMMA) && current.length > 0) {
219+
parameters.push(current);
220+
current = '';
221+
this.advance();
222+
continue;
223+
}
224+
225+
// Otherwise treat the token as a plain text character in the current parameter.
226+
current += this.tokenText();
227+
this.advance();
228+
}
229+
230+
if (!this.token().isCharacter(chars.$RPAREN) || commaDelimStack.length > 0) {
231+
this.error(this.token(), 'Unexpected end of expression');
232+
}
233+
234+
if (this.index < this.tokens.length - 1 &&
235+
!this.tokens[this.index + 1].isCharacter(chars.$COMMA)) {
236+
this.unexpectedToken(this.tokens[this.index + 1]);
237+
}
238+
239+
return parameters;
240+
}
241+
242+
private tokenText(): string {
243+
// Tokens have a toString already which we could use, but for string tokens it omits the quotes.
244+
// Eventually we could expose this information on the token directly.
245+
return this.expression.slice(this.start + this.token().index, this.start + this.token().end);
246+
}
247+
248+
private error(token: Token, message: string): void {
249+
const newStart = this.span.start.moveBy(this.start + token.index);
250+
const newEnd = newStart.moveBy(token.end - token.index);
251+
this.errors.push(new ParseError(new ParseSourceSpan(newStart, newEnd), message));
252+
}
253+
254+
private unexpectedToken(token: Token) {
255+
this.error(token, `Unexpected token "${token}"`);
256+
}
257+
}
258+
259+
function createIdleTrigger(
260+
parameters: string[], sourceSpan: ParseSourceSpan): t.IdleDeferredTrigger {
261+
if (parameters.length > 0) {
262+
throw new Error(`"${OnTriggerType.IDLE}" trigger cannot have parameters`);
263+
}
264+
265+
return new t.IdleDeferredTrigger(sourceSpan);
266+
}
267+
268+
function createTimerTrigger(parameters: string[], sourceSpan: ParseSourceSpan) {
269+
if (parameters.length !== 1) {
270+
throw new Error(`"${OnTriggerType.TIMER}" trigger must have exactly one parameter`);
271+
}
272+
273+
const delay = parseDeferredTime(parameters[0]);
274+
275+
if (delay === null) {
276+
throw new Error(`Could not parse time value of trigger "${OnTriggerType.TIMER}"`);
277+
}
278+
279+
return new t.TimerDeferredTrigger(delay, sourceSpan);
280+
}
281+
282+
function createInteractionTrigger(
283+
parameters: string[], sourceSpan: ParseSourceSpan): t.InteractionDeferredTrigger {
284+
if (parameters.length > 1) {
285+
throw new Error(`"${OnTriggerType.INTERACTION}" trigger can only have zero or one parameters`);
286+
}
287+
288+
return new t.InteractionDeferredTrigger(parameters[0] ?? null, sourceSpan);
289+
}
290+
291+
function createImmediateTrigger(
292+
parameters: string[], sourceSpan: ParseSourceSpan): t.ImmediateDeferredTrigger {
293+
if (parameters.length > 0) {
294+
throw new Error(`"${OnTriggerType.IMMEDIATE}" trigger cannot have parameters`);
295+
}
296+
297+
return new t.ImmediateDeferredTrigger(sourceSpan);
298+
}
299+
300+
function createHoverTrigger(
301+
parameters: string[], sourceSpan: ParseSourceSpan): t.HoverDeferredTrigger {
302+
if (parameters.length > 0) {
303+
throw new Error(`"${OnTriggerType.HOVER}" trigger cannot have parameters`);
304+
}
305+
306+
return new t.HoverDeferredTrigger(sourceSpan);
307+
}
308+
309+
function createViewportTrigger(
310+
parameters: string[], sourceSpan: ParseSourceSpan): t.ViewportDeferredTrigger {
311+
// TODO: the RFC has some more potential parameters for `viewport`.
312+
if (parameters.length > 1) {
313+
throw new Error(`"${OnTriggerType.VIEWPORT}" trigger can only have zero or one parameters`);
314+
}
315+
316+
return new t.ViewportDeferredTrigger(parameters[0] ?? null, sourceSpan);
317+
}
318+
319+
/** Gets the index within an expression at which the trigger parameters start. */
320+
export function getTriggerParametersStart(value: string, startPosition = 0): number {
321+
let hasFoundSeparator = false;
322+
323+
for (let i = startPosition; i < value.length; i++) {
324+
if (SEPARATOR_PATTERN.test(value[i])) {
325+
hasFoundSeparator = true;
326+
} else if (hasFoundSeparator) {
327+
return i;
328+
}
43329
}
44330

45-
return start;
331+
return -1;
46332
}
47333

48334
/**

0 commit comments

Comments
 (0)