Skip to content

Commit 08dd967

Browse files
committed
Reverse JS<->TS dependency: javascript.ts is the standalone base, typescript.ts imports it
JavaScript is the subset/base of the ECMAScript family; the subset must not depend on the superset. Move the shared vocabulary -- the 16 token consts, notReserved/notReservedExpr, the precedence ladder (ecmaPrec), and the base scope map (jsScopes) -- into examples/javascript.ts, which now imports ONLY ../src/api.ts and stands alone. examples/typescript.ts imports that vocabulary and extends it: its scopes reuse jsScopes' shared keys by reference and interleave the TS-only entries (interface/type/enum/namespace, the keyof/as/satisfies/... operators, the primitive type names) at their original positions; its rules add the type layer. Behavior-preserving for TypeScript: typescript.tmLanguage.json byte-identical, conformance 100%/97.84%, coverage 99.3%, sanity 15/15, agnostic 5/5, refactor-guard 112/112. JavaScript unchanged (js-conformance 61/61 accepted, 8/10 rejected). javascript.ts has no import from typescript.ts. NOTE (do not "simplify" to a spread): the TS scopes object is explicitly ordered, reusing jsScopes by key -- NOT { ...jsScopes, ...tsOnly }. A spread (a) appends the TS-only keys at the end, reordering the keyword groups emitted in negative-lookahead patterns, and (b) flips the first-occurrence scope of the shared literal "module" (support.variable in JS vs storage.type.namespace in TS), which scopeOverrides keys by first occurrence -- dropping the module-declaration construct. Both change the generated grammar; the explicit form is the only byte-identical one. (Dependency reversal implemented by a delegated agent; independently re-verified byte-identical + all gates green.)
1 parent 87505d5 commit 08dd967

2 files changed

Lines changed: 268 additions & 237 deletions

File tree

examples/javascript.ts

Lines changed: 190 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,20 @@
1-
// JavaScript grammar for Monogram.
1+
// JavaScript grammar for Monogram — the STANDALONE BASE of the ECMAScript family.
22
//
3-
// JavaScript is a syntactic subset of TypeScript, so this grammar is the TS
4-
// grammar (examples/typescript.ts) with every type construct removed: no type
3+
// JavaScript is the syntactic SUBSET of TypeScript (TS = JS + a type layer), so
4+
// this file owns the shared, type-free ECMAScript *vocabulary* — the token set,
5+
// the `notReserved`/`notReservedExpr` reserved-word guards, the precedence ladder
6+
// (`ecmaPrec`), and the JS scope map (`jsScopes`) — and exports it. The TS grammar
7+
// (the sibling superset file) then imports that vocabulary and EXTENDS it with the
8+
// type layer. The dependency runs subset → superset only: this file imports nothing
9+
// from the TS grammar (it has no type knowledge) and must stand alone — its only
10+
// import is the engine's combinator API.
11+
//
12+
// The rules are NOT shared either direction: combinator rules bind their
13+
// references at definition time, so a JS rule must reference the OTHER JS rule
14+
// consts — it can't reuse TS's rule objects (and vice-versa). Each file therefore
15+
// keeps its own rule consts; only the vocabulary above is shared.
16+
//
17+
// This grammar is the TS grammar with every type construct removed: no type
518
// annotations, type parameters/arguments, `interface`/`type`/`enum`/`namespace`/
619
// `declare`/`module`-as-type declarations, `as`/`satisfies`/`<T>`-cast/non-null
720
// (`!`) expressions, and no type-optional `?` on params/members. Everything that
@@ -10,24 +23,136 @@
1023
// operator set (`??`, `?.`, `**`, optional chaining), destructuring, template
1124
// literals (tagged + interpolated), regex, every module import/export VALUE form,
1225
// control flow, numeric literals, and Stage-3 decorators on classes.
13-
//
14-
// The type-free *vocabulary* (tokens, the precedence ladder) is imported from
15-
// typescript.ts rather than duplicated; the rules are copied and stripped here
16-
// because combinator rules bind their references at definition time — a JS rule
17-
// must reference the OTHER JS rule consts, so it can't reuse the TS rule objects.
1826

1927
import {
20-
rule, defineGrammar,
28+
token, rule, defineGrammar,
2129
left, right, none, noUnaryLhs,
2230
op, prefix, postfix, sameLine,
2331
sep, opt, many, many1, alt, exclude, not,
2432
} from '../src/api.ts';
25-
import {
26-
ecmaTokens, ecmaPrec,
33+
34+
// ── Tokens ──
35+
36+
const Ident = token(/(?:[a-zA-Z_$]|\\u[0-9a-fA-F]{4}|\\u\{[0-9a-fA-F]+\})(?:[a-zA-Z0-9_$]|\\u[0-9a-fA-F]{4}|\\u\{[0-9a-fA-F]+\})*/, { identifier: true });
37+
// Numeric tokens end with `(?![0-9A-Za-z_$\\])`: the spec rule that a numeric literal
38+
// may not be immediately followed by an IdentifierStart or DecimalDigit. Without it,
39+
// `0b2`/`0B1102110`/`0o81010` would munch a valid prefix (`0b1`, `0B110`) and leave the
40+
// rest as a second token, so the file parses as two statements instead of being rejected.
41+
// With it the bad literal matches no token and the lexer throws — the correct rejection.
42+
// (ASCII IdentifierStart + `\` for `\u`-escapes; the lexer compiles patterns without the
43+
// /u flag so \p{L} is unavailable, and every affected conformance case is ASCII.)
44+
const HexNumber = token(/0[xX][0-9a-fA-F]+(_[0-9a-fA-F]+)*(?![0-9A-Za-z_$\\])/, { scope: 'constant.numeric.hex' });
45+
const OctalNumber = token(/0[oO][0-7]+(_[0-7]+)*(?![0-9A-Za-z_$\\])/, { scope: 'constant.numeric.octal' });
46+
const BinaryNumber = token(/0[bB][01]+(_[01]+)*(?![0-9A-Za-z_$\\])/, { scope: 'constant.numeric.binary' });
47+
const BigInt_ = token(/[0-9]+(_[0-9]+)*n(?![0-9A-Za-z_$\\])/, { scope: 'constant.numeric.bigint' });
48+
const Number_ = token(/[0-9]+(_[0-9]+)*(?:\.[0-9]*(_[0-9]+)*)?(?:[eE][+-]?[0-9]+(_[0-9]+)*)?(?![0-9A-Za-z_$\\])/);
49+
// A well-formed JS escape, used in the string-body pattern below. `\u`/`\x` must
50+
// match their strict forms — a `\u{cp}` with cp ≤ 0x10FFFF, a 4-hex `\uXXXX`, or a
51+
// 2-hex `\xXX` — while `\` + any *other* char (\n, \\, \q non-escape, line
52+
// continuation) stays valid via `[^ux]`. A malformed `\u`/`\x` (e.g. `\u{110000}`,
53+
// `\u{r}`, `\u{}`, `\u{67`) matches no escape, so the string matches no token and the
54+
// lexer throws — TS's exact rejection. The in-range codepoint is `0*` leading zeros
55+
// then 1–5 hex (0–0xFFFFF) or `10`+4 hex (0x100000–0x10FFFF).
56+
const codePoint = String.raw`0*(?:[0-9a-fA-F]{1,5}|10[0-9a-fA-F]{4})`;
57+
const escape = String.raw`\\(?:u\{${codePoint}\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|[^ux])`;
58+
const String_ = token(new RegExp(`"(?:[^"\\\\]|${escape})*"|'(?:[^'\\\\]|${escape})*'`), {
59+
string: true,
60+
escape: /\\(?:[nrtbfv0'"\\]|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|u\{[0-9a-fA-F]+\})/,
61+
});
62+
const Template = token(/`(?:[^`\\$]|\\.|\$(?!\{))*`/, {
63+
escape: /\\(?:[nrtbfv0'"\\`$]|x[0-9a-fA-F]{2}|u[0-9a-fA-F]{4}|u\{[0-9a-fA-F]+\})/,
64+
// Same well-formed-escape rule as strings; the lexer rejects a malformed `\u`/`\x`
65+
// in an *untagged* template (`\u{110000}`, `\u{r}`), but allows it when tagged.
66+
escapeValid: new RegExp(escape),
67+
template: { open: '`', interpOpen: '${', interpClose: '}' },
68+
});
69+
const Regex_ = token(/\/(?:[^\/\\\[\n]|\\.|\[(?:[^\]\\\n]|\\.)*\])+\/[gimsuydv]*/, {
70+
regex: true,
71+
regexContext: {
72+
divisionAfterTypes: ['Ident', 'Number', 'String', 'Template', 'BigInt'],
73+
divisionAfterTexts: [')', ']', '++', '--', 'this', 'super', 'true', 'false', 'null', 'undefined'],
74+
regexAfterTexts: ['in', 'of', 'instanceof', 'typeof', 'delete', 'void', 'await', 'yield', 'throw', 'return', 'case', 'do', 'else', 'new'],
75+
// `kw ( … )` heads (control-flow): the closing `)` is a statement head, not a
76+
// value, so `if (a) /re/` parses `/re/` as a regex rather than division.
77+
regexAfterParenKeywords: ['if', 'while', 'for', 'with'],
78+
// member accessors: after one, those keywords are property NAMES, so
79+
// `obj.for(x) / y` stays a method call + division.
80+
memberAccessTexts: ['.', '?.'],
81+
},
82+
});
83+
const Decorator = token(/@(?:[a-zA-Z_$][a-zA-Z0-9_$.]*)?/, { scope: 'entity.name.function.decorator' });
84+
const PrivateField = token(/#[a-zA-Z_$][a-zA-Z0-9_$]*/, { scope: 'variable.other.property' });
85+
const Shebang = token(/^#![^\n]*/, { skip: true, scope: 'comment.line.shebang' });
86+
const JSDoc = token(/\/\*\*(?!\/)[\s\S]*?\*\//, { skip: true, scope: 'comment.block.documentation', embed: 'jsdoc' });
87+
const TripleSlash = token(/\/\/\/\s*<[^\n]*/, { skip: true, scope: 'comment.line.triple-slash' });
88+
const LineComment = token(/\/\/[^\n]*/, { skip: true });
89+
const BlockComment = token(/\/\*[\s\S]*?\*\//, { skip: true });
90+
91+
// The token consts, reserved-word guards, precedence ladder, and scope map are
92+
// pure ECMAScript vocabulary — no rule wiring — so the TS grammar imports them from
93+
// here and extends them rather than duplicating them.
94+
export {
95+
Shebang, JSDoc, TripleSlash, LineComment, BlockComment,
2796
Ident, HexNumber, OctalNumber, BinaryNumber, BigInt_,
2897
Number_, String_, Template, Regex_, Decorator, PrivateField,
29-
notReserved, notReservedExpr,
30-
} from './typescript.ts';
98+
};
99+
100+
// ── Always-reserved words ──
101+
// The `Ident` token deliberately swallows keywords (they lex as identifiers), so
102+
// every keyword can otherwise fall back to a bare identifier. These words are
103+
// reserved in EVERY context (ECMAScript ReservedWord ∪ TS's always-reserved), so
104+
// they are valid as an identifier NOWHERE — not as an expression, a shorthand
105+
// property, or a binding name. `notReserved` is a zero-width guard placed before an
106+
// identifier position to forbid exactly these. Excluded on purpose: contextual
107+
// keywords (as/async/from/type/of/…) and strict-mode-only reserved words
108+
// (let/static/implements/yield/await/…) — those ARE valid identifiers in some
109+
// context a CFG can't detect (sloppy mode, non-generator/non-async), so forbidding
110+
// them here would reject valid code (`var let = 1`, `function f(yield) {}`).
111+
export const notReserved = not(alt(
112+
'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default',
113+
'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for',
114+
'function', 'if', 'import', 'in', 'instanceof', 'new', 'null', 'return', 'super',
115+
'switch', 'this', 'throw', 'true', 'try', 'typeof', 'var', 'void', 'while', 'with',
116+
));
117+
118+
// A NARROWER guard for the *expression* identifier-NUD only. The full `notReserved`
119+
// set can NOT be used at expression position: most always-reserved words legitimately
120+
// begin an expression via their own dedicated forms (`new`/`new.target`, `class`/
121+
// `function` expressions, `import(…)`/`import<T>`, `super`, `this`, `true`/`false`/
122+
// `null`, …), and TS's own error-recovery tolerates several reserved words sliding into
123+
// the bare-identifier fallback inside otherwise-valid files (e.g. `export default …`,
124+
// undeclared `for (x in …)`, `class … extends (e)`, a decorator before `export`). The
125+
// words below have NO such role: they are the prefix operators `void`/`typeof`/`delete`
126+
// (which must take an operand) plus the `catch`/`throw` keywords and `enum`. Forbidding
127+
// the bare-identifier fallback for exactly these rejects `catch(x){}` with no `try`,
128+
// `void ;`/`typeof ;`/`delete ;` (operatorless prefix op), and `throw ;` — while leaving
129+
// every valid expression (and TS's recovery cases) untouched. Verified: widening this
130+
// set to other reserved words regresses valid code; these five are the FN-safe maximum.
131+
export const notReservedExpr = not(alt(
132+
'catch', 'delete', 'enum', 'throw', 'typeof', 'void',
133+
));
134+
135+
// ── Precedence ladder (shared ECMAScript operator precedence) ──
136+
137+
export const ecmaPrec = [
138+
right('=', '+=', '-=', '*=', '/=', '%=', '**=', '<<=', '>>=', '>>>=', '&=', '|=', '^='),
139+
right('??=', '||=', '&&='),
140+
left('??'),
141+
left('||'),
142+
left('&&'),
143+
left('|'),
144+
left('^'),
145+
left('&'),
146+
none('==', '!=', '===', '!=='),
147+
none('<', '>', '<=', '>='),
148+
left('<<', '>>', '>>>'),
149+
left('+', '-'),
150+
left('*', '/', '%'),
151+
right(noUnaryLhs('**')), // `-x ** y` is a syntax error: a unary-prefix expr can't be a `**` LHS
152+
right(prefix('!', '~', '+', '-', 'typeof', 'void', 'delete', 'await', 'yield')),
153+
right(prefix('++', '--')),
154+
left(postfix('++', '--')),
155+
];
31156

32157
// ── Decorators ──
33158
// Stage-3 / JS-real decorators: `@dec` and `@dec(args)`. The TS-only
@@ -296,13 +421,63 @@ const Program = rule($ => [
296421
many(alt(Decl, Stmt)), // Decl first: prefer declaration over IIFE expression-statement
297422
]);
298423

424+
// ── Scope map ──
425+
// The JS scope map: scopes every real-JS token. The TS grammar imports this and
426+
// extends it with the type-only scope keys (storage.type.interface/type/enum/
427+
// namespace, the keyof/as/satisfies/is/infer/asserts keyword.operator.expression
428+
// entries, support.type.primitive) — those names are plain identifiers in JS, not
429+
// types, so they are deliberately absent here.
430+
export const jsScopes = {
431+
'keyword.control.conditional': ['if', 'else', 'switch', 'case', 'default'],
432+
'keyword.control.loop': ['for', 'while', 'do', 'in', 'of'],
433+
'keyword.control.flow': ['return', 'break', 'continue', 'await', 'yield'],
434+
'keyword.control.trycatch': ['try', 'catch', 'finally', 'throw'],
435+
'keyword.control': ['debugger', 'with'],
436+
'keyword.control.import': ['import', 'export', 'from'],
437+
'storage.type': ['let', 'const', 'var', 'using'],
438+
'storage.type.function': ['function', 'constructor'],
439+
'storage.type.class': ['class'],
440+
'storage.modifier': ['static', 'async', 'accessor'],
441+
'storage.type.property': ['get', 'set'],
442+
'keyword.other.extends': ['extends'],
443+
'keyword.operator.expression': ['instanceof', 'new', 'delete', 'void', 'typeof'],
444+
'keyword.operator.assignment': ['=', '+=', '-=', '*=', '/=', '%=', '**=', '<<=', '>>=', '>>>=', '&=', '|=', '^=', '??=', '||=', '&&='],
445+
'keyword.operator.comparison': ['==', '!=', '===', '!=='],
446+
'keyword.operator.logical': ['||', '&&', '??'],
447+
'keyword.operator.arithmetic': ['+', '-', '*', '/', '%', '**'],
448+
'keyword.operator.increment-decrement': ['++', '--'],
449+
'keyword.operator.logical.prefix': ['!', '~'],
450+
'keyword.operator.bitwise': ['|', '&', '^'],
451+
'keyword.operator.bitwise.shift': ['<<', '>>', '>>>'],
452+
'storage.type.function.arrow': ['=>'],
453+
'punctuation.bracket.round': ['(', ')'],
454+
'punctuation.bracket.curly': ['{', '}'],
455+
'punctuation.bracket.square': ['[', ']'],
456+
'punctuation.accessor': ['.'],
457+
'punctuation.accessor.optional': ['?.'],
458+
'punctuation.terminator.statement': [';'],
459+
'punctuation.separator.comma': [','],
460+
'constant.language.boolean': ['true', 'false'],
461+
'constant.language.null': ['null', 'undefined'],
462+
'variable.language': ['this', 'super'],
463+
'support.class': ['Promise', 'Array', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Error', 'RegExp', 'Date', 'Object', 'Function', 'Symbol'],
464+
'support.variable': ['console', 'window', 'document', 'process', 'module', 'require', 'exports', 'global', 'globalThis'],
465+
'support.variable.property': ['.length', '.prototype', '.constructor'],
466+
};
467+
299468
// ── Grammar ──
300469

301470
export default defineGrammar({
302471
name: 'javascript',
303472
scopeName: 'source.js',
304473

305-
tokens: ecmaTokens,
474+
tokens: {
475+
// Comments must come before Regex_ to avoid /** ... */ being matched as regex
476+
Shebang, JSDoc, TripleSlash, LineComment, BlockComment,
477+
Ident, HexNumber, OctalNumber, BinaryNumber, BigInt: BigInt_,
478+
Number: Number_, String: String_, Template, Regex: Regex_,
479+
Decorator, PrivateField,
480+
},
306481

307482
prec: ecmaPrec,
308483

@@ -317,47 +492,7 @@ export default defineGrammar({
317492
Program,
318493
},
319494

320-
// JS subset of ecmaScopes: dropped storage.type.interface/type/enum/namespace,
321-
// the type-operator keyword.operator.expression entries (keyof/as/satisfies/is/
322-
// infer/asserts), and support.type.primitive (those names are plain identifiers
323-
// in JS, not types). Kept everything that scopes real JS tokens.
324-
scopes: {
325-
'keyword.control.conditional': ['if', 'else', 'switch', 'case', 'default'],
326-
'keyword.control.loop': ['for', 'while', 'do', 'in', 'of'],
327-
'keyword.control.flow': ['return', 'break', 'continue', 'await', 'yield'],
328-
'keyword.control.trycatch': ['try', 'catch', 'finally', 'throw'],
329-
'keyword.control': ['debugger', 'with'],
330-
'keyword.control.import': ['import', 'export', 'from'],
331-
'storage.type': ['let', 'const', 'var', 'using'],
332-
'storage.type.function': ['function', 'constructor'],
333-
'storage.type.class': ['class'],
334-
'storage.modifier': ['static', 'async', 'accessor'],
335-
'storage.type.property': ['get', 'set'],
336-
'keyword.other.extends': ['extends'],
337-
'keyword.operator.expression': ['instanceof', 'new', 'delete', 'void', 'typeof'],
338-
'keyword.operator.assignment': ['=', '+=', '-=', '*=', '/=', '%=', '**=', '<<=', '>>=', '>>>=', '&=', '|=', '^=', '??=', '||=', '&&='],
339-
'keyword.operator.comparison': ['==', '!=', '===', '!=='],
340-
'keyword.operator.logical': ['||', '&&', '??'],
341-
'keyword.operator.arithmetic': ['+', '-', '*', '/', '%', '**'],
342-
'keyword.operator.increment-decrement': ['++', '--'],
343-
'keyword.operator.logical.prefix': ['!', '~'],
344-
'keyword.operator.bitwise': ['|', '&', '^'],
345-
'keyword.operator.bitwise.shift': ['<<', '>>', '>>>'],
346-
'storage.type.function.arrow': ['=>'],
347-
'punctuation.bracket.round': ['(', ')'],
348-
'punctuation.bracket.curly': ['{', '}'],
349-
'punctuation.bracket.square': ['[', ']'],
350-
'punctuation.accessor': ['.'],
351-
'punctuation.accessor.optional': ['?.'],
352-
'punctuation.terminator.statement': [';'],
353-
'punctuation.separator.comma': [','],
354-
'constant.language.boolean': ['true', 'false'],
355-
'constant.language.null': ['null', 'undefined'],
356-
'variable.language': ['this', 'super'],
357-
'support.class': ['Promise', 'Array', 'Map', 'Set', 'WeakMap', 'WeakSet', 'Error', 'RegExp', 'Date', 'Object', 'Function', 'Symbol'],
358-
'support.variable': ['console', 'window', 'document', 'process', 'module', 'require', 'exports', 'global', 'globalThis'],
359-
'support.variable.property': ['.length', '.prototype', '.constructor'],
360-
},
495+
scopes: jsScopes,
361496

362497
entry: Program,
363498
});

0 commit comments

Comments
 (0)