diff --git a/CLAUDE.md b/CLAUDE.md index c9d71fe2..f147ab4b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -216,6 +216,14 @@ native ``, no Acrylic or Accent opacity slider. Spec and plan: Deliberately no semantic diagnostics: without a tsconfig or node_modules they would be noise. Every language loads on demand (one Vite chunk each). Prose (`.txt`, `.log`, `.csv`, subtitles) gets no gutter and no language. Token colours are the THEME's since #298 (`--p-code-*`). + **JSONC COMMENTS ARE COMMENTS** (#312; owner, 2026-10-07: "comments in jsonc arent read as + comments in prism"): the strict JSON grammar made every // line plain text under a squiggle. + `.jsonc`, `tsconfig(.*).json`, `jsconfig(.*).json`, `.eslintrc(.json)`, `.babelrc(.json)`, + `(.)devcontainer.json`, `wrangler/turbo/biome/deno.json` and every `.json` in a `.vscode` + folder go to `lib/jsoncMode.ts` (a tokenizer with the JSON grammar's own tags, comment tags, + brace folding) and `codeLint.jsoncErrors` (comments and trailing commas blanked to spaces, + offsets kept, then `JSON.parse`), so a missing comma is still underlined. `.json5` colours the + same with no linter (bare keys and single quotes). Plain `.json` stays strict. `jsonc` e2e. **A FILE THAT GROWS** (2026-08-31): "Follow the file" appends new bytes as they are written - a build log, an agent's transcript - and a file PAST THE 64MB CEILING now shows its TAIL (2MB) instead of an apology. Both are READ-ONLY, and structurally so: a followed diff --git a/package-lock.json b/package-lock.json index 401e00ee..6abb6c33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "prism", - "version": "0.94.1", + "version": "0.94.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "prism", - "version": "0.94.1", + "version": "0.94.2", "license": "MIT", "dependencies": { "@codemirror/commands": "^6.11.0", diff --git a/package.json b/package.json index 64f2d77f..85404888 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "prism", - "version": "0.94.1", + "version": "0.94.2", "description": "A fast, universal media viewer for Windows.", "author": "Max", "license": "MIT", diff --git a/src/renderer/src/components/CodeView.tsx b/src/renderer/src/components/CodeView.tsx index 2a4ab00f..5dea1612 100644 --- a/src/renderer/src/components/CodeView.tsx +++ b/src/renderer/src/components/CodeView.tsx @@ -27,7 +27,7 @@ import { ContextMenu, type MenuItem } from './ContextMenu' import { fileVerbs, tickIf } from '../lib/fileVerbs' import { lintKeymap } from '@codemirror/lint' import { isProse, langFor } from '../lib/codeLang' -import { jsonLinter, syntaxLinter } from '../lib/codeLint' +import { lintFor } from '../lib/codeLint' import { prismCodeTheme } from '../lib/codeTheme' import { setWrapPref, useWrapPref, wrapPref, wrapsFor, type WrapPref } from '../lib/codePrefs' import { reloadAction, stampChanged, touchesFile, type Stamp } from '../lib/fileReload' @@ -257,7 +257,7 @@ export function CodeView({ // match it. Runs on every path change, since the view outlives the file. useEffect(() => { let alive = true - const lang = langFor(name) + const lang = langFor(name, path) void Promise.all([ window.prism.readText(path), lang ? lang.load() : Promise.resolve([]), @@ -290,14 +290,9 @@ export function CodeView({ wrapComp.reconfigure(wrapFor(name, wrapPref())), langComp.reconfigure(langExt), // Squiggles need a grammar to be wrong against. A stream lexer has - // none, so those languages get colour and no claims about errors. - lintComp.reconfigure( - /\.jsonc?$|\.json5$/i.test(name) - ? [syntaxLinter, jsonLinter] - : lang?.parsed - ? syntaxLinter - : [] - ) + // none, so those languages get colour and no claims about errors; + // JSONC is the one lexed language with a checker of its own (#312). + lintComp.reconfigure(lintFor(lang)) ] }) report(body === disk ? null : body) diff --git a/src/renderer/src/lib/codeLang.ts b/src/renderer/src/lib/codeLang.ts index 7a6dc73a..be72c1ef 100644 --- a/src/renderer/src/lib/codeLang.ts +++ b/src/renderer/src/lib/codeLang.ts @@ -19,6 +19,8 @@ export type CodeLang = { readonly name: string /** True when the grammar can report syntax errors. */ readonly parsed: boolean + /** A checker beyond the grammar's error nodes: JSON.parse, or its JSONC form. */ + readonly lint?: 'json' | 'jsonc' readonly load: () => Promise } @@ -40,7 +42,21 @@ const JS = parsed('JavaScript', async () => (await import('@codemirror/lang-java const JSX = parsed('JSX', async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: true })) const TS = parsed('TypeScript', async () => (await import('@codemirror/lang-javascript')).javascript({ typescript: true })) const TSX = parsed('TSX', async () => (await import('@codemirror/lang-javascript')).javascript({ jsx: true, typescript: true })) -const JSON_ = parsed('JSON', async () => (await import('@codemirror/lang-json')).json()) +const JSON_: CodeLang = { + ...parsed('JSON', async () => (await import('@codemirror/lang-json')).json()), + lint: 'json' +} +// JSON with comments (#312): the strict grammar read every // line as an error. +// A tokenizer colours it and codeLint's jsoncErrors judges it. +const JSONC: CodeLang = { + name: 'JSON with Comments', + parsed: false, + lint: 'jsonc', + load: async () => (await import('./jsoncMode')).jsonc() +} +// JSON5 has bare keys, single quotes and hex, which jsoncErrors would call +// wrong: the same colours, and no claims about errors. +const JSON5: CodeLang = { name: 'JSON5', parsed: false, load: JSONC.load } const CSS = parsed('CSS', async () => (await import('@codemirror/lang-css')).css()) const HTML = parsed('HTML', async () => (await import('@codemirror/lang-html')).html()) const XML = parsed('XML', async () => (await import('@codemirror/lang-xml')).xml()) @@ -121,7 +137,7 @@ const COBOL = lexed('COBOL', async () => (await import('@codemirror/legacy-modes const BY_EXT: Record = { '.js': JS, '.mjs': JS, '.cjs': JS, '.jsx': JSX, '.ts': TS, '.mts': TS, '.cts': TS, '.tsx': TSX, - '.json': JSON_, '.jsonc': JSON_, '.json5': JSON_, '.ipynb': JSON_, + '.json': JSON_, '.jsonc': JSONC, '.json5': JSON5, '.ipynb': JSON_, '.css': CSS, '.scss': SCSS, '.sass': SASS, '.less': LESS, '.styl': STYLUS, '.html': HTML, '.xhtml': HTML, '.svelte': HTML, '.astro': HTML, '.vue': VUE, '.xml': XML, '.svg': XML, '.svgz': XML, @@ -189,8 +205,28 @@ const BY_NAME: Record = { '.editorconfig': PROPERTIES, '.env': PROPERTIES, '.prettierrc': JSON_, - '.eslintrc': JSON_, - '.babelrc': JSON_ + // The tools that read these allow comments in them, so JSONC (#312). + '.eslintrc': JSONC, + '.eslintrc.json': JSONC, + '.babelrc': JSONC, + '.babelrc.json': JSONC, + 'tsconfig.json': JSONC, + 'jsconfig.json': JSONC, + 'devcontainer.json': JSONC, + '.devcontainer.json': JSONC, + 'wrangler.json': JSONC, + 'turbo.json': JSONC, + 'biome.json': JSONC, + 'deno.json': JSONC +} + +/** `tsconfig.app.json` and the like: the variants read the same way. */ +const JSONC_FAMILY = /^(?:tsconfig|jsconfig)\..+\.json$/ + +/** The folder a path sits in, lower-cased, or '' without one. */ +function parentName(path: string): string { + const parts = path.split(/[\\/]/) + return (parts[parts.length - 2] ?? '').toLowerCase() } /** @@ -198,10 +234,13 @@ const BY_NAME: Record = { * Null is a real answer, not a failure: prose (`.txt`, `.log`) is meant to be * plain, and an unmapped extension reads better uncoloured than mis-coloured. */ -export function langFor(name: string): CodeLang | null { +export function langFor(name: string, path?: string): CodeLang | null { const n = name.toLowerCase() const byName = BY_NAME[n] if (byName) return byName + if (JSONC_FAMILY.test(n)) return JSONC + // VS Code's own folder: settings, launch, tasks, extensions all take comments. + if (path && n.endsWith('.json') && parentName(path) === '.vscode') return JSONC const ext = /\.[^.]*$/.exec(n)?.[0] ?? '' return BY_EXT[ext] ?? null } diff --git a/src/renderer/src/lib/codeLint.ts b/src/renderer/src/lib/codeLint.ts index 4fab759a..14cd0c12 100644 --- a/src/renderer/src/lib/codeLint.ts +++ b/src/renderer/src/lib/codeLint.ts @@ -1,6 +1,7 @@ import { ensureSyntaxTree, syntaxTree } from '@codemirror/language' import { linter, type Diagnostic } from '@codemirror/lint' -import type { EditorState } from '@codemirror/state' +import type { EditorState, Extension } from '@codemirror/state' +import type { CodeLang } from './codeLang' // The red underline. Prism does not run a language server, so this is honest // about what it knows: where a Lezer grammar failed to parse, and nothing more. @@ -88,8 +89,93 @@ export function jsonErrors(text: string): Diagnostic[] { } } +/** + * JSONC as plain JSON with the same offsets (#312): every comment and every + * trailing comma becomes spaces (line breaks kept), so `JSON.parse` judges + * what is left and a position it names is a position in the file. Strings are + * copied untouched, so a "//" inside a url stays. Exported for tests. + */ +export function blankJsonc(text: string): { json: string; unclosedComment: number | null } { + const out = text.split('') + const blank = (from: number, to: number): void => { + for (let i = from; i < to; i++) if (out[i] !== '\n' && out[i] !== '\r') out[i] = ' ' + } + // The last comma seen, until something other than space shows whether a + // closer follows it (comments are already gone by then). + let comma = -1 + let i = 0 + while (i < text.length) { + const c = text[i] + if (c === '"') { + comma = -1 + let j = i + 1 + while (j < text.length && text[j] !== '"' && text[j] !== '\n') j += text[j] === '\\' ? 2 : 1 + i = j + 1 + continue + } + if (c === '/' && text[i + 1] === '/') { + let j = i + while (j < text.length && text[j] !== '\n') j++ + blank(i, j) + i = j + continue + } + if (c === '/' && text[i + 1] === '*') { + const end = text.indexOf('*/', i + 2) + if (end < 0) { + blank(i, text.length) + return { json: out.join(''), unclosedComment: i } + } + blank(i, end + 2) + i = end + 2 + continue + } + if (c === ',') comma = i + else if (c === '}' || c === ']') { + if (comma >= 0) out[comma] = ' ' + comma = -1 + } else if (!/\s/.test(c)) comma = -1 + i++ + } + return { json: out.join(''), unclosedComment: null } +} + +/** + * JSON's rules less the two JSONC relaxes: comments and trailing commas are + * fine, a missing comma or an unclosed brace is still an error, said by + * `JSON.parse` at its real place in the file. Exported for tests. + */ +export function jsoncErrors(text: string): Diagnostic[] { + const { json, unclosedComment } = blankJsonc(text) + if (unclosedComment !== null) { + return [ + { + from: unclosedComment, + to: Math.min(unclosedComment + 2, text.length), + severity: 'error' as const, + message: 'This comment is never closed' + } + ] + } + return jsonErrors(json) +} + +/** + * Which squiggles a file gets. A grammar's own error nodes for a parsed + * language, plus `JSON.parse` for JSON; JSONC gets its own check and NEVER the + * grammar's (it has none that knows comments); a stream lexer gets nothing. + */ +export function lintFor(lang: CodeLang | null): Extension { + if (lang?.lint === 'jsonc') return jsoncLinter + if (lang?.lint === 'json') return [syntaxLinter, jsonLinter] + return lang?.parsed ? syntaxLinter : [] +} + /** Squiggles from the grammar. Only worth attaching to a parsed language. */ export const syntaxLinter = linter((view) => parseErrors(view.state), { delay: 300 }) /** Squiggles from JSON.parse, which explains itself better than the grammar can. */ export const jsonLinter = linter((view) => jsonErrors(view.state.doc.toString()), { delay: 300 }) + +/** Squiggles for JSON with comments: JSON's errors, never its comments. */ +export const jsoncLinter = linter((view) => jsoncErrors(view.state.doc.toString()), { delay: 300 }) diff --git a/src/renderer/src/lib/jsonc.test.ts b/src/renderer/src/lib/jsonc.test.ts new file mode 100644 index 00000000..fdb7f418 --- /dev/null +++ b/src/renderer/src/lib/jsonc.test.ts @@ -0,0 +1,154 @@ +import { ensureSyntaxTree, foldable } from '@codemirror/language' +import { EditorState } from '@codemirror/state' +import { classHighlighter, highlightTree } from '@lezer/highlight' +import { describe, expect, it } from 'vitest' +import { langFor } from './codeLang' +import { blankJsonc, jsoncErrors, lintFor, jsoncLinter, syntaxLinter, jsonLinter } from './codeLint' + +// #312 (owner, 2026-10-07: "comments in jsonc arent read as comments in +// prism"): every // line in a wrangler.jsonc was plain text under a squiggle. + +/** The owner's file, near enough: comments of both kinds and a trailing comma. */ +const WRANGLER = `/** + * For more details on how to configure Wrangler, refer to: + * https://developers.cloudflare.com/workers/wrangler/configuration/ + */ +{ + "$schema": "node_modules/wrangler/config-schema.json", + "name": "my-worker", + "main": "src/index.ts", + "compatibility_date": "2025-04-01", + // "compatibility_flags": ["nodejs_compat"], + "observability": { + "enabled": true, /* on by default */ + }, + "assets": { "directory": "./public/" }, +} +` + +async function stateFor(name: string, doc: string, path?: string): Promise { + const lang = langFor(name, path) + return EditorState.create({ doc, extensions: lang ? [await lang.load()] : [] }) +} + +/** Each highlighted run as [text, classes]. */ +function runs(state: EditorState): Array<[string, string]> { + const tree = ensureSyntaxTree(state, state.doc.length, 5000) + if (!tree) throw new Error('no tree') + const out: Array<[string, string]> = [] + highlightTree(tree, classHighlighter, (from, to, cls) => out.push([state.doc.sliceString(from, to), cls])) + return out +} + +describe('which files are JSONC', () => { + it('maps .jsonc and the well-known JSON-with-comments names', () => { + for (const n of [ + 'wrangler.jsonc', 'a.JSONC', 'tsconfig.json', 'tsconfig.app.json', 'tsconfig.node.json', + 'jsconfig.json', 'jsconfig.base.json', '.eslintrc.json', '.eslintrc', '.babelrc', '.babelrc.json', + 'devcontainer.json', '.devcontainer.json', 'wrangler.json', 'turbo.json', 'biome.json', 'deno.json' + ]) { + expect(langFor(n)?.lint, n).toBe('jsonc') + } + }) + + it('takes every .json in a .vscode folder, and only there', () => { + for (const n of ['settings.json', 'launch.json', 'tasks.json', 'extensions.json']) { + expect(langFor(n, `C:\\repo\\.vscode\\${n}`)?.lint, n).toBe('jsonc') + expect(langFor(n, `/home/u/repo/.vscode/${n}`)?.lint, n).toBe('jsonc') + expect(langFor(n, `C:\\repo\\config\\${n}`)?.lint, n).toBe('json') + } + }) + + it('keeps plain JSON strict', () => { + for (const n of ['package.json', 'a.json', 'tsconfigs.json', 'mytsconfig.json', 'composer.json']) { + expect(langFor(n)?.lint, n).toBe('json') + } + expect(langFor('a.ipynb')?.lint).toBe('json') + }) + + it('colours JSON5 the same way and makes no claims about it', () => { + expect(langFor('a.json5')?.name).toBe('JSON5') + expect(langFor('a.json5')?.lint).toBeUndefined() + expect(lintFor(langFor('a.json5'))).toEqual([]) + }) + + it('gives each kind its checker', () => { + expect(lintFor(langFor('a.jsonc'))).toBe(jsoncLinter) + expect(lintFor(langFor('a.json'))).toEqual([syntaxLinter, jsonLinter]) + expect(lintFor(langFor('a.py'))).toBe(syntaxLinter) + expect(lintFor(langFor('a.sh'))).toEqual([]) + expect(lintFor(null)).toEqual([]) + }) +}) + +describe('JSONC colours', () => { + it('tags both comment kinds as comments', async () => { + const r = runs(await stateFor('wrangler.jsonc', WRANGLER)) + const line = r.find(([t]) => t.startsWith('// "compatibility_flags"')) + expect(line?.[1]).toContain('tok-comment') + expect(r.find(([t]) => t === '/* on by default */')?.[1]).toContain('tok-comment') + // The block comment over four lines is comment on every line. + expect(r.filter(([t]) => t.includes('wrangler/configuration')).every(([, c]) => c.includes('tok-comment'))).toBe(true) + }) + + it('a // inside a string is a string, not a comment', async () => { + const r = runs(await stateFor('a.jsonc', '{ "url": "https://example.com" }')) + expect(r.find(([t]) => t === '"https://example.com"')?.[1]).toBe('tok-string') + }) + + it('colours JSON exactly as the JSON grammar does', async () => { + const doc = '{\n "a": 1.5,\n "b": [true, false, null, "s"],\n "c": { "d": -2e3 }\n}\n' + expect(runs(await stateFor('a.jsonc', doc))).toEqual(runs(await stateFor('a.json', doc))) + }) + + it('folds an object or array to its partner', async () => { + const state = await stateFor('wrangler.jsonc', WRANGLER) + ensureSyntaxTree(state, state.doc.length, 5000) + const lineOf = (text: string): { from: number; to: number } => { + for (let n = 1; n <= state.doc.lines; n++) { + const l = state.doc.line(n) + if (l.text.includes(text)) return l + } + throw new Error(text) + } + const obs = lineOf('"observability"') + const range = foldable(state, obs.from, obs.to) + expect(range).not.toBeNull() + expect(state.doc.sliceString(range!.from, range!.to)).toContain('"enabled"') + // A one-line object has nothing to fold. + const assets = lineOf('"assets"') + expect(foldable(state, assets.from, assets.to)).toBeNull() + }) +}) + +describe('jsoncErrors', () => { + it('passes comments and trailing commas', () => { + expect(jsoncErrors(WRANGLER)).toEqual([]) + expect(jsoncErrors('// only a comment\n')).toEqual([]) + expect(jsoncErrors('[1, 2, /* x */ ,\n]')).not.toEqual([]) // two commas is still wrong + expect(jsoncErrors('[1, 2,\n// last\n]')).toEqual([]) + }) + + it('keeps every offset, so an error lands where it is', () => { + const { json } = blankJsonc(WRANGLER) + expect(json.length).toBe(WRANGLER.length) + expect(json.split('\n').length).toBe(WRANGLER.split('\n').length) + expect(json).not.toContain('developers.cloudflare') + expect(blankJsonc('{"u": "a//b", "v": "/*"}').json).toBe('{"u": "a//b", "v": "/*"}') + }) + + it('still reports a missing comma, at the member that lacks it', () => { + const doc = '{\n // a comment\n "a": 1\n "b": 2\n}\n' + const [err] = jsoncErrors(doc) + expect(err).toBeDefined() + expect(doc.slice(err.from, err.to)).toBe('"') + expect(err.from).toBe(doc.indexOf('"b"')) + }) + + it('still reports an unclosed brace and an unclosed comment', () => { + expect(jsoncErrors('{\n "a": 1, // x\n').length).toBe(1) + const [c] = jsoncErrors('{ "a": 1 } /* never closed') + expect(c.message).toMatch(/never closed/) + expect(c.from).toBe(11) + }) +}) diff --git a/src/renderer/src/lib/jsoncMode.ts b/src/renderer/src/lib/jsoncMode.ts new file mode 100644 index 00000000..d9146870 --- /dev/null +++ b/src/renderer/src/lib/jsoncMode.ts @@ -0,0 +1,104 @@ +import { foldService, matchBrackets, StreamLanguage, syntaxTree, type StreamParser } from '@codemirror/language' +import type { Extension } from '@codemirror/state' + +// JSON with comments (#312; owner, 2026-10-07: "comments in jsonc arent read +// as comments in prism"). tsconfig.json, .vscode/settings.json, wrangler.jsonc +// and the like are JSONC: // and /* */ comments, and trailing commas, which +// VS Code and TypeScript accept. The Lezer JSON grammar has no comment token, +// so a // line came out as error nodes: plain-coloured text under a red +// squiggle on every comment. +// +// This is a tokenizer, not a grammar: it colours with the SAME tags the Lezer +// JSON grammar uses (so a JSONC file looks like a JSON file, plus comments in +// the theme's comment colour), and errors come from `jsoncErrors` in +// codeLint.ts, which knows the actual rules. It is lenient on purpose (single +// quotes, bare keys, hex), so JSON5 colours with it too. + +type State = { depth: number; inBlock: boolean } + +/** The rest of a block comment, or of the line when it does not close here. */ +function blockRest(stream: { match: (p: RegExp) => unknown; skipToEnd: () => void }, state: State): string { + if (stream.match(/^[\s\S]*?\*\//)) state.inBlock = false + else stream.skipToEnd() + return 'blockComment' +} + +export const jsoncParser: StreamParser = { + name: 'jsonc', + startState: () => ({ depth: 0, inBlock: false }), + copyState: (s) => ({ ...s }), + token(stream, state) { + if (state.inBlock) return blockRest(stream, state) + if (stream.eatSpace()) return null + if (stream.match('//')) { + stream.skipToEnd() + return 'lineComment' + } + if (stream.match('/*')) { + state.inBlock = true + return blockRest(stream, state) + } + const ch = stream.peek() + if (ch === '"' || ch === "'") { + stream.next() + let escaped = false + for (let c = stream.next(); c !== undefined; c = stream.next()) { + if (c === ch && !escaped) break + escaped = !escaped && c === '\\' + } + // A key is a string followed by a colon, which is how the grammar + // tells PropertyName from String too. + return stream.match(/^\s*:/, false) ? 'propertyName' : 'string' + } + if (stream.match(/^[+-]?(?:0x[0-9a-f]+|(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?|Infinity|NaN)/i)) return 'number' + if (stream.match(/^(?:true|false)\b/)) return 'bool' + if (stream.match(/^null\b/)) return 'null' + if (stream.match(/^[A-Za-z_$][\w$]*/)) return stream.match(/^\s*:/, false) ? 'propertyName' : null + stream.next() + if (ch === '{' || ch === '[') state.depth += 1 + if (ch === '}' || ch === ']') state.depth = Math.max(0, state.depth - 1) + if (ch === '{' || ch === '}') return 'brace' + if (ch === '[' || ch === ']') return 'squareBracket' + if (ch === ',' || ch === ':') return 'separator' + return null + }, + indent(state, textAfter, cx) { + const closes = /^\s*[}\]]/.test(textAfter) ? 1 : 0 + return Math.max(0, state.depth - closes) * cx.unit + }, + languageData: { + commentTokens: { line: '//', block: { open: '/*', close: '*/' } }, + closeBrackets: { brackets: ['[', '{', '"'] } + } +} + +const COMMENT = /Comment$/ + +/** + * Folding, which a stream language does not get for free: a line whose last + * token (comments aside) opens a brace or bracket folds to its partner. + */ +const jsoncFold = foldService.of((state, lineStart, lineEnd) => { + let open = -1 + syntaxTree(state).iterate({ + from: lineStart, + to: lineEnd, + enter: (node) => { + if (node.from < lineStart || node.to > lineEnd || node.type.isTop) return + if (COMMENT.test(node.name)) return + const text = state.doc.sliceString(node.from, node.to) + open = text === '{' || text === '[' ? node.from : -1 + } + }) + if (open < 0) return null + const match = matchBrackets(state, open, 1) + if (!match?.matched || !match.end || match.end.from <= lineEnd) return null + return { from: open + 1, to: match.end.from } +}) + +export const jsoncLanguage = StreamLanguage.define(jsoncParser) + +/** The language and its folding, as the editor attaches them. */ +export function jsonc(): Extension { + return [jsoncLanguage, jsoncFold] +} diff --git a/tools/e2e/run.mjs b/tools/e2e/run.mjs index 6cf1de6c..7341317c 100644 --- a/tools/e2e/run.mjs +++ b/tools/e2e/run.mjs @@ -3765,6 +3765,98 @@ async function codeScenario(fixtures) { } } +/** + * JSONC comments are comments (#312; owner, 2026-10-07: "comments in jsonc + * arent read as comments in prism", with a wrangler.jsonc whose every // line + * was plain text under a red squiggle). Fails on the old build, where .jsonc + * went to the strict JSON grammar and JSON.parse. + */ +async function jsoncScenario(fixtures) { + console.log('JSONC comments read as comments') + const dir = join(fixtures, 'jsonc') + rmSync(dir, { recursive: true, force: true }) + mkdirSync(dir, { recursive: true }) + writeFileSync( + join(dir, 'wrangler.jsonc'), + [ + '/**', + ' * For more details on how to configure Wrangler, refer to:', + ' * https://developers.cloudflare.com/workers/wrangler/configuration/', + ' */', + '{', + ' "$schema": "node_modules/wrangler/config-schema.json",', + ' "name": "my-worker",', + ' "main": "src/index.ts",', + ' "compatibility_date": "2025-04-01",', + ' // "compatibility_flags": ["nodejs_compat"],', + ' "observability": {', + ' "enabled": true', + ' },', + ' /**', + ' * Smart Placement', + ' */', + ' // "placement": { "mode": "smart" },', + ' "assets": { "directory": "./public/" },', + '}', + '' + ].join('\n') + ) + // A well-known name with no .jsonc on it, and a trailing comma. + writeFileSync( + join(dir, 'tsconfig.json'), + '{\n // Base options\n "compilerOptions": {\n "strict": true, /* always */\n "target": "ES2022",\n },\n}\n' + ) + // JSONC is not "anything goes": a missing comma is still an error. + writeFileSync(join(dir, 'zbroken.jsonc'), '{\n // fine\n "a": 1\n "b": 2\n}\n') + + const { app, win } = await launch(join(dir, 'wrangler.jsonc')) + /** Every rendered span whose text is a comment, with its colour and the theme's. */ + const comments = () => + win.evaluate(() => { + const probe = document.createElement('span') + probe.style.color = 'var(--p-code-comment)' + document.querySelector('.cm-content').appendChild(probe) + const want = getComputedStyle(probe).color + probe.remove() + const spans = [...document.querySelectorAll('.cm-line span')] + .filter((s) => /^\s*(\/\/|\/\*|\*)/.test(s.textContent)) + .map((s) => ({ text: s.textContent, color: getComputedStyle(s).color, italic: getComputedStyle(s).fontStyle })) + const plain = getComputedStyle(document.querySelector('.cm-line')).color + return { want, plain, spans } + }) + try { + await win.waitForSelector('.cm-content', { timeout: 10000 }) + await win.waitForFunction(() => document.querySelectorAll('.cm-line span').length > 5, undefined, { timeout: 10000 }) + await sleep(1500) // past the linter's debounce, so absence means absence + ok((await win.locator('.cm-lintRange-error').count()) === 0, 'a wrangler.jsonc with comments has no squiggles') + const c = await comments() + ok(c.spans.length >= 6, `every comment line is its own token (got ${c.spans.length})`) + ok(c.want !== c.plain, `the theme's comment colour differs from plain text (${c.want} vs ${c.plain})`) + ok( + c.spans.every((s) => s.color === c.want), + `and every comment wears it (${c.spans.filter((s) => s.color !== c.want).map((s) => s.text).join(' | ') || 'all do'})` + ) + ok(c.spans.every((s) => s.italic === 'italic'), 'in the comment style') + await win.screenshot({ path: join(SHOTS, 'jsonc.png') }) + + await win.click('[role="treeitem"]:has-text("tsconfig.json")') + await win.waitForFunction(() => (document.querySelector('.cm-content')?.textContent ?? '').includes('compilerOptions'), undefined, { timeout: 10000 }) + await sleep(1500) + ok((await win.locator('.cm-lintRange-error').count()) === 0, 'tsconfig.json takes comments and a trailing comma too') + const t = await comments() + ok(t.spans.length >= 2 && t.spans.every((s) => s.color === t.want), 'and colours them as comments') + + await win.click('[role="treeitem"]:has-text("zbroken.jsonc")') + await win.waitForFunction(() => (document.querySelector('.cm-content')?.textContent ?? '').includes('"b": 2'), undefined, { timeout: 10000 }) + await win.waitForSelector('.cm-lintRange-error', { timeout: 10000 }).catch(() => {}) + const marked = await win.locator('.cm-lintRange-error').allTextContents() + ok(marked.length === 1 && marked[0].startsWith('"'), `a missing comma in JSONC is still underlined, once (${JSON.stringify(marked)})`) + await win.screenshot({ path: join(SHOTS, 'jsonc-error.png') }) + } finally { + await app.close() + } +} + async function treeNavScenario(fixtures) { console.log('tree navigation') // Open inside code/, so the root has folders above and below the cursor. @@ -16063,6 +16155,7 @@ await run(reloadScenario) await run(tailScenario) await run(hexScenario) await run(codeScenario) +await run(jsoncScenario) await run(treeNavScenario) await run(unsavedScenario) await run(playerScenario)