Skip to content

Commit ee077f1

Browse files
committed
Multi-errors: Capture all errors in each compilation stage
(review with "ignore whitespace changes" on)
1 parent 674a1d8 commit ee077f1

File tree

7 files changed

+332
-55
lines changed

7 files changed

+332
-55
lines changed

lib/compiler/index.js

+3
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,9 @@ const compiler = {
9898

9999
pass(ast, options, session);
100100
});
101+
102+
// Collect all errors by stage
103+
session.checkErrors();
101104
});
102105

103106
switch (options.output) {

lib/compiler/session.js

+31-2
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,8 @@ class Defaults {
1111
if (typeof options.info === "function") { this.info = options.info; }
1212
}
1313

14-
error(stage, ...args) {
15-
throw new GrammarError(...args);
14+
error() {
15+
// Intentionally empty placeholder
1616
}
1717

1818
warning() {
@@ -27,13 +27,36 @@ class Defaults {
2727
class Session {
2828
constructor(options) {
2929
this._callbacks = new Defaults(options);
30+
this._firstError = null;
3031
this.errors = 0;
32+
/** @type {import("../peg").Problem[]} */
3133
this.problems = [];
34+
/** @type {import("../peg").Stage} */
3235
this.stage = null;
3336
}
3437

3538
error(...args) {
3639
++this.errors;
40+
// In order to preserve backward compatibility we cannot change `GrammarError`
41+
// constructor, nor throw another class of error:
42+
// - if we change `GrammarError` constructor, this will break plugins that
43+
// throws `GrammarError`
44+
// - if we throw another Error class, this will break parser clients that
45+
// catches GrammarError
46+
//
47+
// So we use a compromise: we throw an `GrammarError` with all found problems
48+
// in the `problems` property, but the thrown error itself is the first
49+
// registered error.
50+
//
51+
// Thus when the old client catches the error it can find all properties on
52+
// the Grammar error that it want. On the other hand the new client can
53+
// inspect the `problems` property to get all problems.
54+
if (this._firstError === null) {
55+
this._firstError = new GrammarError(...args);
56+
this._firstError.stage = this.stage;
57+
this._firstError.problems = this.problems;
58+
}
59+
3760
this.problems.push(["error", ...args]);
3861
this._callbacks.error(this.stage, ...args);
3962
}
@@ -47,6 +70,12 @@ class Session {
4770
this.problems.push(["info", ...args]);
4871
this._callbacks.info(this.stage, ...args);
4972
}
73+
74+
checkErrors() {
75+
if (this.errors !== 0) {
76+
throw this._firstError;
77+
}
78+
}
5079
}
5180

5281
module.exports = Session;

lib/grammar-error.js

+48-25
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ const setProtoOf = Object.setPrototypeOf
1717
};
1818

1919
// Thrown when the grammar contains an error.
20+
/** @type {import("./peg").GrammarError} */
2021
class GrammarError extends Error {
2122
constructor(message, location, diagnostics) {
2223
super(message);
@@ -27,6 +28,10 @@ class GrammarError extends Error {
2728
diagnostics = [];
2829
}
2930
this.diagnostics = diagnostics;
31+
// All problems if this error is thrown by the plugin and not at stage
32+
// checking phase
33+
this.stage = null;
34+
this.problems = [["error", message, location, diagnostics]];
3035
}
3136

3237
toString() {
@@ -51,9 +56,6 @@ class GrammarError extends Error {
5156
return str;
5257
}
5358

54-
/**
55-
* @typedef SourceText {source: any, text: string}
56-
*/
5759
/**
5860
* Format the error with associated sources. The `location.source` should have
5961
* a `toString()` representation in order the result to look nice. If source
@@ -73,7 +75,7 @@ class GrammarError extends Error {
7375
* | ^^^^
7476
* ```
7577
*
76-
* @param {SourceText[]} sources mapping from location source to source text
78+
* @param {import("./peg").SourceText[]} sources mapping from location source to source text
7779
*
7880
* @returns {string} the formatted error
7981
*/
@@ -83,6 +85,14 @@ class GrammarError extends Error {
8385
text: text.split(/\r\n|\n|\r/g),
8486
}));
8587

88+
/**
89+
* Returns a highlighted piece of source to which the `location` points
90+
*
91+
* @param {import("./peg").LocationRange} location
92+
* @param {number} indent How much width in symbols line number strip should have
93+
* @param {string} message Additional message that will be shown after location
94+
* @returns {string}
95+
*/
8696
function entry(location, indent, message = "") {
8797
let str = "";
8898
const src = srcLines.find(({ source }) => source === location.source);
@@ -109,30 +119,43 @@ ${"".padEnd(indent)} | ${"".padEnd(s.column - 1)}${"".padEnd(last - s.column, "^
109119
return str;
110120
}
111121

112-
// Calculate maximum width of all lines
113-
let maxLine;
114-
if (this.location) {
115-
maxLine = this.diagnostics.reduce(
116-
(t, { location }) => Math.max(t, location.start.line),
117-
this.location.start.line
118-
);
119-
} else {
120-
maxLine = Math.max.apply(
121-
null,
122-
this.diagnostics.map(d => d.location.start.line)
123-
);
124-
}
125-
maxLine = maxLine.toString().length;
122+
/**
123+
* Returns a formatted representation of the one problem in the error.
124+
*
125+
* @param {import("./peg").Severity} severity Importance of the message
126+
* @param {string} message Test message of the problem
127+
* @param {import("./peg").LocationRange?} location Location of the problem in the source
128+
* @param {import("./peg").DiagnosticNote[]} diagnostics Additional notes about the problem
129+
* @returns {string}
130+
*/
131+
function formatProblem(severity, message, location, diagnostics) {
132+
// Calculate maximum width of all lines
133+
let maxLine;
134+
if (location) {
135+
maxLine = diagnostics.reduce(
136+
(t, { location }) => Math.max(t, location.start.line),
137+
location.start.line
138+
);
139+
} else {
140+
maxLine = Math.max.apply(
141+
null,
142+
diagnostics.map(d => d.location.start.line)
143+
);
144+
}
145+
maxLine = maxLine.toString().length;
126146

127-
let str = `Error: ${this.message}`;
128-
if (this.location) {
129-
str += entry(this.location, maxLine);
130-
}
131-
for (const diag of this.diagnostics) {
132-
str += entry(diag.location, maxLine, diag.message);
147+
let str = `${severity}: ${message}`;
148+
if (location) {
149+
str += entry(location, maxLine);
150+
}
151+
for (const diag of diagnostics) {
152+
str += entry(diag.location, maxLine, diag.message);
153+
}
154+
155+
return str;
133156
}
134157

135-
return str;
158+
return this.problems.map(p => formatProblem(p[0], p[1], p[2], p[3])).join("\n\n");
136159
}
137160
}
138161

lib/peg.d.ts

+77
Original file line numberDiff line numberDiff line change
@@ -271,6 +271,22 @@ export interface DiagnosticNote {
271271
location: LocationRange;
272272
}
273273

274+
/** Possible compilation stage name. */
275+
type Stage = keyof Stages;
276+
/** Severity level of problems that can be registered in compilation session. */
277+
type Severity = "error" | "warning" | "info";
278+
279+
type Problem = [
280+
/** Problem severity. */
281+
Severity,
282+
/** Diagnostic message. */
283+
string,
284+
/** Location where message is generated, if applicable. */
285+
LocationRange?,
286+
/** List of additional messages with their locations, if applicable. */
287+
DiagnosticNote[]?,
288+
];
289+
274290
/** Thrown if the grammar contains a semantic error. */
275291
export class GrammarError extends Error {
276292
/** Location of the error in the source. */
@@ -279,6 +295,15 @@ export class GrammarError extends Error {
279295
/** Additional messages with context information. */
280296
diagnostics: DiagnosticNote[];
281297

298+
/** Compilation stage during which error was generated. */
299+
stage: Stage | null;
300+
301+
/**
302+
* List of diagnostics containing all errors, warnings and information
303+
* messages generated during compilation stage `stage`.
304+
*/
305+
problems: Problem[];
306+
282307
constructor(
283308
message: string,
284309
location?: LocationRange,
@@ -951,6 +976,51 @@ export interface Session {
951976
): void;
952977
}
953978

979+
export interface DiagnosticCallback {
980+
/**
981+
* Called when compiler reports an error.
982+
*
983+
* @param stage Stage in which this diagnostic was originated
984+
* @param message Main message, which should describe error objectives
985+
* @param location If defined, this is location described in the `message`
986+
* @param notes Additional messages with context information
987+
*/
988+
error?(
989+
stage: Stage,
990+
message: string,
991+
location?: LocationRange,
992+
notes?: DiagnosticNote[]
993+
): void;
994+
/**
995+
* Called when compiler reports a warning.
996+
*
997+
* @param stage Stage in which this diagnostic was originated
998+
* @param message Main message, which should describe warning objectives
999+
* @param location If defined, this is location described in the `message`
1000+
* @param notes Additional messages with context information
1001+
*/
1002+
warning?(
1003+
stage: Stage,
1004+
message: string,
1005+
location?: LocationRange,
1006+
notes?: DiagnosticNote[]
1007+
): void;
1008+
/**
1009+
* Called when compiler reports an informational message.
1010+
*
1011+
* @param stage Stage in which this diagnostic was originated
1012+
* @param message Main message, which gives information about an event
1013+
* @param location If defined, this is location described in the `message`
1014+
* @param notes Additional messages with context information
1015+
*/
1016+
info?(
1017+
stage: Stage,
1018+
message: string,
1019+
location?: LocationRange,
1020+
notes?: DiagnosticNote[]
1021+
): void;
1022+
}
1023+
9541024
/**
9551025
* Parser dependencies, is an object which maps variables used to access the
9561026
* dependencies in the parser to module IDs used to load them
@@ -988,6 +1058,13 @@ export interface BuildOptionsBase {
9881058

9891059
/** Makes the parser trace its progress (default: `false`) */
9901060
trace?: boolean;
1061+
1062+
/** Called when a semantic error during build was detected. */
1063+
error?: DiagnosticCallback;
1064+
/** Called when a warning during build was registered. */
1065+
warning?: DiagnosticCallback;
1066+
/** Called when an informational message during build was registered. */
1067+
info?: DiagnosticCallback;
9911068
}
9921069

9931070
export interface ParserBuildOptions extends BuildOptionsBase {

test/behavior/generated-parser-behavior.spec.js

+31
Original file line numberDiff line numberDiff line change
@@ -1780,5 +1780,36 @@ Error: Expected "{", code block, comment, end of line, identifier, or whitespace
17801780
}
17811781
}).to.throw(peg.parser.SyntaxError);
17821782
});
1783+
1784+
it("reports multiple errors in each compilation stage", () => {
1785+
try {
1786+
peg.generate(`
1787+
start = leftRecursion
1788+
leftRecursion = duplicatedLabel:duplicatedRule duplicatedLabel:missingRule
1789+
duplicatedRule = missingRule
1790+
duplicatedRule = start
1791+
`);
1792+
} catch (e) {
1793+
expect(e).with.property("stage", "check");
1794+
expect(e).with.property("problems").to.be.an("array");
1795+
1796+
// Check that each problem is an array with at least two members and the first is a severity
1797+
e.problems.forEach(problem => {
1798+
expect(problem).to.be.an("array").lengthOf.gte(2);
1799+
expect(problem[0]).to.be.oneOf(["error", "warning", "info"]);
1800+
});
1801+
1802+
// Get second elements of errors (error messages)
1803+
const messages = e.problems.filter(p => p[0] === "error").map(p => p[1]);
1804+
1805+
// Check that all messages present in the list
1806+
expect(messages).to.include.members([
1807+
"Rule \"missingRule\" is not defined",
1808+
"Rule \"duplicatedRule\" is already defined",
1809+
"Label \"duplicatedLabel\" is already defined",
1810+
"Possible infinite loop when parsing (left recursion: duplicatedRule -> start -> leftRecursion -> duplicatedRule)",
1811+
]);
1812+
}
1813+
});
17831814
});
17841815
});

test/unit/compiler/passes/helpers.js

+7-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use strict";
22

3+
const { GrammarError } = require("../../../../lib/peg");
34
const parser = require("../../../../lib/parser");
45
const Session = require("../../../../lib/compiler/session");
56

@@ -47,7 +48,9 @@ module.exports = function(chai, utils) {
4748

4849
const ast = parser.parse(grammar);
4950

50-
utils.flag(this, "object")(ast, options, new Session());
51+
utils.flag(this, "object")(ast, options, new Session({
52+
error(stage, ...args) { throw new GrammarError(...args); },
53+
}));
5154

5255
this.assert(
5356
matchProps(ast, props),
@@ -64,7 +67,9 @@ module.exports = function(chai, utils) {
6467
let passed, result;
6568

6669
try {
67-
utils.flag(this, "object")(ast, {}, new Session());
70+
utils.flag(this, "object")(ast, {}, new Session({
71+
error(stage, ...args) { throw new GrammarError(...args); },
72+
}));
6873
passed = true;
6974
} catch (e) {
7075
result = e;

0 commit comments

Comments
 (0)