Skip to content

Commit 53cd528

Browse files
dyclaude
andcommitted
Fix reserved words being consumed as keywords after .
Member property names are IdentifierNames, not expressions, so `a.class`, `a?.function` etc. wrongly invoked keyword handlers. Add a `member` registrar whose right side is parsed as a name (falling back to expr for #private/numeric), replace `binary('.')` in access/prop with it, and use the shared `propName` for `?.` too. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent 85fe555 commit 53cd528

6 files changed

Lines changed: 45 additions & 10 deletions

File tree

feature/access.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
* Property access: a.b, a[b], a(b), [1,2,3] - parse half
33
* For private fields (#x), see class.js
44
*/
5-
import { access, binary } from '../parse.js';
5+
import { access, member } from '../parse.js';
66

77
const ACCESS = 170;
88

99
// a[b]
1010
access('[]', ACCESS);
1111

12-
// a.b
13-
binary('.', ACCESS);
12+
// a.b - property name is an IdentifierName (reserved words allowed)
13+
member('.', ACCESS);
1414

1515
// a(b,c,d), a()
1616
access('()', ACCESS);

feature/op/optional.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
* a?.[x] → optional computed access
66
* a?.() → optional call
77
*/
8-
import { parse, token, expr, skip } from '../../parse.js';
8+
import { parse, token, expr, skip, propName } from '../../parse.js';
99

1010
const ACCESS = 170;
1111

@@ -16,7 +16,7 @@ token('?.', ACCESS, (a, b) => {
1616
if (cc === 40) { skip(); return ['?.()', a, expr(0, 41) || null]; }
1717
// Optional computed: a?.[x]
1818
if (cc === 91) { skip(); return ['?.[]', a, expr(0, 93)]; }
19-
// Optional member: a?.b
20-
b = expr(ACCESS);
19+
// Optional member: a?.b - property name is an IdentifierName
20+
b = propName(ACCESS);
2121
return b ? ['?.', a, b] : void 0;
2222
});

feature/prop.js

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22
* Minimal property access: a.b, a[b], f() - parse half
33
* For array literals, private fields, see member.js
44
*/
5-
import { access, binary, group } from '../parse.js';
5+
import { access, member, group } from '../parse.js';
66

77
const ACCESS = 170;
88

99
// a[b] - computed member access only (no array literal support)
1010
access('[]', ACCESS);
1111

12-
// a.b - dot member access
13-
binary('.', ACCESS);
12+
// a.b - dot member access; property name is an IdentifierName
13+
member('.', ACCESS);
1414

1515
// (a) - grouping only (no sequences)
1616
group('()', ACCESS);

parse.js

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
1-
// Pratt parser core + operator registry + compile
1+
// Pratt parser core + operator registry + compile.
2+
//
3+
// Language-agnostic by design: the core (token / lookup / prec / the expr loop)
4+
// assumes no particular language. The registrars below — binary, unary, nary,
5+
// literal, group, access, member, keyword — are a shared toolkit of common
6+
// operator *shapes*; each is parameterized by operator string + precedence, so a
7+
// dialect composes its grammar from them. Keep language-specific rules in
8+
// feature/*, not here.
9+
210
// Character codes
311
const SPACE = 32;
412

@@ -116,6 +124,16 @@ export let idx, cur,
116124

117125
access = (op, p) => token(op[0], p, a => (a && [op, a, expr(0, op.charCodeAt(1)) || null])),
118126

127+
// propName(p) - parse the right side of a name-access operator. A bare name
128+
// beats keyword/operator matching, so reserved words read as plain identifiers
129+
// (a.class). Non-name starts (digit, #, ...) fall back to expr(p), keeping the
130+
// door open for any dialect-defined token there. Uses the live parse.id.
131+
propName = (p, c) => (parse.space(), c = cur.charCodeAt(idx), parse.id(c) && (c < 48 || c > 57) ? next(parse.id) : expr(p)),
132+
133+
// member(op, p) - binary operator whose right side is a name, not an expression
134+
// (a.b, a::b, a->b). Same [op, a, b] shape as binary().
135+
member = (op, p) => token(op, p, a => a && (b => b && [op, a, b])(propName(p))),
136+
119137
// keyword(op, prec, fn) - prefix word token with property name support
120138
// parse.prop set by collection.js to prevent matching {keyword: value}
121139
keyword = (op, prec, map, c = op.charCodeAt(0), l = op.length, prev = lookup[c], r) =>

subscript.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export function literal(op: string, val: any): void;
2828
export function nary(op: string, prec: number, right?: boolean): void;
2929
export function group(op: string, prec: number): void;
3030
export function access(op: string, prec: number): void;
31+
export function member(op: string, prec: number): void;
32+
export function propName(prec: number): AST;
3133

3234
// Compile exports
3335
export const operators: Record<string, Operator>;

test/jessie.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,21 @@ test('jessie: inherits justin', () => {
2424
is(parse('{a: 1}'), ['{}', [':', 'a', [, 1]]])
2525
})
2626

27+
// === reserved words as member property names ===
28+
// After `.`/`?.` the grammar is an IdentifierName - reserved words are plain names.
29+
30+
test('jessie: reserved words in member access', () => {
31+
is(parse('record.class.name'), ['.', ['.', 'record', 'class'], 'name'])
32+
is(parse('record.function.kind'), ['.', ['.', 'record', 'function'], 'kind'])
33+
is(parse('a.if.else'), ['.', ['.', 'a', 'if'], 'else'])
34+
is(parse('a?.class'), ['?.', 'a', 'class'])
35+
is(parse('a?.function'), ['?.', 'a', 'function'])
36+
is(parse('a. class'), ['.', 'a', 'class']) // whitespace before name
37+
is(parse('a.#x'), ['.', 'a', '#x']) // private name still works
38+
is(parse('a.b(class X{})'), ['()', ['.', 'a', 'b'], ['class', 'X', null, null]]) // class arg unaffected
39+
is(run('o.class + o.function', { o: { class: 2, function: 3 } }), 5)
40+
})
41+
2742
// === variables ===
2843

2944
test('jessie: variables', () => {

0 commit comments

Comments
 (0)