diff --git a/lib/index.d.ts b/lib/index.d.ts index ae66ffb..7f4a2d0 100644 --- a/lib/index.d.ts +++ b/lib/index.d.ts @@ -7,6 +7,8 @@ * original. * @prop {boolean} [noOriginal] Do not run tests on the original code, only * on the generated code. + * @prop {boolean} [inputSourceMap] Read sourceMap information from the input + * file. Use it to filter the generated sourceMap information. */ /** * Test the basic functionality of a Peggy grammar, to make coverage easier. @@ -113,4 +115,9 @@ export type TestPeggyOptions = { * on the generated code. */ noOriginal?: boolean | undefined; + /** + * Read sourceMap information from the input + * file. Use it to filter the generated sourceMap information. + */ + inputSourceMap?: boolean | undefined; }; diff --git a/lib/index.js b/lib/index.js index 60bea6b..240289b 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,3 +1,4 @@ +import { SourceMapConsumer, SourceNode } from "source-map"; import { deepEqual, equal, @@ -5,12 +6,14 @@ import { ok, throws, } from "node:assert/strict"; -import { SourceNode } from "source-map"; import { fileURLToPath } from "node:url"; import fs from "node:fs/promises"; import path from "node:path"; const INVALID = "\uffff"; +const START = "//#"; // c8: THIS file is not mapped. +const sourceMapRE = new RegExp(`^${START}\\s*sourceMappingURL\\s*=\\s*([^\r\n]+)`, "m"); +const startRuleRE = /^\s*peg\$result = peg\$startRuleFunction\(\);/; let counter = 0; /** @@ -258,6 +261,8 @@ function checkParserStarts(grammar, starts, modified, counts) { * original. * @prop {boolean} [noOriginal] Do not run tests on the original code, only * on the generated code. + * @prop {boolean} [inputSourceMap] Read sourceMap information from the input + * file. Use it to filter the generated sourceMap information. */ /** @@ -333,13 +338,47 @@ export async function testPeggy(grammarUrl, starts, opts) { ok(grammarJs); equal(typeof grammarJs, "string"); + /** @type {SourceMapConsumer|null} */ + let ism = null; + if (opts?.inputSourceMap) { + const m = grammarJs.match(sourceMapRE); + if (m) { + const map = m[1].startsWith("data:") + ? await (await fetch(m[1])).json() + : JSON.parse( + await fs.readFile( + path.resolve(process.cwd(), path.dirname(grammarPath), m[1]), + "utf8" + ) + ); + ism = await new SourceMapConsumer(map); + } + } + // Approach: generate a new file next to the existing grammar file, with // test code injected just before the parser runs. Source map information // embedded in the new file will make coverage show up on the original file. const src = new SourceNode(); let lineNum = 1; for (const line of grammarJs.split(/(?<=\n)/)) { - if (/^\s*peg\$result = peg\$startRuleFunction\(\);/.test(line)) { + let sn = null; + if (ism) { + const pos = ism.originalPositionFor({ + line: lineNum, + column: 0, + }); + if (pos) { + sn = new SourceNode(pos.line, pos.column, pos.source, line); + } + } + if (!sn) { + sn = new SourceNode(lineNum, 0, grammarPath, line); + } + lineNum++; + + if (sourceMapRE.test(line)) { + // No-op, never output sourcemapurl line. + } else if (startRuleRE.test(line)) { src.add(`\ //#region Inserted by @peggyjs/coverage let replacements = Object.create(null); @@ -440,7 +479,7 @@ export async function testPeggy(grammarUrl, starts, opts) { //#endregion `); - src.add(new SourceNode(lineNum++, 0, grammarPath, line)); + src.add(sn); src.add(` //#region Inserted by @peggyjs/coverage for (const [name, orig] of Object.entries(replacements)) { @@ -450,7 +489,7 @@ export async function testPeggy(grammarUrl, starts, opts) { //#endregion `); } else { - src.add(new SourceNode(lineNum++, 0, grammarPath, line)); + src.add(sn); } } @@ -460,17 +499,15 @@ export async function testPeggy(grammarUrl, starts, opts) { let sm = starts.every(s => !s.options?.peg$debugger); if (opts && Object.prototype.hasOwnProperty.call(opts, "noMap")) { sm = !opts.noMap; - } else { - if (!sm) { - console.error("WARNING: sourcemap disabled due to peg$debugger"); - } + } else if (!sm) { + console.error("WARNING: sourcemap disabled due to peg$debugger"); } - const start = "//#"; // c8: THIS file is not mapped. if (sm) { code += ` -${start} sourceMappingURL=data:application/json;charset=utf-8;base64,${map} +${START} sourceMappingURL=data:application/json;charset=utf-8;base64,${map} `; } + ism?.destroy(); await fs.writeFile(modifiedPath, code); try { const agrammar = /** @type {Parser} */ ( diff --git a/package.json b/package.json index a334cba..7c0020e 100644 --- a/package.json +++ b/package.json @@ -37,10 +37,10 @@ }, "devDependencies": { "@peggyjs/eslint-config": "6.0.0", - "@types/node": "22.15.3", + "@types/node": "22.15.14", "c8": "10.1.3", "eslint": "9.26.0", - "typedoc": "0.28.3", + "typedoc": "0.28.4", "typescript": "5.8.3" }, "packageManager": "pnpm@10.10.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d870369..bf12b7e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -19,8 +19,8 @@ importers: specifier: 6.0.0 version: 6.0.0(eslint@9.26.0)(typescript@5.8.3) '@types/node': - specifier: 22.15.3 - version: 22.15.3 + specifier: 22.15.14 + version: 22.15.14 c8: specifier: 10.1.3 version: 10.1.3 @@ -28,8 +28,8 @@ importers: specifier: 9.26.0 version: 9.26.0 typedoc: - specifier: 0.28.3 - version: 0.28.3(typescript@5.8.3) + specifier: 0.28.4 + version: 0.28.4(typescript@5.8.3) typescript: specifier: 5.8.3 version: 5.8.3 @@ -167,17 +167,17 @@ packages: resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} engines: {node: '>=14'} - '@shikijs/engine-oniguruma@3.3.0': - resolution: {integrity: sha512-l0vIw+GxeNU7uGnsu6B+Crpeqf+WTQ2Va71cHb5ZYWEVEPdfYwY5kXwYqRJwHrxz9WH+pjSpXQz+TJgAsrkA5A==} + '@shikijs/engine-oniguruma@3.4.0': + resolution: {integrity: sha512-zwcWlZ4OQuJ/+1t32ClTtyTU1AiDkK1lhtviRWoq/hFqPjCNyLj22bIg9rB7BfoZKOEOfrsGz7No33BPCf+WlQ==} - '@shikijs/langs@3.3.0': - resolution: {integrity: sha512-zt6Kf/7XpBQKSI9eqku+arLkAcDQ3NHJO6zFjiChI8w0Oz6Jjjay7pToottjQGjSDCFk++R85643WbyINcuL+g==} + '@shikijs/langs@3.4.0': + resolution: {integrity: sha512-bQkR+8LllaM2duU9BBRQU0GqFTx7TuF5kKlw/7uiGKoK140n1xlLAwCgXwSxAjJ7Htk9tXTFwnnsJTCU5nDPXQ==} - '@shikijs/themes@3.3.0': - resolution: {integrity: sha512-tXeCvLXBnqq34B0YZUEaAD1lD4lmN6TOHAhnHacj4Owh7Ptb/rf5XCDeROZt2rEOk5yuka3OOW2zLqClV7/SOg==} + '@shikijs/themes@3.4.0': + resolution: {integrity: sha512-YPP4PKNFcFGLxItpbU0ZW1Osyuk8AyZ24YEFaq04CFsuCbcqydMvMUTi40V2dkc0qs1U2uZFrnU6s5zI6IH+uA==} - '@shikijs/types@3.3.0': - resolution: {integrity: sha512-KPCGnHG6k06QG/2pnYGbFtFvpVJmC3uIpXrAiPrawETifujPBv0Se2oUxm5qYgjCvGJS9InKvjytOdN+bGuX+Q==} + '@shikijs/types@3.4.0': + resolution: {integrity: sha512-EUT/0lGiE//7j5N/yTMNMT3eCWNcHJLrRKxT0NDXWIfdfSmFJKfPX7nMmRBrQnWboAzIsUziCThrYMMhjbMS1A==} '@shikijs/vscode-textmate@10.0.2': resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} @@ -209,35 +209,35 @@ packages: '@types/ms@2.1.0': resolution: {integrity: sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==} - '@types/node@22.15.3': - resolution: {integrity: sha512-lX7HFZeHf4QG/J7tBZqrCAXwz9J5RD56Y6MpP0eJkka8p+K0RY/yBTW7CYFJ4VGCclxqOLKmiGP5juQc6MKgcw==} + '@types/node@22.15.14': + resolution: {integrity: sha512-BL1eyu/XWsFGTtDWOYULQEs4KR0qdtYfCxYAUYRoB7JP7h9ETYLgQTww6kH8Sj2C0pFGgrpM0XKv6/kbIzYJ1g==} '@types/unist@3.0.3': resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} - '@typescript-eslint/scope-manager@8.31.1': - resolution: {integrity: sha512-BMNLOElPxrtNQMIsFHE+3P0Yf1z0dJqV9zLdDxN/xLlWMlXK/ApEsVEKzpizg9oal8bAT5Sc7+ocal7AC1HCVw==} + '@typescript-eslint/scope-manager@8.32.0': + resolution: {integrity: sha512-jc/4IxGNedXkmG4mx4nJTILb6TMjL66D41vyeaPWvDUmeYQzF3lKtN15WsAeTr65ce4mPxwopPSo1yUUAWw0hQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/types@8.31.1': - resolution: {integrity: sha512-SfepaEFUDQYRoA70DD9GtytljBePSj17qPxFHA/h3eg6lPTqGJ5mWOtbXCk1YrVU1cTJRd14nhaXWFu0l2troQ==} + '@typescript-eslint/types@8.32.0': + resolution: {integrity: sha512-O5Id6tGadAZEMThM6L9HmVf5hQUXNSxLVKeGJYWNhhVseps/0LddMkp7//VDkzwJ69lPL0UmZdcZwggj9akJaA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@typescript-eslint/typescript-estree@8.31.1': - resolution: {integrity: sha512-kaA0ueLe2v7KunYOyWYtlf/QhhZb7+qh4Yw6Ni5kgukMIG+iP773tjgBiLWIXYumWCwEq3nLW+TUywEp8uEeag==} + '@typescript-eslint/typescript-estree@8.32.0': + resolution: {integrity: sha512-pU9VD7anSCOIoBFnhTGfOzlVFQIA1XXiQpH/CezqOBaDppRwTglJzCC6fUQGpfwey4T183NKhF1/mfatYmjRqQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/utils@8.31.1': - resolution: {integrity: sha512-2DSI4SNfF5T4oRveQ4nUrSjUqjMND0nLq9rEkz0gfGr3tg0S5KB6DhwR+WZPCjzkZl3cH+4x2ce3EsL50FubjQ==} + '@typescript-eslint/utils@8.32.0': + resolution: {integrity: sha512-8S9hXau6nQ/sYVtC3D6ISIDoJzS1NsCK+gluVhLN2YkBPX+/1wkwyUiDKnxRh15579WoOIyVWnoyIf3yGI9REw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 typescript: '>=4.8.4 <5.9.0' - '@typescript-eslint/visitor-keys@8.31.1': - resolution: {integrity: sha512-I+/rgqOVBn6f0o7NDTmAPWWC6NuqhV174lfYvAm9fUaWeiefLdux9/YI3/nLugEn9L8fcSi0XmpKi/r5u0nmpw==} + '@typescript-eslint/visitor-keys@8.32.0': + resolution: {integrity: sha512-1rYQTCLFFzOI5Nl0c8LUpJT8HxpwVRn9E4CkMsYfuN6ctmQqExjSTzzSk0Tz2apmXy7WU6/6fyaZVVA/thPN+w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} accepts@2.0.0: @@ -1149,8 +1149,8 @@ packages: resolution: {integrity: sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw==} engines: {node: '>= 0.6'} - typedoc@0.28.3: - resolution: {integrity: sha512-5svOCTfXvVSh6zbZKSQluZhR8yN2tKpTeHZxlmWpE6N5vc3R8k/jhg9nnD6n5tN9/ObuQTojkONrOxFdUFUG9w==} + typedoc@0.28.4: + resolution: {integrity: sha512-xKvKpIywE1rnqqLgjkoq0F3wOqYaKO9nV6YkkSat6IxOWacUCc/7Es0hR3OPmkIqkPoEn7U3x+sYdG72rstZQA==} engines: {node: '>= 18', pnpm: '>= 10'} hasBin: true peerDependencies: @@ -1240,8 +1240,8 @@ packages: peerDependencies: zod: ^3.24.1 - zod@3.24.3: - resolution: {integrity: sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==} + zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -1323,10 +1323,10 @@ snapshots: '@gerrit0/mini-shiki@3.3.0': dependencies: - '@shikijs/engine-oniguruma': 3.3.0 - '@shikijs/langs': 3.3.0 - '@shikijs/themes': 3.3.0 - '@shikijs/types': 3.3.0 + '@shikijs/engine-oniguruma': 3.4.0 + '@shikijs/langs': 3.4.0 + '@shikijs/themes': 3.4.0 + '@shikijs/types': 3.4.0 '@shikijs/vscode-textmate': 10.0.2 '@humanfs/core@0.19.1': {} @@ -1374,8 +1374,8 @@ snapshots: express-rate-limit: 7.5.0(express@5.1.0) pkce-challenge: 5.0.0 raw-body: 3.0.0 - zod: 3.24.3 - zod-to-json-schema: 3.24.5(zod@3.24.3) + zod: 3.24.4 + zod-to-json-schema: 3.24.5(zod@3.24.4) transitivePeerDependencies: - supports-color @@ -1409,20 +1409,20 @@ snapshots: '@pkgjs/parseargs@0.11.0': optional: true - '@shikijs/engine-oniguruma@3.3.0': + '@shikijs/engine-oniguruma@3.4.0': dependencies: - '@shikijs/types': 3.3.0 + '@shikijs/types': 3.4.0 '@shikijs/vscode-textmate': 10.0.2 - '@shikijs/langs@3.3.0': + '@shikijs/langs@3.4.0': dependencies: - '@shikijs/types': 3.3.0 + '@shikijs/types': 3.4.0 - '@shikijs/themes@3.3.0': + '@shikijs/themes@3.4.0': dependencies: - '@shikijs/types': 3.3.0 + '@shikijs/types': 3.4.0 - '@shikijs/types@3.3.0': + '@shikijs/types@3.4.0': dependencies: '@shikijs/vscode-textmate': 10.0.2 '@types/hast': 3.0.4 @@ -1431,7 +1431,7 @@ snapshots: '@stylistic/eslint-plugin@4.2.0(eslint@9.26.0)(typescript@5.8.3)': dependencies: - '@typescript-eslint/utils': 8.31.1(eslint@9.26.0)(typescript@5.8.3) + '@typescript-eslint/utils': 8.32.0(eslint@9.26.0)(typescript@5.8.3) eslint: 9.26.0 eslint-visitor-keys: 4.2.0 espree: 10.3.0 @@ -1461,23 +1461,23 @@ snapshots: '@types/ms@2.1.0': {} - '@types/node@22.15.3': + '@types/node@22.15.14': dependencies: undici-types: 6.21.0 '@types/unist@3.0.3': {} - '@typescript-eslint/scope-manager@8.31.1': + '@typescript-eslint/scope-manager@8.32.0': dependencies: - '@typescript-eslint/types': 8.31.1 - '@typescript-eslint/visitor-keys': 8.31.1 + '@typescript-eslint/types': 8.32.0 + '@typescript-eslint/visitor-keys': 8.32.0 - '@typescript-eslint/types@8.31.1': {} + '@typescript-eslint/types@8.32.0': {} - '@typescript-eslint/typescript-estree@8.31.1(typescript@5.8.3)': + '@typescript-eslint/typescript-estree@8.32.0(typescript@5.8.3)': dependencies: - '@typescript-eslint/types': 8.31.1 - '@typescript-eslint/visitor-keys': 8.31.1 + '@typescript-eslint/types': 8.32.0 + '@typescript-eslint/visitor-keys': 8.32.0 debug: 4.4.0 fast-glob: 3.3.3 is-glob: 4.0.3 @@ -1488,20 +1488,20 @@ snapshots: transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@8.31.1(eslint@9.26.0)(typescript@5.8.3)': + '@typescript-eslint/utils@8.32.0(eslint@9.26.0)(typescript@5.8.3)': dependencies: '@eslint-community/eslint-utils': 4.7.0(eslint@9.26.0) - '@typescript-eslint/scope-manager': 8.31.1 - '@typescript-eslint/types': 8.31.1 - '@typescript-eslint/typescript-estree': 8.31.1(typescript@5.8.3) + '@typescript-eslint/scope-manager': 8.32.0 + '@typescript-eslint/types': 8.32.0 + '@typescript-eslint/typescript-estree': 8.32.0(typescript@5.8.3) eslint: 9.26.0 typescript: 5.8.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/visitor-keys@8.31.1': + '@typescript-eslint/visitor-keys@8.32.0': dependencies: - '@typescript-eslint/types': 8.31.1 + '@typescript-eslint/types': 8.32.0 eslint-visitor-keys: 4.2.0 accepts@2.0.0: @@ -1738,7 +1738,7 @@ snapshots: minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 - zod: 3.24.3 + zod: 3.24.4 transitivePeerDependencies: - supports-color @@ -2612,7 +2612,7 @@ snapshots: media-typer: 1.1.0 mime-types: 3.0.1 - typedoc@0.28.3(typescript@5.8.3): + typedoc@0.28.4(typescript@5.8.3): dependencies: '@gerrit0/mini-shiki': 3.3.0 lunr: 2.3.9 @@ -2698,10 +2698,10 @@ snapshots: yocto-queue@0.1.0: {} - zod-to-json-schema@3.24.5(zod@3.24.3): + zod-to-json-schema@3.24.5(zod@3.24.4): dependencies: - zod: 3.24.3 + zod: 3.24.4 - zod@3.24.3: {} + zod@3.24.4: {} zwitch@2.0.4: {} diff --git a/test/index.test.js b/test/index.test.js index 35418b0..e7c5ef1 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -1,8 +1,17 @@ import { deepEqual, equal, rejects } from "node:assert"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { SourceNode } from "source-map"; +import { join } from "node:path"; import test from "node:test"; import { testPeggy } from "../lib/index.js"; +import { tmpdir } from "node:os"; const MIN = new URL("minimal.js", import.meta.url); +const tmp = await mkdtemp(join(tmpdir(), "peggyjs-coverage-")); + +test.after(async() => { + await rm(tmp, { recursive: true }); +}); function cleanCounts(counts) { equal(typeof counts.grammarPath, "string"); @@ -207,3 +216,55 @@ test("peg$debugger", async() => { console.error = old; deepEqual(stderr, [["WARNING: sourcemap disabled due to peg$debugger"]]); }); + +test("inputSourceMap", async() => { + // MIN doesn't have sourcemap + await testPeggy(MIN, [ + { + validInput: "foo", + }, + ], { + inputSourceMap: true, + }); + + const tmpMin = join(tmp, "minimal.js"); + const src = new SourceNode(); + const tmpTxt = await readFile(MIN, "utf8"); + let lineNum = 1; + for (const line of tmpTxt.split(/(?<=\n)/)) { + const sn = new SourceNode(lineNum++, 0, MIN, line); + src.add(sn); + } + const withMap = src.toStringWithSourceMap(); + const map = Buffer.from(withMap.map.toString()).toString("base64"); + const { code } = withMap; + const START = "//#"; + const codeWithDataMap = code + ` +${START} sourceMappingURL=data:application/json;charset=utf-8;base64,${map} +`; + await writeFile(tmpMin, codeWithDataMap); + + // With data: URL + await testPeggy(tmpMin, [ + { + validInput: "foo", + }, + ], { + inputSourceMap: true, + }); + + // With file reference + const codeWithFileMap = code + ` +${START} sourceMappingURL=minimal.js.map +`; + await writeFile(tmpMin, codeWithFileMap); + const tmpMinMap = join(tmp, "minimal.js.map"); + await writeFile(tmpMinMap, withMap.map.toString()); + await testPeggy(tmpMin, [ + { + validInput: "foo", + }, + ], { + inputSourceMap: true, + }); +});