|
6 | 6 | * found in the LICENSE file at https://angular.io/license |
7 | 7 | */ |
8 | 8 |
|
| 9 | +import * as chars from '../chars'; |
| 10 | +import {Lexer, Token, TokenType} from '../expression_parser/lexer'; |
9 | 11 | import * as html from '../ml_parser/ast'; |
10 | | -import {ParseError} from '../parse_util'; |
| 12 | +import {ParseError, ParseSourceSpan} from '../parse_util'; |
11 | 13 | import {BindingParser} from '../template_parser/binding_parser'; |
12 | 14 |
|
13 | 15 | import * as t from './r3_ast'; |
14 | 16 |
|
15 | 17 | /** Pattern for a timing value in a trigger. */ |
16 | 18 | const TIME_PATTERN = /^\d+(ms|s)?$/; |
17 | 19 |
|
| 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 | + |
18 | 40 | /** Parses a `when` deferred trigger. */ |
19 | 41 | export function parseWhenTrigger( |
20 | 42 | {expression, sourceSpan}: html.BlockParameter, bindingParser: BindingParser, |
21 | 43 | 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); |
24 | 58 | } |
25 | 59 |
|
26 | 60 | /** Parses an `on` trigger */ |
27 | 61 | export function parseOnTrigger( |
28 | 62 | {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(); |
31 | 74 | } |
32 | 75 |
|
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 | + } |
36 | 119 |
|
37 | | - if (start === -1) { |
38 | | - return startPosition; |
| 120 | + private advance() { |
| 121 | + this.index++; |
39 | 122 | } |
40 | 123 |
|
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 | + } |
43 | 329 | } |
44 | 330 |
|
45 | | - return start; |
| 331 | + return -1; |
46 | 332 | } |
47 | 333 |
|
48 | 334 | /** |
|
0 commit comments