Skip to content

Commit c8ecef5

Browse files
committed
Reject unary-prefix left operand of '**' (TS17006)
`-x ** y` / `typeof x ** y` are syntax errors in JS/TS: the left operand of `**` must be an UpdateExpression, not a UnaryExpression (write `(-x) ** y` or `-(x ** y)`). We parsed it silently as `(-x) ** y`. New declarable, language-agnostic operator property: `noUnaryLhs('**')` marks an infix op whose left operand may not be a bare unary-prefix expression. The engine enforces it generically — when such an op is about to bind and the lhs is a prefix-op node whose operator is NOT also a postfix (i.e. a pure unary like `-`/`!`/`typeof`, as opposed to an update `++`/`--`), the whole expression fails hard (returns null). Failing hard (not just declining to bind) is required so it can't reparse another way: left-assoc `(x ** -y) ** z`, or `typeof` degrading to a bare identifier that splits the statement. The check sits at the LED, so `(-x) ** y` is fine (lhs is a paren node, not a prefix node). It's a general property — Python allows `-x ** y` and simply wouldn't declare it. Correct across the boundary: rejects `-x ** y`, `typeof/void/delete/await x ** y`, `x ** -y ** z`, `1 ** -2 ** 3`; still parses `x ** -y`, `(-x) ** y`, `-(x ** y)`, `++x ** y`, `x++ ** y`, `2 ** 3 ** 4`, `-x * y`, `a = -b`. FP 93->88 (5 es7/exponentiationOperator cases), FN stays 0 (3376/3376), TP unchanged. agnostic 5/5, refactor-guard 112/112, highlighter 99.3% unchanged, smoke pass. Artifacts regenerated.
1 parent e632ff8 commit c8ecef5

4 files changed

Lines changed: 42 additions & 7 deletions

File tree

examples/typescript.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import {
22
token, rule, defineGrammar,
3-
left, right, none,
3+
left, right, none, noUnaryLhs,
44
op, prefix, postfix, sameLine,
55
sep, opt, many, many1, alt, exclude, not,
66
} from '../src/api.ts';
@@ -509,7 +509,7 @@ export default defineGrammar({
509509
left('<<', '>>', '>>>'),
510510
left('+', '-'),
511511
left('*', '/', '%'),
512-
right('**'),
512+
right(noUnaryLhs('**')), // `-x ** y` is a syntax error: a unary-prefix expr can't be a `**` LHS
513513
right(prefix('!', '~', '+', '-', 'typeof', 'void', 'delete', 'await', 'yield')),
514514
right(prefix('++', '--')),
515515
left(postfix('++', '--')),

src/api.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,7 @@ interface PostfixSlot {
7979
}
8080
interface PrefixOps { readonly __kind: 'prefix-ops'; ops: string[] }
8181
interface PostfixOps { readonly __kind: 'postfix-ops'; ops: string[] }
82+
interface NoUnaryLhsOps { readonly __kind: 'no-unary-lhs-ops'; ops: string[] }
8283

8384
type Marker = OpMarker | PrefixSlot | PostfixSlot | SameLineMarker;
8485

@@ -97,6 +98,13 @@ export const postfix: PostfixSlot = Object.assign(
9798
{ __kind: 'postfix' as const },
9899
) as PostfixSlot;
99100

101+
// Mark infix operators whose LEFT operand may not be a bare unary-prefix expression
102+
// (a prefix-op result that is NOT also an update `++`/`--`). E.g. JS `**`: `-x ** y`
103+
// is a syntax error (write `(-x) ** y` or `-(x ** y)`), but `x ** -y`, `(-x) ** y`,
104+
// and `++x ** y` are fine. A general, declarable property — Python, by contrast,
105+
// allows `-x ** y` and would not use this. The engine enforces it generically.
106+
export const noUnaryLhs = (...ops: string[]): NoUnaryLhsOps => ({ __kind: 'no-unary-lhs-ops' as const, ops });
107+
100108
// ── Combinators ──
101109

102110
class SepNode {
@@ -192,29 +200,33 @@ interface PrecLevelDef {
192200
operators: PrecOperator[];
193201
}
194202

195-
function buildPrecOps(ops: (string | PrefixOps | PostfixOps)[]): PrecOperator[] {
203+
type OpSpec = string | PrefixOps | PostfixOps | NoUnaryLhsOps;
204+
205+
function buildPrecOps(ops: OpSpec[]): PrecOperator[] {
196206
const result: PrecOperator[] = [];
197207
for (const o of ops) {
198208
if (typeof o === 'string') {
199209
result.push({ value: o, position: 'infix' });
200210
} else if (o.__kind === 'prefix-ops') {
201211
for (const v of o.ops) result.push({ value: v, position: 'prefix' });
202-
} else {
212+
} else if (o.__kind === 'postfix-ops') {
203213
for (const v of o.ops) result.push({ value: v, position: 'postfix' });
214+
} else {
215+
for (const v of o.ops) result.push({ value: v, position: 'infix', noUnaryLhs: true });
204216
}
205217
}
206218
return result;
207219
}
208220

209-
export function left(...ops: (string | PrefixOps | PostfixOps)[]): PrecLevelDef {
221+
export function left(...ops: OpSpec[]): PrecLevelDef {
210222
return { __kind: 'prec-level', assoc: 'left', operators: buildPrecOps(ops) };
211223
}
212224

213-
export function right(...ops: (string | PrefixOps | PostfixOps)[]): PrecLevelDef {
225+
export function right(...ops: OpSpec[]): PrecLevelDef {
214226
return { __kind: 'prec-level', assoc: 'right', operators: buildPrecOps(ops) };
215227
}
216228

217-
export function none(...ops: (string | PrefixOps | PostfixOps)[]): PrecLevelDef {
229+
export function none(...ops: OpSpec[]): PrecLevelDef {
218230
return { __kind: 'prec-level', assoc: 'none', operators: buildPrecOps(ops) };
219231
}
220232

src/gen-parser.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,12 @@ export function createParser(grammar: CstGrammar) {
4242
// Build precedence table
4343
const opTable = new Map<string, OpInfo>();
4444
const prefixOps = new Map<string, OpInfo>();
45+
// Infix ops whose LEFT operand may not be a bare unary-prefix expression (e.g. `**`).
46+
// A prefix op that is NOT also a postfix op is a "pure unary" prefix (`-`/`!`/`typeof`…)
47+
// as opposed to an update (`++`/`--`, which are both prefix and postfix); only the
48+
// pure-unary ones are forbidden before a noUnaryLhs operator.
49+
const noUnaryLhsOps = new Set<string>();
50+
const postfixOpValues = new Set<string>();
4551

4652
for (let i = 0; i < grammar.precs.length; i++) {
4753
const level = grammar.precs[i];
@@ -55,6 +61,7 @@ export function createParser(grammar: CstGrammar) {
5561
position: 'prefix',
5662
});
5763
} else if (op.position === 'postfix') {
64+
postfixOpValues.add(op.value);
5865
opTable.set(op.value, {
5966
lbp: bp,
6067
rbp: 0,
@@ -65,6 +72,7 @@ export function createParser(grammar: CstGrammar) {
6572
const lbp = bp;
6673
const rbp = level.assoc === 'right' ? bp - 1 : bp;
6774
opTable.set(op.value, { lbp, rbp, assoc: level.assoc, position: 'infix' });
75+
if (op.noUnaryLhs) noUnaryLhsOps.add(op.value);
6876
}
6977
}
7078
}
@@ -706,6 +714,20 @@ export function createParser(grammar: CstGrammar) {
706714
matched = true;
707715
}
708716
} else {
717+
// A `noUnaryLhs` op (e.g. `**`) may not take a bare unary-prefix expression
718+
// (`-x`, `typeof x` — a prefix-op node whose op is NOT also a postfix, i.e.
719+
// not an update `++`/`--`) as its LEFT operand. Fail the whole expression
720+
// hard (return null) rather than just declining to bind — otherwise it could
721+
// reparse another way (left-assoc `(x ** -y) ** z`, or `typeof` as a bare
722+
// identifier splitting the statement). `(-x) ** y` is unaffected: lhs is then
723+
// a parenthesized node, not a prefix node.
724+
if (noUnaryLhsOps.has(tokKey) && lhs.kind === 'node') {
725+
const head = lhs.children[0];
726+
if (head?.kind === 'leaf' && head.tokenType === '$operator'
727+
&& prefixOps.has(head.text) && !postfixOpValues.has(head.text)) {
728+
return null;
729+
}
730+
}
709731
pos++;
710732
const opLeaf: CstLeaf = { kind: 'leaf', tokenType: '$operator', text: tok.text, offset: tok.offset, end: tok.offset + tok.text.length };
711733
const rhs = parsePratt(rule, info.rbp);

src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ export interface RegexContext {
3939
export interface PrecOperator {
4040
value: string;
4141
position: 'infix' | 'prefix' | 'postfix';
42+
noUnaryLhs?: boolean; // infix op whose left operand may not be a bare unary-prefix expression (e.g. JS `**`)
4243
}
4344

4445
export interface PrecLevel {

0 commit comments

Comments
 (0)