Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: use own error printing instead of Ohm's #1203

Merged
merged 5 commits into from
Dec 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import path from "path";
import { cwd } from "process";
import { AstFuncId, AstId, AstTypeId } from "./grammar/ast";
import { ItemOrigin, SrcInfo } from "./grammar";
import { getSrcInfoFromOhm } from "./grammar/src-info";

export class TactError extends Error {
readonly loc?: SrcInfo;
Expand Down Expand Up @@ -64,7 +65,7 @@ export function throwParseError(
origin: ItemOrigin,
): never {
const interval = matchResult.getInterval();
const source = new SrcInfo(interval, path, origin);
const source = getSrcInfoFromOhm(interval, path, origin);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const message = `Parse error: expected ${(matchResult as any).getExpectedText()}\n`;
throw new TactParseError(
Expand Down
3 changes: 1 addition & 2 deletions src/grammar/ast.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { dummySrcInfo } from "./grammar";
import { SrcInfo } from "./src-info";
import { dummySrcInfo, SrcInfo } from "./src-info";

export type AstModule = {
kind: "module";
Expand Down
4 changes: 2 additions & 2 deletions src/grammar/grammar.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { AstModule, getAstFactory } from "./ast";
import { loadCases } from "../utils/loadCases";
import { getParser } from "./grammar";
import { SrcInfo } from "./src-info";
import { SrcInfo, isSrcInfo } from "./src-info";

expect.addSnapshotSerializer({
test: (src) => src instanceof SrcInfo,
test: (src) => isSrcInfo(src),
print: (src) => (src as SrcInfo).contents,
});

Expand Down
10 changes: 3 additions & 7 deletions src/grammar/grammar.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Node, IterationNode, NonterminalNode, grammar, Grammar } from "ohm-js";
import { Node, IterationNode, NonterminalNode } from "ohm-js";
import tactGrammar from "./grammar.ohm-bundle";
import { throwInternalCompilerError } from "../errors";
import {
Expand All @@ -22,11 +22,7 @@ import { throwParseError, throwSyntaxError } from "../errors";
import { checkVariableName } from "./checkVariableName";
import { checkFunctionAttributes } from "./checkFunctionAttributes";
import { checkConstAttributes } from "./checkConstAttributes";
import { ItemOrigin, SrcInfo } from "./src-info";

const DummyGrammar: Grammar = grammar("Dummy { DummyRule = any }");
const DUMMY_INTERVAL = DummyGrammar.match("").getInterval();
export const dummySrcInfo: SrcInfo = new SrcInfo(DUMMY_INTERVAL, null, "user");
import { getSrcInfoFromOhm, ItemOrigin, SrcInfo } from "./src-info";

type Context = {
origin: ItemOrigin | null;
Expand Down Expand Up @@ -56,7 +52,7 @@ function createRef(s: Node): SrcInfo {
throwInternalCompilerError("Parser context was not initialized");
}

return new SrcInfo(s.source, context.currentFile, context.origin);
return getSrcInfoFromOhm(s.source, context.currentFile, context.origin);
}

const createNode: FactoryAst["createNode"] = (...args) => {
Expand Down
4 changes: 2 additions & 2 deletions src/grammar/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
export { dummySrcInfo, getParser, Parser } from "./grammar";
export { getParser, Parser } from "./grammar";

export { ItemOrigin, SrcInfo } from "./src-info";
export { dummySrcInfo, ItemOrigin, SrcInfo } from "./src-info";
2 changes: 1 addition & 1 deletion src/grammar/rename.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ import {
} from "./ast";
import { AstSorter } from "./sort";
import { AstHasher, AstHash } from "./hash";
import { dummySrcInfo } from "./grammar";
import { dummySrcInfo } from "./src-info";

type GivenName = string;

Expand Down
303 changes: 275 additions & 28 deletions src/grammar/src-info.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,286 @@
import { Interval as RawInterval } from "ohm-js";

import { throwInternalCompilerError } from "../errors";

export type ItemOrigin = "stdlib" | "user";

type LineAndColumnInfo = {
lineNum: number;
colNum: number;
toString(...ranges: number[][]): string;
};

type Interval = {
contents: string;
getLineAndColumnMessage(): string;
getLineAndColumn(): LineAndColumnInfo;
startIdx: number;
endIdx: number;
};

// Do not export! Use isSrcInfo
const srcInfoSymbol = Symbol("src-info");

export const isSrcInfo = (t: unknown): t is SrcInfo => {
return (
typeof t === "object" &&
t !== null &&
srcInfoSymbol in t &&
Boolean(t[srcInfoSymbol])
);
};

export interface SrcInfo {
file: string | null;
contents: string;
interval: Interval;
origin: ItemOrigin;

/**
* Tag so that custom snapshot serializer can distinguish it
*/
[srcInfoSymbol]: true;
/**
* toJSON method is provided, so that it's not serialized into snapshots
*/
toJSON: () => object;
}

const isEndline = (s: string) => s === "\n";

const repeat = (s: string, n: number): string => new Array(n + 1).join(s);

type Range = {
start: number;
end: number;
};

const intersect = (a: Range, b: Range): Range => {
return {
start: Math.max(a.start, b.start),
end: Math.min(a.end, b.end),
};
};

const shift = (a: Range, b: number) => {
return {
start: a.start + b,
end: a.end + b,
};
};

type Line = {
id: number;
text: string;
range: Range;
};

/**
* Information about source code location (file and interval within it)
* and the source code contents.
* Convert code into a list of lines
*/
export class SrcInfo {
readonly #interval: RawInterval;
readonly #file: string | null;
readonly #origin: ItemOrigin;

constructor(
interval: RawInterval,
file: string | null,
origin: ItemOrigin,
) {
this.#interval = interval;
this.#file = file;
this.#origin = origin;
const toLines = (source: string): Line[] => {
const result: Line[] = [];
let position = 0;
for (const [id, text] of source.split("\n").entries()) {
result.push({
id,
text,
range: {
start: position,
end: position + text.length,
},
});
position += text.length + 1;
}
return result;
};

get file() {
return this.#file;
}
/**
* Should wrap string into ANSI codes for coloring
*/
type Colorer = (s: string) => string;

get contents() {
return this.#interval.contents;
}
type ErrorPrinterParams = {
/**
* Number of context lines below and above error
*/
contextLines: number;
/**
* Colorer for code with error
*/
error: Colorer;
/**
* Colorer for context lines of code
*/
context: Colorer;
};

get interval() {
return this.#interval;
}
const getErrorPrinter = ({
error,
context,
contextLines,
}: ErrorPrinterParams) => {
const displayLine = (line: Line, range: Range) => {
// Only the line that contains range.start is underlined in error message
// Otherwise error on `while (...) {}` would display the whole loop body, for example
const hasInterval =
line.range.start <= range.start && range.start < line.range.end;

get origin() {
return this.#origin;
}
}
// Find the line-relative range
const mapped = shift(intersect(range, line.range), -line.range.start);

// All lines except with error message are displayed in gray
if (!hasInterval) {
return [
{
id: line.id,
text: context(line.text),
hasInterval,
startOfError: mapped.start,
},
];
}

// Source line with error colored
const sourceLine = {
id: line.id,
text: [
line.text.substring(0, mapped.start),
error(line.text.substring(mapped.start, mapped.end)),
line.text.substring(mapped.end),
].join(""),
hasInterval: true,
startOfError: mapped.start,
};

// Wiggly line underneath it
const underline = {
id: null,
text: [
repeat(" ", mapped.start),
"^",
repeat("~", Math.max(0, mapped.end - mapped.start - 1)),
].join(""),
hasInterval: true,
startOfError: mapped.start,
};

return [sourceLine, underline];
};

const show = (str: string, range: Range): string => {
// Display all lines of source file
const lines = toLines(str).flatMap((line) => displayLine(line, range));

// Find first and lines lines with error message
const firstLineNum = lines.findIndex((line) => line.hasInterval);
const lastLineNum = lines.findLastIndex((line) => line.hasInterval);
if (firstLineNum === -1 || lastLineNum === -1) {
throwInternalCompilerError(
`Interval [${range.start}, ${range.end}[ is empty or out of source bounds (${str.length})`,
);
}

// Expand the line range so that `contextLines` are above and below
const rangeStart = Math.max(0, firstLineNum - contextLines);
const rangeEnd = Math.min(lines.length - 1, lastLineNum + contextLines);

// Pick displayed lines out of full list
const displayedLines = lines.slice(rangeStart, rangeEnd + 1);

// Find padding based on the line with largest line number
const maxLineId = displayedLines.reduce((acc, line) => {
return line.id === null ? acc : Math.max(acc, line.id);
}, 1);
const lineNumLength = String(maxLineId + 1).length;

// Add line numbers and cursor to lines
const paddedLines = displayedLines.map(({ hasInterval, id, text }) => {
const prefix = hasInterval && id !== null ? ">" : " ";
const paddedLineNum =
id === null
? repeat(" ", lineNumLength) + " "
: String(id + 1).padStart(lineNumLength) + " |";
return `${prefix} ${paddedLineNum} ${text}`;
});

// Add header and concatenate lines
const header = `Line ${firstLineNum + 1}, col ${(lines[firstLineNum]?.startOfError ?? 0) + 1}:`;
return [header, ...paddedLines].join("\n") + "\n";
};

const getLineAndColumn = (str: string, range: Range) => {
const prefix = str.substring(0, range.start).split("");
const lineNum = prefix.filter(isEndline).length;
const prevLineEndPos = prefix.findLastIndex(isEndline);
const lineStartPos = prevLineEndPos === -1 ? 0 : prevLineEndPos + 1;
const colNum = range.start - lineStartPos;

return {
offset: range.start,
lineNum: lineNum + 1,
colNum: colNum + 1,
toString: () => show(str, range),
};
};

return { show, getLineAndColumn };
};

// Default error printer. Should be initialized in entry point instead
const errorPrinter = getErrorPrinter({
// This should be `chalk.red`
error: (s) => s,
// This should be `chalk.gray`
context: (s) => s,
contextLines: 1,
});

const getSrcInfo = (
sourceString: string,
startIdx: number,
endIdx: number,
file: string | null,
origin: ItemOrigin,
): SrcInfo => {
const getLineAndColumn = () => {
return errorPrinter.getLineAndColumn(sourceString, {
start: startIdx,
end: endIdx,
});
};

const getLineAndColumnMessage = () => {
return getLineAndColumn().toString();
};

const contents = sourceString.substring(startIdx, endIdx);

return {
[srcInfoSymbol]: true,
contents: contents,
file,
interval: {
contents: contents,
startIdx: startIdx,
endIdx: endIdx,
getLineAndColumn,
getLineAndColumnMessage,
},
origin,
toJSON: () => ({}),
};
};

/**
* @deprecated
*/
export const getSrcInfoFromOhm = (
{ sourceString, startIdx, endIdx }: RawInterval,
file: string | null,
origin: ItemOrigin,
): SrcInfo => {
return getSrcInfo(sourceString, startIdx, endIdx, file, origin);
};

export const dummySrcInfo: SrcInfo = getSrcInfo("", 0, 0, null, "user");
Loading
Loading