diff --git a/.npmignore b/.npmignore index b69dcda..4112c51 100644 --- a/.npmignore +++ b/.npmignore @@ -110,3 +110,5 @@ dist test/ tsconfig.json .github/ +.editorconfig +eslint.config.js diff --git a/.vscode/settings.json b/.vscode/settings.json index e78d7ff..5d0a021 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,3 +1,8 @@ { - "eslint.experimental.useFlatConfig": true -} \ No newline at end of file + "eslint.experimental.useFlatConfig": true, + "eslint.validate": [ + "javascript", + "peggy", + "typescript" + ] +} diff --git a/README.md b/README.md index 2150c7e..71eedef 100644 --- a/README.md +++ b/README.md @@ -15,19 +15,43 @@ import peggy from "peggy-tag"; const parse = peggy`foo = $("f" "o"+)`; console.log(parse("foooo")); // "foooo" -const trace = peggy.withOptions({ trace: true }); -const traceParse = trace`num = n:$[0-9]+ { return parseInt(n, 10); }` -console.log(traceParse("123")); -// 1:1-1:1 rule.enter num -// 1:1-1:4 rule.match num +const traceGrammar = peggy.withOptions({ trace: true }); +const trace = traceGrammar`num = n:$[0-9]+ { return parseInt(n, 10); }` +console.log(trace("123")); +// 8:20-8:20 rule.enter num +// 8:20-8:23 rule.match num +// 123 +``` + +If your grammar imports rules from other grammars, you MUST use the async +functions `withImports` or `withImportsOptions` + +```js +import {withImports, withImportsOptions} from "peggy-tag"; + +const parse = await withImports` +import Foo from './test/fixtures/foo.js' +bar = Foo`; +console.log(parse("foo")); // "foo" + +const traceGrammar = await withImportsOptions({ trace: true }); +const trace = traceGrammar`num = n:$[0-9]+ { return parseInt(n, 10); }` +console.log(trace("123")); +// 11:20-11:20 rule.enter num +// 11:20-11:23 rule.match num // 123 ``` ## Notes: - This currently is only tested on Node 18+, no browser version yet. -- This is for non-performance-sensitive code (e.g. prototypes), because the +- Node 20.8+ and `--experimental-vm-modules` are required for the async + versions that allow importing libraries. +- This is for NON-performance-sensitive code (e.g. prototypes), because the parser with be generated every time the template is evaluated. +- If your parse function's variable name has exactly five letters (like + "parse" or "trace"), the column numbers will be correct. See issue #14 + for discussion. [![Tests](https://github.com/peggyjs/peggy-tag/actions/workflows/node.js.yml/badge.svg)](https://github.com/peggyjs/peggy-tag/actions/workflows/node.js.yml) [![codecov](https://codecov.io/gh/peggyjs/peggy-tag/branch/main/graph/badge.svg?token=JCB9G04O47)](https://codecov.io/gh/peggyjs/peggy-tag) diff --git a/eslint.config.js b/eslint.config.js index de8e5af..592c5a0 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -1,11 +1,21 @@ import mod from "@peggyjs/eslint-config/flat/module.js"; +import modern from "@peggyjs/eslint-config/flat/modern.js"; +import peggyjs from "@peggyjs/eslint-plugin/lib/flat/recommended.js"; export default [ { ignores: [ "node_module/**", "**/*.d.ts", + "test/fixtures/*.js", ], }, mod, + peggyjs, + { + files: [ + "test/**", + ], + ...modern, + }, ]; diff --git a/lib/index.js b/lib/index.js index 6c72235..837ed7f 100644 --- a/lib/index.js +++ b/lib/index.js @@ -1,35 +1,51 @@ +import { callLocation, combine, formatMessage } from "./utils.js"; +import fromMem from "@peggyjs/from-mem"; import peggy from "peggy"; -import url from "node:url"; /** + * @typedef {function(string, peggy.ParserOptions): any} ParseFunction + */ + +/** + * Return a function that has the given parse function wrapped with utilities + * that set the grammarLocation and format any errors that are thrown. * - * @param {number} depth How deep in the callstack to go? "2" is usually the - * first interesting one. - * @returns {peggy.GrammarLocation?} Location of the grammar in the enclosing - * file. + * @param {ParseFunction} parse + * @returns {ParseFunction} */ -function callLocation(depth) { - const old = Error.prepareStackTrace; - Error.prepareStackTrace = (_, s) => s; - const stack = new Error().stack; - Error.prepareStackTrace = old; +function curryParse(parse) { + return function Parse(text, options = {}) { + if (!options.grammarSource) { + options.grammarSource = callLocation(2, 7); + } + try { + return parse(text, options); + } catch (e) { + throw formatMessage(e, options.grammarSource, text); + } + }; +} - // Not v8, or short-stacked vs. expectations - if (!Array.isArray(stack) || (stack.length < depth)) { - return null; +/** + * Turn a templated string into a Peggy parsing function. + * + * @param {peggy.ParserBuildOptions|undefined} opts Grammar generation options. + * @param {string[]} strings The string portions of the template. + * @param {any[]} values The interpolated values of the template. + * @returns {ParseFunction} The parsing function. + */ +function pegWithOptions(opts, strings, values) { + const text = combine(strings, values); + const grammarSource = callLocation(3); + try { + const { parse } = peggy.generate(text, { + grammarSource, + ...opts, + }); + return curryParse(parse); + } catch (e) { + throw formatMessage(e, grammarSource, text); } - - const callsite = stack[depth]; - const source = callsite.getFileName(); - const path = source.startsWith("file:") ? url.fileURLToPath(source) : source; - return new peggy.GrammarLocation( - path, - { - offset: callsite.getPosition() + 1, // Go past backtick - line: callsite.getLineNumber(), - column: callsite.getColumnNumber() + 1, // Go past backtick - } - ); } /** @@ -37,42 +53,26 @@ function callLocation(depth) { * * @param {peggy.ParserBuildOptions|undefined} opts Grammar generation options. * @param {string[]} strings The string portions of the template. - * @param {...any} values The interpolated values of the template. - * @returns {function(string, peggy.ParserOptions): any} The parsing function. + * @param {any[]} values The interpolated values of the template. + * @returns {Promise} The parsing function. */ -function pegWithOptions(opts, strings, ...values) { - let text = ""; - strings.forEach((string, i) => { - text += string + (values[i] || ""); - }); - const grammarSource = callLocation(3) || "peggy-tag"; +async function importPegWithOptions(opts, strings, values) { + const text = combine(strings, values); + const grammarSource = callLocation(3); try { - const parser = peggy.generate(text, { + const src = /** @type {string} */ (peggy.generate(text, { grammarSource, + format: "es", + output: "source-with-inline-map", ...opts, - }).parse; - return (text, options = {}) => { - if (!options.grammarSource) { - options.grammarSource = "peggy-tag-parser"; - } - try { - return parser(text, options); - } catch (e) { - // @ts-ignore - if (typeof e?.format === "function") { - // @ts-ignore - e.message = e.format([{ source: options.grammarSource, text }]); - } - throw e; - } - }; + })); + const { parse } = /** @type {peggy.Parser} */ (await fromMem(src, { + filename: grammarSource.source, + format: "es", + })); + return curryParse(parse); } catch (e) { - // @ts-ignore - if (typeof e?.format === "function") { - // @ts-ignore - e.message = e.format([{ source: grammarSource, text }]); - } - throw e; + throw formatMessage(e, grammarSource, text); } } @@ -81,33 +81,71 @@ function pegWithOptions(opts, strings, ...values) { * * @param {string[]} strings The string portions of the template. * @param {...any} values The interpolated values of the template. - * @returns {function(string, peggy.ParserOptions): any} The parsing function. + * @returns {ParseFunction} The parsing function. * @example * import peg from "peggy-tag"; - * const parser = peg`foo = "foo"`; - * console.log(parser("foo")); + * const parse = peg`foo = "foo"`; + * console.log(parse("foo")); */ export default function peg(strings, ...values) { - return pegWithOptions(undefined, strings, ...values); + return pegWithOptions(undefined, strings, values); } /** * Create a template string tag with non-default grammar generation options. * * @param {peggy.ParserBuildOptions|undefined} opts Grammar generation options. - * @returns {function(string[], ...any): function(string, peggy.ParserOptions): any} + * @returns {function(string[], ...any): ParseFunction} * @example - * import peg from "peggy-tag"; - * import myPeg = peg.withOptions({trace: true}) + * import { withOptions } from "peggy-tag"; + * import myPeg = withOptions({trace: true}) * const parser = myPeg`foo = "foo"`; * console.log(parser("foo")); */ -peg.withOptions = opts => ( +export function withOptions(opts) { /** - * * @param {string[]} strings The string portions of the template. * @param {...any} values The interpolated values of the template. - * @returns {function(string, peggy.ParserOptions): any} The parsing function. + * @returns {ParseFunction} The parsing function. */ - (strings, ...values) => pegWithOptions(opts, strings, ...values) -); + return (strings, ...values) => pegWithOptions(opts, strings, values); +} +peg.withOptions = withOptions; + +/** + * Create a parse from a string that may include import statements. + * + * @param {string[]} strings The string portions of the template. + * @param {...any} values The interpolated values of the template. + * @returns {Promise} The parsing function. + * @example + * import { withImports } from "peggy-tag"; + * const parse = await withImports`foo = "foo"`; + * console.log(parse("foo")); + */ +export function withImports(strings, ...values) { + return importPegWithOptions(undefined, strings, values); +} +peg.withImports = withImports; + +/** + * Create a template string tag with non-default grammar generation options, + * for grammars that include imports. + * + * @param {peggy.ParserBuildOptions|undefined} opts Grammar generation options. + * @returns {function(string[], ...any): Promise} + * @example + * import { withImportsOptions } from "peggy-tag"; + * import myPeg = peg.withOptions({trace: true}) + * const parser = await myPeg`foo = "foo"`; + * console.log(parser("foo")); + */ +export function withImportsOptions(opts) { + /** + * @param {string[]} strings The string portions of the template. + * @param {...any} values The interpolated values of the template. + * @returns {Promise} The parsing function. + */ + return (strings, ...values) => importPegWithOptions(opts, strings, values); +} +peg.withImportsOptions = withImportsOptions; diff --git a/lib/utils.js b/lib/utils.js new file mode 100644 index 0000000..e3b7651 --- /dev/null +++ b/lib/utils.js @@ -0,0 +1,79 @@ +import peggy from "peggy"; +import url from "node:url"; + +/** + * Generate a GrammarLocation for one of the functions up the call stack + * from here. 0 is not useful, it's always the callLocation function. + * 1 is unlikely to be useful, it's the place you are calling callLocation from, + * so you presumably know where you are. 2 is the caller of the function you + * are in, etc. + * + * @param {number} depth How deep in the callstack to go? + * @param {number} [offset=1] How many characters to add to the location to + * account for the calling apparatus, such as the backtick or the function + * name + paren. + * @returns {peggy.GrammarLocation} Location of the grammar in the enclosing + * file. + * @see https://v8.dev/docs/stack-trace-api + */ +export function callLocation(depth, offset = 1) { + const old = Error.prepareStackTrace; + Error.prepareStackTrace = (_, s) => s; + const stack = /** @type {NodeJS.CallSite[]} */( + /** @type {unknown} */(new Error().stack) + ); + Error.prepareStackTrace = old; + + // Not v8, or short-stacked vs. expectations + if (!Array.isArray(stack) || (stack.length < depth)) { + return new peggy.GrammarLocation( + "peggy-tag", + { + offset: 0, + line: 0, + column: 0, + } + ); + } + + const callsite = stack[depth]; + const fn = callsite.getFileName(); + const path = fn?.startsWith("file:") ? url.fileURLToPath(fn) : fn; + return new peggy.GrammarLocation( + path, + { + offset: callsite.getPosition() + offset, + // These will be 0 if the frame selected is native code, which + // we should never be doing in this package. + line: callsite.getLineNumber() || 0, + column: (callsite.getColumnNumber() || 0) + offset, + } + ); +} + +/** + * Combine the parameters from a tagged template literal into a string. + * + * @param {string[]} strings + * @param {any[]} values + * @returns {string} + */ +export function combine(strings, values) { + return strings.reduce((t, s, i) => t + s + String(values[i] ?? ""), ""); +} + +/** + * If this is a grammar error, reformat the message using the associated + * text. + * + * @param {any} error An error th + * @param {any} source + * @param {string} text + * @returns {Error} Error with reformatted message, if possible + */ +export function formatMessage(error, source, text) { + if ((typeof error === "object") && (typeof error?.format === "function")) { + error.message = error.format([{ source, text }]); + } + return error; +} diff --git a/package.json b/package.json index 36ee94d..53c6808 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "type": "module", "types": "types/index.d.ts", "scripts": { - "test": "node test/index-spec.js", + "test": "node --experimental-vm-modules --test test/*-spec.js", "coverage": "c8 npm test", "lint": "eslint .", "ts": "tsc" @@ -31,13 +31,16 @@ }, "homepage": "https://github.com/peggy/peggy-tag#readme", "dependencies": { + "@peggyjs/from-mem": "1.0.0", "eslint": "^8.56.0", "peggy": "4.0.0" }, "devDependencies": { "@peggyjs/eslint-config": "3.2.2", - "@types/node": "20.11.19", + "@peggyjs/eslint-plugin": "2.0.0", + "@types/node": "20.11.20", "c8": "9.1.0", + "semver": "7.6.0", "typescript": "5.3.3" }, "packageManager": "pnpm@8.15.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f2f6c1f..dfbf05a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -5,6 +5,9 @@ settings: excludeLinksFromLockfile: false dependencies: + '@peggyjs/from-mem': + specifier: 1.0.0 + version: 1.0.0 eslint: specifier: ^8.56.0 version: 8.56.0 @@ -16,12 +19,18 @@ devDependencies: '@peggyjs/eslint-config': specifier: 3.2.2 version: 3.2.2(eslint@8.56.0)(typescript@5.3.3) + '@peggyjs/eslint-plugin': + specifier: 2.0.0 + version: 2.0.0(@peggyjs/eslint-parser@2.0.4)(eslint@8.56.0) '@types/node': - specifier: 20.11.19 - version: 20.11.19 + specifier: 20.11.20 + version: 20.11.20 c8: specifier: 9.1.0 version: 9.1.0 + semver: + specifier: 7.6.0 + version: 7.6.0 typescript: specifier: 5.3.3 version: 5.3.3 @@ -152,6 +161,30 @@ packages: - supports-color dev: true + /@peggyjs/eslint-parser@2.0.4: + resolution: {integrity: sha512-e3SaXEzbY/JcUxjcOG5OK7Z76XK8JJUyZkHQTheNtxu/9CwZvxDxPJneREh8IzYBv9M2hwVcOZbffKIz/wvKJQ==} + engines: {node: '>=18'} + dev: true + + /@peggyjs/eslint-plugin@2.0.0(@peggyjs/eslint-parser@2.0.4)(eslint@8.56.0): + resolution: {integrity: sha512-Fb/l6kgkbClerqzpgQbfpe5bX1JOsH1MyQDSpGcNU7GT1CclIhWXozXE3OOKv0PJKhryxdBucCR8VbJ0HVGL5Q==} + engines: {node: '>=20.8'} + peerDependencies: + '@peggyjs/eslint-parser': ^2.0.4 + eslint: '>=8.56.0' + dependencies: + '@peggyjs/eslint-parser': 2.0.4 + '@peggyjs/from-mem': 1.0.0 + enhanced-resolve: 5.15.0 + eslint: 8.56.0 + dev: true + + /@peggyjs/from-mem@1.0.0: + resolution: {integrity: sha512-IagfcDvEzBJlNs2roRHQSZbDCXYRR64M/vy6l7AfXS54/YM5cBWwsUnYoB6PhJYFfrWmk5mBUyuEXEo6FDD8dw==} + engines: {node: '>=20.8'} + dependencies: + semver: 7.6.0 + /@stylistic/eslint-plugin-js@1.6.2(eslint@8.56.0): resolution: {integrity: sha512-ndT6X2KgWGxv8101pdMOxL8pihlYIHcOv3ICd70cgaJ9exwkPn8hJj4YQwslxoAlre1TFHnXd/G1/hYXgDrjIA==} engines: {node: ^16.0.0 || >=18.0.0} @@ -243,8 +276,8 @@ packages: resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} dev: true - /@types/node@20.11.19: - resolution: {integrity: sha512-7xMnVEcZFu0DikYjWOlRq7NTPETrm7teqUT2WkQjrTIkEgUyyGdWsj/Zg8bEJt5TNklzbPD1X3fqfsHw3SpapQ==} + /@types/node@20.11.20: + resolution: {integrity: sha512-7/rR21OS+fq8IyHTgtLkDK949uzsa6n8BkziAKtPVpugIkO6D+/ooXMvzXxDnZrmtXVfjb1bKQafYpb8s89LOg==} dependencies: undici-types: 5.26.5 dev: true @@ -476,6 +509,14 @@ packages: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} dev: true + /enhanced-resolve@5.15.0: + resolution: {integrity: sha512-LXYT42KJ7lpIKECr2mAXIaMldcNCh/7E0KBKOu4KSfkHmP+mZmSs+8V5gBAqisWBy0OO4W5Oyys0GO1Y8KtdKg==} + engines: {node: '>=10.13.0'} + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + dev: true + /escalade@3.1.2: resolution: {integrity: sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==} engines: {node: '>=6'} @@ -688,6 +729,10 @@ packages: slash: 3.0.0 dev: true + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + dev: true + /graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} @@ -813,7 +858,6 @@ packages: engines: {node: '>=10'} dependencies: yallist: 4.0.0 - dev: true /make-dir@4.0.0: resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} @@ -964,7 +1008,6 @@ packages: hasBin: true dependencies: lru-cache: 6.0.0 - dev: true /shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} @@ -1016,6 +1059,11 @@ packages: dependencies: has-flag: 4.0.0 + /tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + dev: true + /test-exclude@6.0.0: resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} engines: {node: '>=8'} @@ -1104,7 +1152,6 @@ packages: /yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - dev: true /yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} diff --git a/test/bar.peggy b/test/bar.peggy new file mode 100644 index 0000000..7d72e15 --- /dev/null +++ b/test/bar.peggy @@ -0,0 +1,2 @@ +import Foo from "./fixtures/foo.js" +Bar = Foo diff --git a/test/fixtures/foo.js b/test/fixtures/foo.js new file mode 100644 index 0000000..cc3e129 --- /dev/null +++ b/test/fixtures/foo.js @@ -0,0 +1,395 @@ +// @generated by Peggy 4.0.0. +// +// https://peggyjs.org/ + + +function peg$subclass(child, parent) { + function C() { this.constructor = child; } + C.prototype = parent.prototype; + child.prototype = new C(); +} + +function peg$SyntaxError(message, expected, found, location) { + var self = Error.call(this, message); + // istanbul ignore next Check is a necessary evil to support older environments + if (Object.setPrototypeOf) { + Object.setPrototypeOf(self, peg$SyntaxError.prototype); + } + self.expected = expected; + self.found = found; + self.location = location; + self.name = "SyntaxError"; + return self; +} + +peg$subclass(peg$SyntaxError, Error); + +function peg$padEnd(str, targetLength, padString) { + padString = padString || " "; + if (str.length > targetLength) { return str; } + targetLength -= str.length; + padString += padString.repeat(targetLength); + return str + padString.slice(0, targetLength); +} + +peg$SyntaxError.prototype.format = function(sources) { + var str = "Error: " + this.message; + if (this.location) { + var src = null; + var k; + for (k = 0; k < sources.length; k++) { + if (sources[k].source === this.location.source) { + src = sources[k].text.split(/\r\n|\n|\r/g); + break; + } + } + var s = this.location.start; + var offset_s = (this.location.source && (typeof this.location.source.offset === "function")) + ? this.location.source.offset(s) + : s; + var loc = this.location.source + ":" + offset_s.line + ":" + offset_s.column; + if (src) { + var e = this.location.end; + var filler = peg$padEnd("", offset_s.line.toString().length, ' '); + var line = src[s.line - 1]; + var last = s.line === e.line ? e.column : line.length + 1; + var hatLen = (last - s.column) || 1; + str += "\n --> " + loc + "\n" + + filler + " |\n" + + offset_s.line + " | " + line + "\n" + + filler + " | " + peg$padEnd("", s.column - 1, ' ') + + peg$padEnd("", hatLen, "^"); + } else { + str += "\n at " + loc; + } + } + return str; +}; + +peg$SyntaxError.buildMessage = function(expected, found) { + var DESCRIBE_EXPECTATION_FNS = { + literal: function(expectation) { + return "\"" + literalEscape(expectation.text) + "\""; + }, + + class: function(expectation) { + var escapedParts = expectation.parts.map(function(part) { + return Array.isArray(part) + ? classEscape(part[0]) + "-" + classEscape(part[1]) + : classEscape(part); + }); + + return "[" + (expectation.inverted ? "^" : "") + escapedParts.join("") + "]"; + }, + + any: function() { + return "any character"; + }, + + end: function() { + return "end of input"; + }, + + other: function(expectation) { + return expectation.description; + } + }; + + function hex(ch) { + return ch.charCodeAt(0).toString(16).toUpperCase(); + } + + function literalEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/"/g, "\\\"") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function classEscape(s) { + return s + .replace(/\\/g, "\\\\") + .replace(/\]/g, "\\]") + .replace(/\^/g, "\\^") + .replace(/-/g, "\\-") + .replace(/\0/g, "\\0") + .replace(/\t/g, "\\t") + .replace(/\n/g, "\\n") + .replace(/\r/g, "\\r") + .replace(/[\x00-\x0F]/g, function(ch) { return "\\x0" + hex(ch); }) + .replace(/[\x10-\x1F\x7F-\x9F]/g, function(ch) { return "\\x" + hex(ch); }); + } + + function describeExpectation(expectation) { + return DESCRIBE_EXPECTATION_FNS[expectation.type](expectation); + } + + function describeExpected(expected) { + var descriptions = expected.map(describeExpectation); + var i, j; + + descriptions.sort(); + + if (descriptions.length > 0) { + for (i = 1, j = 1; i < descriptions.length; i++) { + if (descriptions[i - 1] !== descriptions[i]) { + descriptions[j] = descriptions[i]; + j++; + } + } + descriptions.length = j; + } + + switch (descriptions.length) { + case 1: + return descriptions[0]; + + case 2: + return descriptions[0] + " or " + descriptions[1]; + + default: + return descriptions.slice(0, -1).join(", ") + + ", or " + + descriptions[descriptions.length - 1]; + } + } + + function describeFound(found) { + return found ? "\"" + literalEscape(found) + "\"" : "end of input"; + } + + return "Expected " + describeExpected(expected) + " but " + describeFound(found) + " found."; +}; + +function peg$parse(input, options) { + options = options !== undefined ? options : {}; + + var peg$FAILED = {}; + var peg$source = options.grammarSource; + + var peg$startRuleFunctions = { Foo: peg$parseFoo }; + var peg$startRuleFunction = peg$parseFoo; + + var peg$c0 = "foo"; + + + var peg$e0 = peg$literalExpectation("foo", false); + + var peg$currPos = options.peg$currPos | 0; + var peg$savedPos = peg$currPos; + var peg$posDetailsCache = [{ line: 1, column: 1 }]; + var peg$maxFailPos = peg$currPos; + var peg$maxFailExpected = options.peg$maxFailExpected || []; + var peg$silentFails = options.peg$silentFails | 0; + + var peg$result; + + if (options.startRule) { + if (!(options.startRule in peg$startRuleFunctions)) { + throw new Error("Can't start parsing from rule \"" + options.startRule + "\"."); + } + + peg$startRuleFunction = peg$startRuleFunctions[options.startRule]; + } + + function text() { + return input.substring(peg$savedPos, peg$currPos); + } + + function offset() { + return peg$savedPos; + } + + function range() { + return { + source: peg$source, + start: peg$savedPos, + end: peg$currPos + }; + } + + function location() { + return peg$computeLocation(peg$savedPos, peg$currPos); + } + + function expected(description, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildStructuredError( + [peg$otherExpectation(description)], + input.substring(peg$savedPos, peg$currPos), + location + ); + } + + function error(message, location) { + location = location !== undefined + ? location + : peg$computeLocation(peg$savedPos, peg$currPos); + + throw peg$buildSimpleError(message, location); + } + + function peg$literalExpectation(text, ignoreCase) { + return { type: "literal", text: text, ignoreCase: ignoreCase }; + } + + function peg$classExpectation(parts, inverted, ignoreCase) { + return { type: "class", parts: parts, inverted: inverted, ignoreCase: ignoreCase }; + } + + function peg$anyExpectation() { + return { type: "any" }; + } + + function peg$endExpectation() { + return { type: "end" }; + } + + function peg$otherExpectation(description) { + return { type: "other", description: description }; + } + + function peg$computePosDetails(pos) { + var details = peg$posDetailsCache[pos]; + var p; + + if (details) { + return details; + } else { + if (pos >= peg$posDetailsCache.length) { + p = peg$posDetailsCache.length - 1; + } else { + p = pos; + while (!peg$posDetailsCache[--p]) {} + } + + details = peg$posDetailsCache[p]; + details = { + line: details.line, + column: details.column + }; + + while (p < pos) { + if (input.charCodeAt(p) === 10) { + details.line++; + details.column = 1; + } else { + details.column++; + } + + p++; + } + + peg$posDetailsCache[pos] = details; + + return details; + } + } + + function peg$computeLocation(startPos, endPos, offset) { + var startPosDetails = peg$computePosDetails(startPos); + var endPosDetails = peg$computePosDetails(endPos); + + var res = { + source: peg$source, + start: { + offset: startPos, + line: startPosDetails.line, + column: startPosDetails.column + }, + end: { + offset: endPos, + line: endPosDetails.line, + column: endPosDetails.column + } + }; + if (offset && peg$source && (typeof peg$source.offset === "function")) { + res.start = peg$source.offset(res.start); + res.end = peg$source.offset(res.end); + } + return res; + } + + function peg$fail(expected) { + if (peg$currPos < peg$maxFailPos) { return; } + + if (peg$currPos > peg$maxFailPos) { + peg$maxFailPos = peg$currPos; + peg$maxFailExpected = []; + } + + peg$maxFailExpected.push(expected); + } + + function peg$buildSimpleError(message, location) { + return new peg$SyntaxError(message, null, null, location); + } + + function peg$buildStructuredError(expected, found, location) { + return new peg$SyntaxError( + peg$SyntaxError.buildMessage(expected, found), + expected, + found, + location + ); + } + + function peg$parseFoo() { + var s0; + + if (input.substr(peg$currPos, 3) === peg$c0) { + s0 = peg$c0; + peg$currPos += 3; + } else { + s0 = peg$FAILED; + if (peg$silentFails === 0) { peg$fail(peg$e0); } + } + + return s0; + } + + peg$result = peg$startRuleFunction(); + + if (options.peg$library) { + return /** @type {any} */ ({ + peg$result, + peg$currPos, + peg$FAILED, + peg$maxFailExpected, + peg$maxFailPos + }); + } + if (peg$result !== peg$FAILED && peg$currPos === input.length) { + return peg$result; + } else { + if (peg$result !== peg$FAILED && peg$currPos < input.length) { + peg$fail(peg$endExpectation()); + } + + throw peg$buildStructuredError( + peg$maxFailExpected, + peg$maxFailPos < input.length ? input.charAt(peg$maxFailPos) : null, + peg$maxFailPos < input.length + ? peg$computeLocation(peg$maxFailPos, peg$maxFailPos + 1) + : peg$computeLocation(peg$maxFailPos, peg$maxFailPos) + ); + } +} + +const peg$allowedStartRules = [ + "Foo" +]; + +export { + peg$allowedStartRules as StartRules, + peg$SyntaxError as SyntaxError, + peg$parse as parse +}; diff --git a/test/fixtures/foo.peggy b/test/fixtures/foo.peggy new file mode 100644 index 0000000..9d12885 --- /dev/null +++ b/test/fixtures/foo.peggy @@ -0,0 +1 @@ +Foo = "foo" diff --git a/test/index-spec.js b/test/index-spec.js index 4939596..a605499 100644 --- a/test/index-spec.js +++ b/test/index-spec.js @@ -1,5 +1,6 @@ +import { default as peggy, withImports } from "../lib/index.js"; import assert from "node:assert"; -import peggy from "../lib/index.js"; +import semver from "semver"; import test from "node:test"; // Don't syntax-highlight the intentionally-invalid code. @@ -15,15 +16,15 @@ test("bad grammar", () => { () => bad`foo = "foo`, // The position of the dquote in the line above. This will change // if the line above moves in the file. - /index-spec\.js:15:21\n \|\n15 \| foo = "foo\n \| \^/ + /index-spec\.js:16:21\n \|\n16 \| foo = "foo\n \| \^/ ); }); test("bad input", () => { const parser = peggy`foo = "foo"`; assert.throws( - () => parser("bar"), - /^1 \| bar/m + () => parser("bar"), // This is line 26 + /^26 \| bar/m ); }); @@ -40,3 +41,48 @@ test("with options", () => { }); assert(events.length > 0); }); + +test("with library", async t => { + if (!semver.satisfies(process.version, ">=20.8")) { + t.skip("Requires node 20.8+"); + return; + } + + const parser = await withImports` + import Foo from "./fixtures/foo.js" + Bar = Foo`; + + assert.equal(parser("foo"), "foo"); +}); + +test("with library, errors", async t => { + if (!semver.satisfies(process.version, ">=20.8")) { + t.skip("Requires node 20.8+"); + return; + } + + await assert.rejects(() => withImports` + import Boo from "./fixtures/foo.js" + Bar = Foo`); +}); + +test("with library and options", async t => { + if (!semver.satisfies(process.version, ">=20.8")) { + t.skip("Requires node 20.8+"); + return; + } + + const peg = peggy.withImportsOptions({ trace: true }); + const parser = await peg` + import Foo from "./fixtures/foo.js" + Bar = Foo`; + const events = []; + parser("foo", { + tracer: { + trace(ev) { + events.push(ev); + }, + }, + }); + assert(events.length > 0); +}); diff --git a/test/utils-spec.js b/test/utils-spec.js new file mode 100644 index 0000000..ba27a70 --- /dev/null +++ b/test/utils-spec.js @@ -0,0 +1,19 @@ +import { callLocation, combine } from "../lib/utils.js"; +import assert from "node:assert"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +function comb(strings, ...values) { return combine(strings, values); } + +test("combine", () => { + assert.equal(comb``, ""); + assert.equal(comb`foo`, "foo"); + assert.equal(comb`${false}`, "false"); + assert.equal(comb`${false} is ${NaN}`, "false is NaN"); +}); + +test("callLocation", () => { + assert.equal(callLocation(1000).source, "peggy-tag"); + assert.equal(callLocation(1).source, fileURLToPath(import.meta.url)); + assert.match(callLocation(2).source, /^node:/); +}); diff --git a/types/index.d.ts b/types/index.d.ts index 3590e7d..94cf3e8 100644 --- a/types/index.d.ts +++ b/types/index.d.ts @@ -3,26 +3,55 @@ * * @param {string[]} strings The string portions of the template. * @param {...any} values The interpolated values of the template. - * @returns {function(string, peggy.ParserOptions): any} The parsing function. + * @returns {ParseFunction} The parsing function. * @example * import peg from "peggy-tag"; - * const parser = peg`foo = "foo"`; - * console.log(parser("foo")); + * const parse = peg`foo = "foo"`; + * console.log(parse("foo")); */ -declare function peg(strings: string[], ...values: any[]): (arg0: string, arg1: peggy.ParserOptions) => any; +declare function peg(strings: string[], ...values: any[]): ParseFunction; declare namespace peg { - /** - * Create a template string tag with non-default grammar generation options. - * - * @param {peggy.ParserBuildOptions|undefined} opts Grammar generation options. - * @returns {function(string[], ...any): function(string, peggy.ParserOptions): any} - * @example - * import peg from "peggy-tag"; - * import myPeg = peg.withOptions({trace: true}) - * const parser = myPeg`foo = "foo"`; - * console.log(parser("foo")); - */ - function withOptions(opts: peggy.ParserBuildOptions | undefined): (arg0: string[], ...arg1: any[]) => (arg0: string, arg1: peggy.ParserOptions) => any; + export { withOptions }; + export { withImports }; + export { withImportsOptions }; } export default peg; +/** + * Create a template string tag with non-default grammar generation options. + * + * @param {peggy.ParserBuildOptions|undefined} opts Grammar generation options. + * @returns {function(string[], ...any): ParseFunction} + * @example + * import { withOptions } from "peggy-tag"; + * import myPeg = withOptions({trace: true}) + * const parser = myPeg`foo = "foo"`; + * console.log(parser("foo")); + */ +export function withOptions(opts: peggy.ParserBuildOptions | undefined): (arg0: string[], ...args: any[]) => ParseFunction; +/** + * Create a parse from a string that may include import statements. + * + * @param {string[]} strings The string portions of the template. + * @param {...any} values The interpolated values of the template. + * @returns {Promise} The parsing function. + * @example + * import { withImports } from "peggy-tag"; + * const parse = await withImports`foo = "foo"`; + * console.log(parse("foo")); + */ +export function withImports(strings: string[], ...values: any[]): Promise; +/** + * Create a template string tag with non-default grammar generation options, + * for grammars that include imports. + * + * @param {peggy.ParserBuildOptions|undefined} opts Grammar generation options. + * @returns {function(string[], ...any): Promise} + * @example + * import { withImportsOptions } from "peggy-tag"; + * import myPeg = peg.withOptions({trace: true}) + * const parser = await myPeg`foo = "foo"`; + * console.log(parser("foo")); + */ +export function withImportsOptions(opts: peggy.ParserBuildOptions | undefined): (arg0: string[], ...args: any[]) => Promise; +export type ParseFunction = (arg0: string, arg1: peggy.ParserOptions) => any; import peggy from "peggy"; diff --git a/types/utils.d.ts b/types/utils.d.ts new file mode 100644 index 0000000..52d91f5 --- /dev/null +++ b/types/utils.d.ts @@ -0,0 +1,35 @@ +/** + * Generate a GrammarLocation for one of the functions up the call stack + * from here. 0 is not useful, it's always the callLocation function. + * 1 is unlikely to be useful, it's the place you are calling callLocation from, + * so you presumably know where you are. 2 is the caller of the function you + * are in, etc. + * + * @param {number} depth How deep in the callstack to go? + * @param {number} [offset=1] How many characters to add to the location to + * account for the calling apparatus, such as the backtick or the function + * name + paren. + * @returns {peggy.GrammarLocation} Location of the grammar in the enclosing + * file. + * @see https://v8.dev/docs/stack-trace-api + */ +export function callLocation(depth: number, offset?: number | undefined): peggy.GrammarLocation; +/** + * Combine the parameters from a tagged template literal into a string. + * + * @param {string[]} strings + * @param {any[]} values + * @returns {string} + */ +export function combine(strings: string[], values: any[]): string; +/** + * If this is a grammar error, reformat the message using the associated + * text. + * + * @param {any} error An error th + * @param {any} source + * @param {string} text + * @returns {Error} Error with reformatted message, if possible + */ +export function formatMessage(error: any, source: any, text: string): Error; +import peggy from "peggy";