Skip to content

Commit da42bfd

Browse files
committed
JSX: scope HTML character entities; add a JSX highlighter gate; document the parser boundary
The TextMate highlighter already handles JSX raw text with arbitrary punctuation (`It's 100% & more!` → meta.jsx.children) because the children region is regex-based, not token-based. The one sub-token the official TypeScriptReact grammar lifts out of that flat text is the HTML character entity, which Monogram lumped into the children text. Add a `#jsx-entity` rule to the children region — `&nbsp;` / `&amp;` / `&#123;` / `&#x1F600;` → `constant.character.entity` with `&`/`;` as `punctuation.definition.entity`; a lone `&` with no `name;` tail stays plain children text, matching official. It is part of the JSX dialect patterns (gated behind JSX detection), so plain TS/JS output is byte-identical and the agnostic-engine gate still passes. Add test/tsx-highlight.ts (`npm run bench:tsx`): the JSX *highlighter* gate to complement tsx-conformance's JSX *parser* gate. 17 curated scope checks (tags, attributes, raw text, named/numeric/hex entities, lone-`&`, fragments) plus an opt-in drop-in agreement view vs the official grammar — JSX dialect exact 98.7% / family 100% over the corpus. Sharpen the tsx-conformance Corpus-3 note with the architectural reason the four raw-text cases stay a PARSER boundary (not a highlighter gap): JSX text is raw text, not a token sequence, and emitting it as one JSXText token needs a context-sensitive lexer mode — but Monogram lexes the whole source in one pass with no parser feedback and a deliberately grammar-agnostic lexer, so it cannot know it is between `>` and `</`. The highlighter has no such limit.
1 parent 6a60e73 commit da42bfd

5 files changed

Lines changed: 235 additions & 2 deletions

File tree

examples/tsx.tmLanguage.json

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -430,6 +430,20 @@
430430
"match": "[<>]",
431431
"name": "keyword.operator.relational.tsx"
432432
},
433+
"jsx-entity": {
434+
"match": "(&)(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*)(;)",
435+
"captures": {
436+
"1": {
437+
"name": "punctuation.definition.entity.tsx"
438+
},
439+
"2": {
440+
"name": "constant.character.entity.tsx"
441+
},
442+
"3": {
443+
"name": "punctuation.definition.entity.tsx"
444+
}
445+
}
446+
},
433447
"jsx-children": {
434448
"patterns": [
435449
{
@@ -443,6 +457,9 @@
443457
},
444458
{
445459
"include": "#jsx-expression"
460+
},
461+
{
462+
"include": "#jsx-entity"
446463
}
447464
]
448465
},

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
"conformance:tsx": "node test/tsx-conformance.ts",
1111
"bench": "node test/highlight-bench.ts",
1212
"bench:js": "node test/js-highlight-bench.ts",
13+
"bench:tsx": "node test/tsx-highlight.ts",
1314
"bench:perf": "node test/perf-bench.ts",
1415
"bench:readme": "node test/highlight-bench.ts --corpus adversarial --write-readme",
1516
"coverage": "node test/scope-coverage.ts"

src/gen-tm.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -750,12 +750,28 @@ function generateJsxPatterns(langName: string, identRegex: string, jsx: JsxInfo)
750750
`(?<!\\+\\+|--)(?<=[({\\[,?=:>&|]|&&|\\|\\||=>|\\breturn|\\byield|\\bdefault|\\bcase|^)\\s*`;
751751

752752
// ── jsx-children: what may appear between `>` and `</` ──
753+
// Raw text needs no pattern — anything that matches none of these falls through
754+
// to the enclosing region's `meta.jsx.children` contentName, so arbitrary text
755+
// punctuation (`It's 100% & more!`) is already covered. The one sub-token the
756+
// official grammar lifts out of that flat text is an HTML character entity
757+
// (`&nbsp;`, `&amp;`, `&#123;`, `&#x1F600;`), scoped `constant.character.entity`
758+
// with `&`/`;` as `punctuation.definition.entity`. A lone `&` (no `name;` tail)
759+
// matches nothing here and stays plain children text — matching official.
760+
result['jsx-entity'] = {
761+
match: '(&)(#[0-9]+|#[xX][0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]*)(;)',
762+
captures: {
763+
'1': { name: `punctuation.definition.entity.${langName}` },
764+
'2': { name: `constant.character.entity.${langName}` },
765+
'3': { name: `punctuation.definition.entity.${langName}` },
766+
},
767+
};
753768
result['jsx-children'] = {
754769
patterns: [
755770
{ include: '#jsx-self-closing-element' },
756771
{ include: '#jsx-element' },
757772
{ include: '#jsx-fragment' },
758773
{ include: '#jsx-expression' },
774+
{ include: '#jsx-entity' },
759775
],
760776
};
761777

test/tsx-conformance.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,15 @@ if (ts_fail.length) {
125125
}
126126

127127
console.log('\n── Corpus 3: known-unsupported valid TSX (raw-text children) ──');
128-
console.log(' (TS accepts these; Monogram does not — JSX text with arbitrary');
129-
console.log(' punctuation needs a JSX text-lexer mode this subset omits.)');
128+
console.log(' (TS accepts these; Monogram does not. JSX text is RAW TEXT, not a token');
129+
console.log(' sequence — `It\'s 100% & more!` has an unterminated string, a modulo, etc.');
130+
console.log(' Emitting it as one JSXText token needs a context-sensitive lexer mode, but');
131+
console.log(' Monogram lexes the whole source in ONE pass with NO parser feedback and a');
132+
console.log(' deliberately grammar-agnostic lexer (test/agnostic.ts) — it cannot know it');
133+
console.log(' is between `>` and `</`. The TextMate HIGHLIGHTER has no such limit (it is');
134+
console.log(' region-based): test/tsx-highlight.ts shows raw text + entities highlight');
135+
console.log(' correctly. So this is a PARSER-conformance boundary only, not a highlighter');
136+
console.log(' gap.)');
130137
for (const [name, code] of unsupported) {
131138
const we = weAccept(code), t = tsxAccepts(code);
132139
console.log(` - ${we ? 'accept' : 'reject'}${t ? '' : ' [TS also rejects — recheck]'}: ${name}`);

test/tsx-highlight.ts

Lines changed: 192 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,192 @@
1+
// ─────────────────────────────────────────────────────────────────────────────
2+
// tsx-highlight.ts — the JSX *highlighter* gate (tsx-conformance.ts is the JSX
3+
// *parser* gate). It checks the scopes Monogram's GENERATED examples/tsx.tmLanguage.json
4+
// emits for the JSX-dialect constructs the TS/JS benches can't reach: element &
5+
// fragment tags, attributes, expression containers, raw text children, and HTML
6+
// character entities.
7+
//
8+
// Two views:
9+
// 1. CURATED checks — a hard gate: specific JSX tokens must carry specific
10+
// scopes (the same style as test/issue-cases.ts). These encode the JSX
11+
// contract and catch regressions in the dialect patterns.
12+
// 2. DROP-IN agreement (opt-in: set MONOGRAM_OFFICIAL_TSX) — over a JSX corpus,
13+
// the share of official-emitted scopes Monogram matches at family / exact
14+
// granularity. For the JSX dialect the official TypeScriptReact grammar is
15+
// the de-facto reference (tsc exposes no neutral per-token JSX scope roles
16+
// the way scope-roles.ts does for TS), so this is an agreement measure, not
17+
// an absolute-accuracy one — hence opt-in and not the hard gate.
18+
//
19+
// Run: `node test/tsx-highlight.ts` (set MONOGRAM_OFFICIAL_TSX for the drop-in view)
20+
// ─────────────────────────────────────────────────────────────────────────────
21+
import vsctm from 'vscode-textmate';
22+
import onig from 'vscode-oniguruma';
23+
import { readFileSync, existsSync } from 'node:fs';
24+
import { createRequire } from 'node:module';
25+
import { scopeFamily } from './highlight-engines.ts';
26+
27+
const { INITIAL, Registry, parseRawGrammar } = vsctm;
28+
const require = createRequire(import.meta.url);
29+
const wasmBin = readFileSync(require.resolve('vscode-oniguruma/release/onig.wasm'));
30+
await onig.loadWASM(wasmBin.buffer.slice(wasmBin.byteOffset, wasmBin.byteOffset + wasmBin.byteLength));
31+
32+
function load(scopeName: string, path: string): Promise<vsctm.IGrammar | null> {
33+
const content = readFileSync(path, 'utf-8');
34+
return new Registry({
35+
onigLib: Promise.resolve({
36+
createOnigScanner: (p: string[]) => new onig.OnigScanner(p),
37+
createOnigString: (s: string) => new onig.OnigString(s),
38+
}),
39+
loadGrammar: async (sn: string) => (sn === scopeName ? parseRawGrammar(content, 'g.json') : null),
40+
}).loadGrammar(scopeName);
41+
}
42+
43+
interface Tok { start: number; end: number; text: string; scope: string }
44+
function tokenize(g: vsctm.IGrammar, text: string): Tok[] {
45+
const out: Tok[] = [];
46+
let rs = INITIAL, off = 0;
47+
for (const line of text.split('\n')) {
48+
const r = g.tokenizeLine(line, rs);
49+
for (const t of r.tokens) out.push({ start: off + t.startIndex, end: off + t.endIndex, text: line.slice(t.startIndex, t.endIndex), scope: t.scopes[t.scopes.length - 1] });
50+
rs = r.ruleStack; off += line.length + 1;
51+
}
52+
return out;
53+
}
54+
// Strip only a TRAILING language suffix (`…​.tsx`/`.ts`/`.js`); never a mid-path
55+
// `.jsx` (it is structural, e.g. `meta.jsx.children`).
56+
const norm = (s: string) => s.replace(/\.(tsx|ts|js)$/, '');
57+
58+
const MONO_PATH = 'examples/tsx.tmLanguage.json';
59+
if (!existsSync(MONO_PATH)) {
60+
console.error(`Monogram TSX grammar not found at ${MONO_PATH}. Run: node src/cli.ts examples/tsx.ts`);
61+
process.exit(1);
62+
}
63+
const mono = (await load('source.tsx', MONO_PATH))!;
64+
65+
// ── 1. Curated checks: (snippet, substring, expected-scope-substring) ──
66+
// The expected scope is matched modulo the language suffix and as a path prefix,
67+
// so `entity.name.tag` accepts `entity.name.tag.tsx`.
68+
const checks: { label: string; code: string; want: { text: string; scope: string }[] }[] = [
69+
{
70+
label: 'element tag + close',
71+
code: `const a = <div></div>;`,
72+
want: [
73+
{ text: 'div', scope: 'entity.name.tag' },
74+
{ text: '<', scope: 'punctuation.definition.tag.begin' },
75+
],
76+
},
77+
{
78+
label: 'attributes: name, =, string, expression container',
79+
code: `const b = <input type="text" value={v} />;`,
80+
want: [
81+
{ text: 'type', scope: 'entity.other.attribute-name' },
82+
{ text: 'text', scope: 'string.quoted.double' },
83+
{ text: 'value', scope: 'entity.other.attribute-name' },
84+
{ text: 'v', scope: 'variable.other' },
85+
],
86+
},
87+
{
88+
label: 'raw text with arbitrary punctuation → meta.jsx.children',
89+
code: `const c = <p>It's 100% & more (really)!</p>;`,
90+
want: [
91+
{ text: `It's 100% & more (really)!`, scope: 'meta.jsx.children' },
92+
],
93+
},
94+
{
95+
label: 'HTML named entity',
96+
code: `const d = <span>&nbsp;</span>;`,
97+
want: [
98+
{ text: '&', scope: 'punctuation.definition.entity' },
99+
{ text: 'nbsp', scope: 'constant.character.entity' },
100+
{ text: ';', scope: 'punctuation.definition.entity' },
101+
],
102+
},
103+
{
104+
label: 'HTML numeric + hex entity',
105+
code: `const e = <span>&#123;&#x1F600;</span>;`,
106+
want: [
107+
{ text: '#123', scope: 'constant.character.entity' },
108+
{ text: '#x1F600', scope: 'constant.character.entity' },
109+
],
110+
},
111+
{
112+
label: 'lone & in text stays plain children (no false entity)',
113+
code: `const f = <p>Tom & Jerry</p>;`,
114+
want: [
115+
{ text: 'Tom & Jerry', scope: 'meta.jsx.children' },
116+
],
117+
},
118+
{
119+
label: 'text interleaved with expression container',
120+
code: `const g = <p>Hello {name}, welcome</p>;`,
121+
want: [
122+
{ text: 'Hello ', scope: 'meta.jsx.children' },
123+
{ text: 'name', scope: 'variable.other' },
124+
{ text: ', welcome', scope: 'meta.jsx.children' },
125+
],
126+
},
127+
{
128+
label: 'fragment children',
129+
code: `const h = <><span>1</span></>;`,
130+
want: [
131+
{ text: 'span', scope: 'entity.name.tag' },
132+
],
133+
},
134+
];
135+
136+
const scopeAt = (toks: Tok[], text: string): string | null => {
137+
const t = toks.find((x) => x.text === text);
138+
return t ? norm(t.scope) : null;
139+
};
140+
let pass = 0, total = 0;
141+
const fails: string[] = [];
142+
for (const { label, code, want } of checks) {
143+
const toks = tokenize(mono, code);
144+
for (const w of want) {
145+
total++;
146+
const got = scopeAt(toks, w.text);
147+
// prefix match (modulo suffix): want `entity.name.tag` accepts `entity.name.tag.begin`? no —
148+
// we want the LEAF to START WITH the expected path, so `entity.name.tag` ⊆ `entity.name.tag`.
149+
if (got && (got === w.want || got.startsWith(w.scope))) pass++;
150+
else fails.push(` [${label}] «${w.text}» want ⊇ ${w.scope} got ${got ?? '(none)'}`);
151+
}
152+
}
153+
console.log('── JSX highlighter — curated scope checks ──');
154+
console.log(` ${pass}/${total} checks pass`);
155+
if (fails.length) { console.log(' FAILURES:'); for (const f of fails) console.log(f); }
156+
157+
// ── 2. Drop-in agreement vs the official TypeScriptReact grammar (opt-in) ──
158+
const OFF = process.env.MONOGRAM_OFFICIAL_TSX
159+
?? '/Applications/Visual Studio Code.app/Contents/Resources/app/extensions/typescript-basics/syntaxes/TypeScriptReact.tmLanguage.json';
160+
const CORPUS = [
161+
`const a = <div className="x" data-id={5}>It's 100% & more!</div>;`,
162+
`const b = <ul>{items.map(x => <li key={x.id}>{x.name}</li>)}</ul>;`,
163+
`const c = <span>&nbsp;&amp; entities &#123;</span>;`,
164+
`const d = <Foo.Bar baz={1}><Child /></Foo.Bar>;`,
165+
`const e = <><Header title="Hi" />{children}<Footer /></>;`,
166+
`const f = <input type="text" value={v} disabled {...rest} />;`,
167+
];
168+
if (existsSync(OFF)) {
169+
const off = (await load('source.tsx', OFF))!;
170+
let exact = 0, fam = 0, graded = 0;
171+
for (const code of CORPUS) {
172+
const mt = tokenize(mono, code), ot = tokenize(off, code);
173+
for (const o of ot) {
174+
if (!o.text.trim() || o.scope === 'source.tsx') continue; // only where official colors
175+
// only JSX-dialect tokens (skip the shared TS surface already graded elsewhere)
176+
if (!/\b(tag|jsx|attribute-name|character\.entity|definition\.entity|section\.embedded)\b/.test(o.scope)) continue;
177+
graded++;
178+
const m = mt.find((x) => x.start <= o.start && o.start < x.end);
179+
if (!m) continue;
180+
if (norm(m.scope) === norm(o.scope)) { exact++; fam++; }
181+
else if (scopeFamily(m.scope) === scopeFamily(o.scope)) fam++;
182+
}
183+
}
184+
console.log('\n── JSX dialect drop-in vs official TypeScriptReact (agreement, opt-in) ──');
185+
console.log(` graded ${graded} JSX tokens · exact ${(exact / graded * 100).toFixed(1)}% family ${(fam / graded * 100).toFixed(1)}%`);
186+
} else {
187+
console.log('\n (set MONOGRAM_OFFICIAL_TSX to also measure JSX drop-in agreement vs official)');
188+
}
189+
190+
const FLOOR = checks.reduce((n, c) => n + c.want.length, 0);
191+
if (pass < FLOOR) { console.log(`\n✗ JSX highlighter curated checks ${pass}/${FLOOR}`); process.exit(1); }
192+
console.log(`\n✓ JSX highlighter: ${pass}/${total} curated scope checks pass`);

0 commit comments

Comments
 (0)