Skip to content

Commit

Permalink
Errors: Fix prototype chain and add ability to append diagnostic note…
Browse files Browse the repository at this point in the history
…s to the `GrammarError`
  • Loading branch information
hildjj authored and Mingun committed May 5, 2021
1 parent 723a5d2 commit 1290beb
Show file tree
Hide file tree
Showing 3 changed files with 255 additions and 6 deletions.
133 changes: 128 additions & 5 deletions lib/grammar-error.js
Original file line number Diff line number Diff line change
@@ -1,15 +1,138 @@
"use strict";

// See: https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work
// This is roughly what typescript generates, it's not called after super(), where it's needed.
const setProtoOf = Object.setPrototypeOf
|| ({ __proto__: [] } instanceof Array
&& function(d, b) {
// eslint-disable-next-line no-proto
d.__proto__ = b;
})
|| function(d, b) {
for (const p in b) {
if (Object.prototype.hasOwnProperty.call(b, p)) {
d[p] = b[p];
}
}
};

// Thrown when the grammar contains an error.
class GrammarError {
constructor(message, location) {
class GrammarError extends Error {
constructor(message, location, diagnostics) {
super(message);
setProtoOf(this, GrammarError.prototype);
this.name = "GrammarError";
this.message = message;
this.location = location;
if (diagnostics === undefined) {
diagnostics = [];
}
this.diagnostics = diagnostics;
}

if (typeof Error.captureStackTrace === "function") {
Error.captureStackTrace(this, GrammarError);
toString() {
let str = super.toString();
if (this.location) {
str += "\n at ";
if ((this.location.source !== undefined)
&& (this.location.source !== null)) {
str += `${this.location.source}:`;
}
str += `${this.location.start.line}:${this.location.start.column}`;
}
for (const diag of this.diagnostics) {
str += "\n from ";
if ((diag.location.source !== undefined)
&& (diag.location.source !== null)) {
str += `${diag.location.source}:`;
}
str += `${diag.location.start.line}:${diag.location.start.column}: ${diag.message}`;
}

return str;
}

/**
* @typedef SourceText {source: any, text: string}
*/
/**
* Format the error with associated sources. The `location.source` should have
* a `toString()` representation in order the result to look nice. If source
* is `null` or `undefined`, it is skipped from the output
*
* Sample output:
* ```
* Error: Label "head" is already defined
* --> examples/arithmetics.pegjs:15:17
* |
* 15 | = head:Factor head:(_ ("*" / "/") _ Factor)* {
* | ^^^^
* note: Original label location
* --> examples/arithmetics.pegjs:15:5
* |
* 15 | = head:Factor head:(_ ("*" / "/") _ Factor)* {
* | ^^^^
* ```
*
* @param {SourceText[]} sources mapping from location source to source text
*
* @returns {string} the formatted error
*/
format(sources) {
const srcLines = sources.map(({ source, text }) => ({
source,
text: text.split(/\r\n|\n|\r/g)
}));

function entry(location, indent, message = "") {
let str = "";
const src = srcLines.find(({ source }) => source === location.source);
const s = location.start;
if (src) {
const e = location.end;
const line = src.text[s.line - 1];
const last = s.line === e.line ? e.column : line.length + 1;
if (message) {
str += `\nnote: ${message}`;
}
str += `
--> ${location.source}:${s.line}:${s.column}
${"".padEnd(indent)} |
${s.line.toString().padStart(indent)} | ${line}
${"".padEnd(indent)} | ${"".padEnd(s.column - 1)}${"".padEnd(last - s.column, "^")}`;
} else {
str += `\n at ${location.source}:${s.line}:${s.column}`;
if (message) {
str += `: ${message}`;
}
}

return str;
}

// Calculate maximum width of all lines
let maxLine;
if (this.location) {
maxLine = this.diagnostics.reduce(
(t, { location }) => Math.max(t, location.start.line),
this.location.start.line
);
} else {
maxLine = Math.max.apply(
null,
this.diagnostics.map(d => d.location.start.line)
);
}
maxLine = maxLine.toString().length;

let str = `Error: ${this.message}`;
if (this.location) {
str += entry(this.location, maxLine);
}
for (const diag of this.diagnostics) {
str += entry(diag.location, maxLine, diag.message);
}

return str;
}
}

Expand Down
40 changes: 39 additions & 1 deletion lib/peg.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,51 @@ export interface ExpectedItem {
description: string;
}

// String passed in as `grammarSource` -> string version of source
export interface Sources {
source: string;
text: string;
}

export interface DiagnosticNote {
message: string;
location: LocationRange;
}

export interface PeggyError extends Error {
name: string;
message: string;
location: LocationRange;
location?: LocationRange;
diagnostics: DiagnosticNote[];
found?: any;
expected?: ExpectedItem[];
stack?: any;

/**
* Format the error with associated sources. The `location.source` should have
* a `toString()` representation in order the result to look nice. If source
* is `null` or `undefined`, it is skipped from the output
*
* Sample output:
* ```
* Error: Label "head" is already defined
* --> examples/arithmetics.pegjs:15:17
* |
* 15 | = head:Factor head:(_ ("*" / "/") _ Factor)* {
* | ^^^^
* note: Original label location
* --> examples/arithmetics.pegjs:15:5
* |
* 15 | = head:Factor head:(_ ("*" / "/") _ Factor)* {
* | ^^^^
* ```
*
* @param sources mapping from location source to source text
*
* @returns the formatted error
*/
format(sources: SourceText[]): string;
toString(): string;
}

// for backwards compatibility with PEGjs
Expand Down
88 changes: 88 additions & 0 deletions test/unit/grammar-error.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
"use strict";

const chai = require("chai");
const GrammarError = require("../../lib/grammar-error");

const expect = chai.expect;

const location = {
source: undefined,
start: { offset: 0, line: 1, column: 1 },
end: { offset: 4, line: 1, column: 5 }
};

describe("Grammar Errors", () => {
it("might not have a location", () => {
const e = new GrammarError("message");
expect(e.location).to.equal(undefined);
expect(e.toString()).to.equal("GrammarError: message");
});
it("might have locations", () => {
location.source = undefined;
let e = new GrammarError("message", location);
expect(e.location).to.eql(location);
expect(e.toString()).to.equal(`\
GrammarError: message
at 1:1`);

e = new GrammarError("message", null, [{ message: "Subinfo", location }]);
expect(e.location).to.equal(null);
expect(e.toString()).to.equal(`\
GrammarError: message
from 1:1: Subinfo`);

location.source = "foo.peggy";
e = new GrammarError("message", location, [{ message: "Subinfo", location }]);
expect(e.toString()).to.equal(`\
GrammarError: message
at foo.peggy:1:1
from foo.peggy:1:1: Subinfo`);
});

it("formats", () => {
location.source = "foo.peggy";
const source = {
source: "foo.peggy",
text: "some error\nthat"
};
let e = new GrammarError("message", location, [{
message: "Subinfo",
location: {
source: "foo.peggy",
start: { offset: 5, line: 1, column: 6 },
end: { offset: 11, line: 2, column: 1 }
}
}]);
expect(e.format([source])).to.equal(`\
Error: message
--> foo.peggy:1:1
|
1 | some error
| ^^^^
note: Subinfo
--> foo.peggy:1:6
|
1 | some error
| ^^^^^`);
expect (e.format([])).to.equal(`\
Error: message
at foo.peggy:1:1
at foo.peggy:1:6: Subinfo`);

e = new GrammarError("message", null, [{
message: "Subinfo",
location: {
source: "foo.peggy",
start: { offset: 5, line: 1, column: 6 },
end: { offset: 11, line: 2, column: 1 }
}
}]);
expect(e.format([source])).to.equal(`\
Error: message
note: Subinfo
--> foo.peggy:1:6
|
1 | some error
| ^^^^^`);
});
});

0 comments on commit 1290beb

Please sign in to comment.