Skip to content

Commit 3bbbf39

Browse files
ayazhafizjosephperrott
authored andcommitted
feat(compiler): Recover on malformed keyed reads and keyed writes (angular#39004)
This patch adds support for recovering well-formed (and near-complete) ASTs for semantically malformed keyed reads and keyed writes. See the added tests for details on the types of semantics we can now recover; in particular, notice that some assumptions are made about the form of a keyed read/write intended by a user. For example, in the malformed expression `a[1 + = 2`, we assume that the user meant to write a binary expression for the key of `a`, and assign that key the value `2`. In particular, we now parse this as `a[1 + <empty expression>] = 2`. There are some different interpretations that can be made here, but I think this is reasonable. The actual changes in the parser code are fairly minimal (a nice surprise!); the biggest addition is a `writeContext` that marks whether the `=` operator can serve as a recovery point after error detection. Part of angular#38596 PR Close angular#39004
1 parent 904adb7 commit 3bbbf39

File tree

2 files changed

+159
-26
lines changed

2 files changed

+159
-26
lines changed

packages/compiler/src/expression_parser/parser.ts

Lines changed: 72 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -288,10 +288,24 @@ export class IvyParser extends Parser {
288288
simpleExpressionChecker = IvySimpleExpressionChecker; //
289289
}
290290

291+
/** Describes a stateful context an expression parser is in. */
292+
enum ParseContextFlags {
293+
None = 0,
294+
/**
295+
* A Writable context is one in which a value may be written to an lvalue.
296+
* For example, after we see a property access, we may expect a write to the
297+
* property via the "=" operator.
298+
* prop
299+
* ^ possible "=" after
300+
*/
301+
Writable = 1,
302+
}
303+
291304
export class _ParseAST {
292305
private rparensExpected = 0;
293306
private rbracketsExpected = 0;
294307
private rbracesExpected = 0;
308+
private context = ParseContextFlags.None;
295309

296310
// Cache of expression start and input indeces to the absolute source span they map to, used to
297311
// prevent creating superfluous source spans in `sourceSpan`.
@@ -368,6 +382,16 @@ export class _ParseAST {
368382
this.index++;
369383
}
370384

385+
/**
386+
* Executes a callback in the provided context.
387+
*/
388+
private withContext<T>(context: ParseContextFlags, cb: () => T): T {
389+
this.context |= context;
390+
const ret = cb();
391+
this.context ^= context;
392+
return ret;
393+
}
394+
371395
consumeOptionalCharacter(code: number): boolean {
372396
if (this.next.isCharacter(code)) {
373397
this.advance();
@@ -384,6 +408,12 @@ export class _ParseAST {
384408
return this.next.isKeywordAs();
385409
}
386410

411+
/**
412+
* Consumes an expected character, otherwise emits an error about the missing expected character
413+
* and skips over the token stream until reaching a recoverable point.
414+
*
415+
* See `this.error` and `this.skip` for more details.
416+
*/
387417
expectCharacter(code: number) {
388418
if (this.consumeOptionalCharacter(code)) return;
389419
this.error(`Missing expected ${String.fromCharCode(code)}`);
@@ -631,18 +661,23 @@ export class _ParseAST {
631661
result = this.parseAccessMemberOrMethodCall(result, true);
632662

633663
} else if (this.consumeOptionalCharacter(chars.$LBRACKET)) {
634-
this.rbracketsExpected++;
635-
const key = this.parsePipe();
636-
this.rbracketsExpected--;
637-
this.expectCharacter(chars.$RBRACKET);
638-
if (this.consumeOptionalOperator('=')) {
639-
const value = this.parseConditional();
640-
result = new KeyedWrite(
641-
this.span(resultStart), this.sourceSpan(resultStart), result, key, value);
642-
} else {
643-
result = new KeyedRead(this.span(resultStart), this.sourceSpan(resultStart), result, key);
644-
}
645-
664+
this.withContext(ParseContextFlags.Writable, () => {
665+
this.rbracketsExpected++;
666+
const key = this.parsePipe();
667+
if (key instanceof EmptyExpr) {
668+
this.error(`Key access cannot be empty`);
669+
}
670+
this.rbracketsExpected--;
671+
this.expectCharacter(chars.$RBRACKET);
672+
if (this.consumeOptionalOperator('=')) {
673+
const value = this.parseConditional();
674+
result = new KeyedWrite(
675+
this.span(resultStart), this.sourceSpan(resultStart), result, key, value);
676+
} else {
677+
result =
678+
new KeyedRead(this.span(resultStart), this.sourceSpan(resultStart), result, key);
679+
}
680+
});
646681
} else if (this.consumeOptionalCharacter(chars.$LPAREN)) {
647682
this.rparensExpected++;
648683
const args = this.parseCallArguments();
@@ -994,6 +1029,10 @@ export class _ParseAST {
9941029
this.consumeOptionalCharacter(chars.$SEMICOLON) || this.consumeOptionalCharacter(chars.$COMMA);
9951030
}
9961031

1032+
/**
1033+
* Records an error and skips over the token stream until reaching a recoverable point. See
1034+
* `this.skip` for more details on token skipping.
1035+
*/
9971036
error(message: string, index: number|null = null) {
9981037
this.errors.push(new ParserError(message, this.input, this.locationText(index), this.location));
9991038
this.skip();
@@ -1005,25 +1044,32 @@ export class _ParseAST {
10051044
`at the end of the expression`;
10061045
}
10071046

1008-
// Error recovery should skip tokens until it encounters a recovery point. skip() treats
1009-
// the end of input and a ';' as unconditionally a recovery point. It also treats ')',
1010-
// '}' and ']' as conditional recovery points if one of calling productions is expecting
1011-
// one of these symbols. This allows skip() to recover from errors such as '(a.) + 1' allowing
1012-
// more of the AST to be retained (it doesn't skip any tokens as the ')' is retained because
1013-
// of the '(' begins an '(' <expr> ')' production). The recovery points of grouping symbols
1014-
// must be conditional as they must be skipped if none of the calling productions are not
1015-
// expecting the closing token else we will never make progress in the case of an
1016-
// extraneous group closing symbol (such as a stray ')'). This is not the case for ';' because
1017-
// parseChain() is always the root production and it expects a ';'.
1018-
1019-
// If a production expects one of these token it increments the corresponding nesting count,
1020-
// and then decrements it just prior to checking if the token is in the input.
1047+
/**
1048+
* Error recovery should skip tokens until it encounters a recovery point. skip() treats
1049+
* the end of input and a ';' as unconditionally a recovery point. It also treats ')',
1050+
* '}' and ']' as conditional recovery points if one of calling productions is expecting
1051+
* one of these symbols. This allows skip() to recover from errors such as '(a.) + 1' allowing
1052+
* more of the AST to be retained (it doesn't skip any tokens as the ')' is retained because
1053+
* of the '(' begins an '(' <expr> ')' production). The recovery points of grouping symbols
1054+
* must be conditional as they must be skipped if none of the calling productions are not
1055+
* expecting the closing token else we will never make progress in the case of an
1056+
* extraneous group closing symbol (such as a stray ')'). This is not the case for ';' because
1057+
* parseChain() is always the root production and it expects a ';'.
1058+
*
1059+
* Furthermore, the presence of a stateful context can add more recovery points.
1060+
* - in a `Writable` context, we are able to recover after seeing the `=` operator, which
1061+
* signals the presence of an independent rvalue expression following the `=` operator.
1062+
*
1063+
* If a production expects one of these token it increments the corresponding nesting count,
1064+
* and then decrements it just prior to checking if the token is in the input.
1065+
*/
10211066
private skip() {
10221067
let n = this.next;
10231068
while (this.index < this.tokens.length && !n.isCharacter(chars.$SEMICOLON) &&
10241069
(this.rparensExpected <= 0 || !n.isCharacter(chars.$RPAREN)) &&
10251070
(this.rbracesExpected <= 0 || !n.isCharacter(chars.$RBRACE)) &&
1026-
(this.rbracketsExpected <= 0 || !n.isCharacter(chars.$RBRACKET))) {
1071+
(this.rbracketsExpected <= 0 || !n.isCharacter(chars.$RBRACKET)) &&
1072+
(!(this.context & ParseContextFlags.Writable) || !n.isOperator('='))) {
10271073
if (this.next.isError()) {
10281074
this.errors.push(
10291075
new ParserError(this.next.toString()!, this.input, this.locationText(), this.location));

packages/compiler/test/expression_parser/parser_spec.ts

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,84 @@ describe('parser', () => {
154154
});
155155
});
156156

157+
describe('keyed read', () => {
158+
it('should parse keyed reads', () => {
159+
checkAction('a["a"]');
160+
checkAction('this.a["a"]', 'a["a"]');
161+
checkAction('a.a["a"]');
162+
});
163+
164+
describe('malformed keyed reads', () => {
165+
it('should recover on missing keys', () => {
166+
checkActionWithError('a[]', 'a[]', 'Key access cannot be empty');
167+
});
168+
169+
it('should recover on incomplete expression keys', () => {
170+
checkActionWithError('a[1 + ]', 'a[1 + ]', 'Unexpected token ]');
171+
});
172+
173+
it('should recover on unterminated keys', () => {
174+
checkActionWithError(
175+
'a[1 + 2', 'a[1 + 2]', 'Missing expected ] at the end of the expression');
176+
});
177+
178+
it('should recover on incomplete and unterminated keys', () => {
179+
checkActionWithError(
180+
'a[1 + ', 'a[1 + ]', 'Missing expected ] at the end of the expression');
181+
});
182+
});
183+
});
184+
185+
describe('keyed write', () => {
186+
it('should parse keyed writes', () => {
187+
checkAction('a["a"] = 1 + 2');
188+
checkAction('this.a["a"] = 1 + 2', 'a["a"] = 1 + 2');
189+
checkAction('a.a["a"] = 1 + 2');
190+
});
191+
192+
describe('malformed keyed writes', () => {
193+
it('should recover on empty rvalues', () => {
194+
checkActionWithError('a["a"] = ', 'a["a"] = ', 'Unexpected end of expression');
195+
});
196+
197+
it('should recover on incomplete rvalues', () => {
198+
checkActionWithError('a["a"] = 1 + ', 'a["a"] = 1 + ', 'Unexpected end of expression');
199+
});
200+
201+
it('should recover on missing keys', () => {
202+
checkActionWithError('a[] = 1', 'a[] = 1', 'Key access cannot be empty');
203+
});
204+
205+
it('should recover on incomplete expression keys', () => {
206+
checkActionWithError('a[1 + ] = 1', 'a[1 + ] = 1', 'Unexpected token ]');
207+
});
208+
209+
it('should recover on unterminated keys', () => {
210+
checkActionWithError('a[1 + 2 = 1', 'a[1 + 2] = 1', 'Missing expected ]');
211+
});
212+
213+
it('should recover on incomplete and unterminated keys', () => {
214+
const ast = parseAction('a[1 + = 1');
215+
expect(unparse(ast)).toEqual('a[1 + ] = 1');
216+
validate(ast);
217+
218+
const errors = ast.errors.map(e => e.message);
219+
expect(errors.length).toBe(2);
220+
expect(errors[0]).toContain('Unexpected token =');
221+
expect(errors[1]).toContain('Missing expected ]');
222+
});
223+
224+
it('should error on writes after a keyed write', () => {
225+
const ast = parseAction('a[1] = 1 = 2');
226+
expect(unparse(ast)).toEqual('a[1] = 1');
227+
validate(ast);
228+
229+
expect(ast.errors.length).toBe(1);
230+
expect(ast.errors[0].message).toContain('Unexpected token \'=\'');
231+
});
232+
});
233+
});
234+
157235
describe('conditional', () => {
158236
it('should parse ternary/conditional expressions', () => {
159237
checkAction('7 == 3 + 4 ? 10 : 20');
@@ -926,3 +1004,12 @@ function expectActionError(text: string, message: string) {
9261004
function expectBindingError(text: string, message: string) {
9271005
expectError(validate(parseBinding(text)), message);
9281006
}
1007+
1008+
/**
1009+
* Check that an malformed action parses to a recovered AST while emitting an
1010+
* error.
1011+
*/
1012+
function checkActionWithError(text: string, expected: string, error: string) {
1013+
checkAction(text, expected);
1014+
expectActionError(text, error);
1015+
}

0 commit comments

Comments
 (0)