Modularize UI, make it a bit more Lisp independent
This commit is contained in:
parent
e1e1b90579
commit
c3edf193c4
19 changed files with 973 additions and 884 deletions
78
src/languages/lisp/experiments.ts
Normal file
78
src/languages/lisp/experiments.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
import { CodePointString, sourceText } from 'source-region';
|
||||||
|
import { parseDocument, programOf } from './parser';
|
||||||
|
import { matchCodePointString } from '../../recognizers';
|
||||||
|
import { Program } from './syntax';
|
||||||
|
|
||||||
|
// === Experiments ===
|
||||||
|
|
||||||
|
function experiment00_emptyDocument(): void {
|
||||||
|
logParse("empty document", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function experiment01_topLevelExpressions(): void {
|
||||||
|
logParse("top-level expressions", "foo 123 (bar baz_1 qux-2) [a, b, c]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function experiment02_nestedLists(): void {
|
||||||
|
logParse("nested lists", "(define square (_ x) (* x x))");
|
||||||
|
}
|
||||||
|
|
||||||
|
function experiment03_unclosedList(): void {
|
||||||
|
logParse("unclosed list", "(foo 123\n (bar 456)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function experiment04_recoverAtDocumentLevel(): void {
|
||||||
|
logParse("document recovery", "foo ) @@@ (bar 1) 99");
|
||||||
|
}
|
||||||
|
|
||||||
|
function experiment05_recoverInsideList(): void {
|
||||||
|
logParse("list recovery", "(foo @@@ 1 (bar # 2) baz)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function experiment06_unicodeSpans(): void {
|
||||||
|
logParse("unicode spans", "alpha 💥 (beta 2)");
|
||||||
|
}
|
||||||
|
|
||||||
|
function experiment07_matchCodePointString(): void {
|
||||||
|
const region = sourceText("λx").fullRegion();
|
||||||
|
const cursor = region.makeCursor();
|
||||||
|
const lambda = CodePointString.makeFromString("λ");
|
||||||
|
console.log("==== recognizer:match code point string ====");
|
||||||
|
console.dir(matchCodePointString(cursor, lambda), { depth: null });
|
||||||
|
console.log("cursor", cursor.current());
|
||||||
|
}
|
||||||
|
|
||||||
|
function experiment08_squareListSeparator(): void {
|
||||||
|
logParse("square list separator", "[a, b c, d]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function experiment09_invalidNumberFragment(): void {
|
||||||
|
logParse("invalid number fragment", "123fasd");
|
||||||
|
}
|
||||||
|
|
||||||
|
function experiment10_repeatedLeadingComma(): void {
|
||||||
|
logParse("repeated leading comma", "[, , foo, bar]");
|
||||||
|
}
|
||||||
|
|
||||||
|
function logParse(name: string, input: string): void {
|
||||||
|
const region = sourceText(input).fullRegion();
|
||||||
|
const result = parseDocument(region);
|
||||||
|
console.log(`==== parser:${name} ====`);
|
||||||
|
console.log(input);
|
||||||
|
console.log(result.syntax.tag, Program.show(programOf(result.syntax)));
|
||||||
|
console.dir(result.errors, { depth: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
[
|
||||||
|
experiment00_emptyDocument,
|
||||||
|
experiment01_topLevelExpressions,
|
||||||
|
experiment02_nestedLists,
|
||||||
|
experiment03_unclosedList,
|
||||||
|
experiment04_recoverAtDocumentLevel,
|
||||||
|
experiment05_recoverInsideList,
|
||||||
|
experiment06_unicodeSpans,
|
||||||
|
experiment07_matchCodePointString,
|
||||||
|
experiment08_squareListSeparator,
|
||||||
|
experiment09_invalidNumberFragment,
|
||||||
|
experiment10_repeatedLeadingComma,
|
||||||
|
].forEach((experiment) => experiment());
|
||||||
22
src/languages/lisp/index.ts
Normal file
22
src/languages/lisp/index.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
export { parseDocument, programOf } from './parser';
|
||||||
|
export type {
|
||||||
|
ConcreteSyntaxResult,
|
||||||
|
ParseDocumentResult,
|
||||||
|
PartialConcreteSyntax,
|
||||||
|
ValidConcreteSyntax,
|
||||||
|
PartialExpr,
|
||||||
|
PartialList,
|
||||||
|
PartialListItem,
|
||||||
|
} from './parser';
|
||||||
|
export type { FoundSyntax, ParseError } from './parse_errors';
|
||||||
|
export type {
|
||||||
|
ConcreteError,
|
||||||
|
ConcreteErrorNode,
|
||||||
|
ConcreteInfo,
|
||||||
|
DelimiterToken,
|
||||||
|
Expr,
|
||||||
|
List,
|
||||||
|
ListItem,
|
||||||
|
Program,
|
||||||
|
} from './syntax';
|
||||||
|
export { Expr as LispExpr } from './syntax';
|
||||||
40
src/languages/lisp/parse_errors.ts
Normal file
40
src/languages/lisp/parse_errors.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { CodePoint, CodePointSpan } from 'source-region';
|
||||||
|
|
||||||
|
export type ParseError =
|
||||||
|
| {
|
||||||
|
tag: "expected-expression";
|
||||||
|
span: CodePointSpan;
|
||||||
|
found: FoundSyntax;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tag: "expected-close-delimiter";
|
||||||
|
span: CodePointSpan;
|
||||||
|
open: CodePointSpan;
|
||||||
|
expected: "paren" | "bracket";
|
||||||
|
found: FoundSyntax;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tag: "unexpected-close-delimiter";
|
||||||
|
span: CodePointSpan;
|
||||||
|
delimiter: "paren" | "bracket";
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tag: "expected-list-separator";
|
||||||
|
span: CodePointSpan;
|
||||||
|
found: FoundSyntax;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tag: "unexpected-code-point";
|
||||||
|
span: CodePointSpan;
|
||||||
|
found: FoundSyntax;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
tag: "invalid-number";
|
||||||
|
span: CodePointSpan;
|
||||||
|
text: string;
|
||||||
|
reason: "unsafe-integer" | "identifier-suffix";
|
||||||
|
};
|
||||||
|
|
||||||
|
export type FoundSyntax =
|
||||||
|
| { tag: "code-point"; value: CodePoint; span: CodePointSpan }
|
||||||
|
| { tag: "eof"; span: CodePointSpan };
|
||||||
402
src/languages/lisp/parser.ts
Normal file
402
src/languages/lisp/parser.ts
Normal file
|
|
@ -0,0 +1,402 @@
|
||||||
|
import {
|
||||||
|
SourceCursor,
|
||||||
|
char,
|
||||||
|
isAsciiAlpha,
|
||||||
|
isAsciiAlphanumeric,
|
||||||
|
isAsciiWhitespace,
|
||||||
|
isDigit,
|
||||||
|
} from 'source-region';
|
||||||
|
import type {
|
||||||
|
CodePoint,
|
||||||
|
CodePointIndex,
|
||||||
|
CodePointSpan,
|
||||||
|
SourceRegion,
|
||||||
|
} from 'source-region';
|
||||||
|
import type { FoundSyntax, ParseError } from './parse_errors';
|
||||||
|
import { consumeWhile, consumeWhile1, skipWhile } from '../../recognizers';
|
||||||
|
import {
|
||||||
|
ConcreteError,
|
||||||
|
DelimiterToken,
|
||||||
|
Expr,
|
||||||
|
ListItem,
|
||||||
|
Program,
|
||||||
|
} from './syntax';
|
||||||
|
import type {
|
||||||
|
ConcreteInfo,
|
||||||
|
ListItem as ListItemType,
|
||||||
|
List as ListType,
|
||||||
|
Expr as ExprType,
|
||||||
|
} from './syntax';
|
||||||
|
|
||||||
|
// Whitespace convention:
|
||||||
|
// - parseDocument consumes leading whitespace before each top-level expression.
|
||||||
|
// - parseExpr assumes leading whitespace has already been consumed.
|
||||||
|
// - Successful expression parsers stop immediately after the expression.
|
||||||
|
// - list parsers own whitespace between list items and before the closing delimiter.
|
||||||
|
//
|
||||||
|
// Recovery policy:
|
||||||
|
// - Unknown expressions consume at least one code point, then panic until a
|
||||||
|
// delimiter, whitespace, or plausible expression start.
|
||||||
|
// - Round lists do not require separators.
|
||||||
|
// - Square lists require commas between neighboring expressions, but allow
|
||||||
|
// optional leading and trailing commas.
|
||||||
|
//
|
||||||
|
// Span convention:
|
||||||
|
// - Parser internals and diagnostics use CodePointSpan.
|
||||||
|
// - Rendering can convert these later with SourceText.getSpan.
|
||||||
|
|
||||||
|
const OPEN_PAREN = char('(');
|
||||||
|
const CLOSE_PAREN = char(')');
|
||||||
|
const OPEN_BRACKET = char('[');
|
||||||
|
const CLOSE_BRACKET = char(']');
|
||||||
|
const COMMA = char(',');
|
||||||
|
const DASH = char('-');
|
||||||
|
const UNDERSCORE = char('_');
|
||||||
|
|
||||||
|
|
||||||
|
export type ConcreteSyntaxResult =
|
||||||
|
| { tag: "valid", value: ValidConcreteSyntax }
|
||||||
|
| { tag: "invalid", value: PartialConcreteSyntax }
|
||||||
|
|
||||||
|
export type ParseDocumentResult = {
|
||||||
|
syntax: ConcreteSyntaxResult;
|
||||||
|
errors: ParseError[];
|
||||||
|
};
|
||||||
|
|
||||||
|
// The main constraints are
|
||||||
|
// - `ValidConcreteSyntax` should be a subtype of `PartialConcreteSyntax`
|
||||||
|
// - if `PartialConcreteSyntax` doesn't contain any sort of error nodes, we should be able to coerce it to `ValidConcreteSyntax` without rebuilding the whole tree
|
||||||
|
export type ValidConcreteSyntax = Program<ConcreteInfo, never>
|
||||||
|
export type PartialConcreteSyntax = Program<ConcreteInfo, ConcreteError>
|
||||||
|
export type PartialExpr = ExprType<ConcreteInfo, ConcreteError>;
|
||||||
|
export type PartialList = ListType<ConcreteInfo, ConcreteError>;
|
||||||
|
export type PartialListItem = ListItemType<ConcreteInfo, ConcreteError>;
|
||||||
|
|
||||||
|
export namespace ConcreteSyntaxResult {
|
||||||
|
export function valid(value: ValidConcreteSyntax): ConcreteSyntaxResult {
|
||||||
|
return { tag: "valid", value };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalid(value: PartialConcreteSyntax): ConcreteSyntaxResult {
|
||||||
|
return { tag: "invalid", value };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function programOf(result: ConcreteSyntaxResult): PartialConcreteSyntax {
|
||||||
|
return result.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
export function parseDocument(region: SourceRegion): ParseDocumentResult {
|
||||||
|
return new Parser(region).parseDocument();
|
||||||
|
}
|
||||||
|
|
||||||
|
class Parser {
|
||||||
|
private readonly cursor: SourceCursor;
|
||||||
|
private readonly errors: ParseError[] = [];
|
||||||
|
|
||||||
|
constructor(private readonly region: SourceRegion) {
|
||||||
|
this.cursor = region.makeCursor();
|
||||||
|
}
|
||||||
|
|
||||||
|
parseDocument(): ParseDocumentResult {
|
||||||
|
const expressions: PartialExpr[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
this.skipWhitespace();
|
||||||
|
if (this.cursor.isAtEnd()) break;
|
||||||
|
|
||||||
|
expressions.push(this.parseExpr());
|
||||||
|
}
|
||||||
|
|
||||||
|
const program = Program.make(expressions, { span: this.region.codePointSpan });
|
||||||
|
return {
|
||||||
|
syntax: this.errors.length === 0
|
||||||
|
? ConcreteSyntaxResult.valid(program as ValidConcreteSyntax)
|
||||||
|
: ConcreteSyntaxResult.invalid(program as PartialConcreteSyntax),
|
||||||
|
errors: this.errors,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseExpr(): PartialExpr {
|
||||||
|
const cp = this.cursor.peek();
|
||||||
|
|
||||||
|
if (cp === undefined) {
|
||||||
|
return this.errorExpression(this.makeError({
|
||||||
|
tag: "expected-expression",
|
||||||
|
span: this.cursor.eofSpan(),
|
||||||
|
found: this.found(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp === CLOSE_PAREN || cp === CLOSE_BRACKET) {
|
||||||
|
const delimiter = cp === CLOSE_PAREN ? "paren" : "bracket";
|
||||||
|
const span = this.cursor.currentSpan();
|
||||||
|
this.cursor.advance();
|
||||||
|
return this.errorExpression(this.makeError({
|
||||||
|
tag: "unexpected-close-delimiter",
|
||||||
|
span,
|
||||||
|
delimiter,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp === OPEN_PAREN) return this.parseRoundList();
|
||||||
|
if (cp === OPEN_BRACKET) return this.parseSquareList();
|
||||||
|
if (isDigit(cp)) return this.parseNumber();
|
||||||
|
if (isIdentifierStart(cp)) return this.parseIdentifier();
|
||||||
|
|
||||||
|
return this.parseUnknownExpression();
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseRoundList(): PartialExpr {
|
||||||
|
const start = this.cursor.checkpoint();
|
||||||
|
const open = DelimiterToken.openParen(this.cursor.currentSpan());
|
||||||
|
this.cursor.advance();
|
||||||
|
|
||||||
|
const items: PartialListItem[] = [];
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
this.skipWhitespace();
|
||||||
|
|
||||||
|
const cp = this.cursor.peek();
|
||||||
|
if (cp === CLOSE_PAREN) {
|
||||||
|
const close = DelimiterToken.closeParen(this.cursor.currentSpan());
|
||||||
|
this.cursor.advance();
|
||||||
|
return Expr.list(open, items, this.cursor.spanFrom(start), close);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp === CLOSE_BRACKET) {
|
||||||
|
const close = DelimiterToken.closeBracket(this.cursor.currentSpan());
|
||||||
|
const error = this.makeError({
|
||||||
|
tag: "expected-close-delimiter",
|
||||||
|
span: this.cursor.currentSpan(),
|
||||||
|
open: open.span,
|
||||||
|
expected: "paren",
|
||||||
|
found: this.found(),
|
||||||
|
});
|
||||||
|
this.cursor.advance();
|
||||||
|
return Expr.list(open, items, this.cursor.spanFrom(start), close, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp === undefined) {
|
||||||
|
const error = this.makeError({
|
||||||
|
tag: "expected-close-delimiter",
|
||||||
|
span: this.cursor.eofSpan(),
|
||||||
|
open: open.span,
|
||||||
|
expected: "paren",
|
||||||
|
found: this.found(),
|
||||||
|
});
|
||||||
|
return Expr.list(open, items, this.cursor.spanFrom(start), undefined, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(this.parseExpr());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseSquareList(): PartialExpr {
|
||||||
|
const start = this.cursor.checkpoint();
|
||||||
|
const open = DelimiterToken.openBracket(this.cursor.currentSpan());
|
||||||
|
this.cursor.advance();
|
||||||
|
|
||||||
|
const items: PartialListItem[] = [];
|
||||||
|
let sawExpression = false;
|
||||||
|
let sawLeadingComma = false;
|
||||||
|
let needsSeparator = false;
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
this.skipWhitespace();
|
||||||
|
|
||||||
|
const cp = this.cursor.peek();
|
||||||
|
if (cp === CLOSE_BRACKET) {
|
||||||
|
const close = DelimiterToken.closeBracket(this.cursor.currentSpan());
|
||||||
|
this.cursor.advance();
|
||||||
|
return Expr.list(open, items, this.cursor.spanFrom(start), close);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp === CLOSE_PAREN) {
|
||||||
|
const close = DelimiterToken.closeParen(this.cursor.currentSpan());
|
||||||
|
const error = this.makeError({
|
||||||
|
tag: "expected-close-delimiter",
|
||||||
|
span: this.cursor.currentSpan(),
|
||||||
|
open: open.span,
|
||||||
|
expected: "bracket",
|
||||||
|
found: this.found(),
|
||||||
|
});
|
||||||
|
this.cursor.advance();
|
||||||
|
return Expr.list(open, items, this.cursor.spanFrom(start), close, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp === undefined) {
|
||||||
|
const error = this.makeError({
|
||||||
|
tag: "expected-close-delimiter",
|
||||||
|
span: this.cursor.eofSpan(),
|
||||||
|
open: open.span,
|
||||||
|
expected: "bracket",
|
||||||
|
found: this.found(),
|
||||||
|
});
|
||||||
|
return Expr.list(open, items, this.cursor.spanFrom(start), undefined, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (needsSeparator) {
|
||||||
|
if (cp === COMMA) {
|
||||||
|
this.cursor.advance();
|
||||||
|
needsSeparator = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const error = this.makeError({
|
||||||
|
tag: "expected-list-separator",
|
||||||
|
span: this.cursor.currentSpan(),
|
||||||
|
found: this.found(),
|
||||||
|
});
|
||||||
|
items.push(ListItem.errorSeparator(error, this.cursor.currentSpan()));
|
||||||
|
needsSeparator = false;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cp === COMMA) {
|
||||||
|
const commaSpan = this.cursor.currentSpan();
|
||||||
|
this.cursor.advance();
|
||||||
|
|
||||||
|
if (sawExpression) {
|
||||||
|
const error = this.makeError({
|
||||||
|
tag: "expected-expression",
|
||||||
|
span: commaSpan,
|
||||||
|
found: { tag: "code-point", value: COMMA, span: commaSpan },
|
||||||
|
});
|
||||||
|
items.push(this.errorExpression(error, commaSpan));
|
||||||
|
} else if (sawLeadingComma) {
|
||||||
|
const error = this.makeError({
|
||||||
|
tag: "expected-expression",
|
||||||
|
span: commaSpan,
|
||||||
|
found: { tag: "code-point", value: COMMA, span: commaSpan },
|
||||||
|
});
|
||||||
|
items.push(this.errorExpression(error, commaSpan));
|
||||||
|
} else {
|
||||||
|
sawLeadingComma = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
items.push(this.parseExpr());
|
||||||
|
sawExpression = true;
|
||||||
|
needsSeparator = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseNumber(): PartialExpr {
|
||||||
|
const start = this.cursor.checkpoint();
|
||||||
|
const match = consumeWhile1(this.cursor, isDigit);
|
||||||
|
if (match.tag === "none") {
|
||||||
|
throw new Error("parseNumber called when cursor is not at a number");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isIdentifierStart(this.cursor.peek() ?? -1)) {
|
||||||
|
consumeWhile(this.cursor, isIdentifierPart);
|
||||||
|
const span = this.cursor.spanFrom(start);
|
||||||
|
const text = this.cursor.slice(span);
|
||||||
|
return Expr.errorNumber(this.makeError({
|
||||||
|
tag: "invalid-number",
|
||||||
|
span,
|
||||||
|
text,
|
||||||
|
reason: "identifier-suffix",
|
||||||
|
}), span);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { span, text } = match;
|
||||||
|
const value = Number(text);
|
||||||
|
|
||||||
|
if (!Number.isSafeInteger(value)) {
|
||||||
|
return Expr.errorNumber(this.makeError({
|
||||||
|
tag: "invalid-number",
|
||||||
|
span,
|
||||||
|
text,
|
||||||
|
reason: "unsafe-integer",
|
||||||
|
}), span);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Expr.number(value, span);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseIdentifier(): PartialExpr {
|
||||||
|
const start = this.cursor.checkpoint();
|
||||||
|
this.cursor.advance();
|
||||||
|
|
||||||
|
consumeWhile(this.cursor, isIdentifierPart);
|
||||||
|
const span = this.cursor.spanFrom(start);
|
||||||
|
return Expr.identifier(this.cursor.slice(span), span);
|
||||||
|
}
|
||||||
|
|
||||||
|
private parseUnknownExpression(): PartialExpr {
|
||||||
|
const start = this.cursor.checkpoint();
|
||||||
|
const focus = this.cursor.currentSpan();
|
||||||
|
|
||||||
|
this.cursor.advance();
|
||||||
|
while (true) {
|
||||||
|
const cp = this.cursor.peek();
|
||||||
|
if (
|
||||||
|
cp === undefined
|
||||||
|
|| isAsciiWhitespace(cp)
|
||||||
|
|| isClosingDelimiter(cp)
|
||||||
|
|| isExpressionStart(cp)
|
||||||
|
) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
this.cursor.advance();
|
||||||
|
}
|
||||||
|
|
||||||
|
const panickedOver = this.cursor.spanFrom(start);
|
||||||
|
const error = this.makeError({
|
||||||
|
tag: "expected-expression",
|
||||||
|
span: focus,
|
||||||
|
found: { tag: "code-point", value: this.region.source.codePointAt(focus.start), span: focus },
|
||||||
|
}, panickedOver);
|
||||||
|
|
||||||
|
return this.errorExpression(error, panickedOver);
|
||||||
|
}
|
||||||
|
|
||||||
|
private skipWhitespace(): void {
|
||||||
|
skipWhile(this.cursor, isAsciiWhitespace);
|
||||||
|
}
|
||||||
|
|
||||||
|
private found(): FoundSyntax {
|
||||||
|
const cp = this.cursor.peek();
|
||||||
|
if (cp === undefined) return { tag: "eof", span: this.cursor.eofSpan() };
|
||||||
|
return { tag: "code-point", value: cp, span: this.cursor.currentSpan() };
|
||||||
|
}
|
||||||
|
|
||||||
|
private makeError(error: ParseError, panickedOver?: CodePointSpan): ConcreteError {
|
||||||
|
this.errors.push(error);
|
||||||
|
return ConcreteError.single({
|
||||||
|
span: error.span,
|
||||||
|
error,
|
||||||
|
panickedOver,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private errorExpression(error: ConcreteError, span?: CodePointSpan): PartialExpr {
|
||||||
|
return Expr.errorExpression(error, span ?? error[0].span);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isExpressionStart(cp: CodePoint | undefined): boolean {
|
||||||
|
return cp !== undefined && (
|
||||||
|
cp === OPEN_PAREN
|
||||||
|
|| cp === OPEN_BRACKET
|
||||||
|
|| isDigit(cp)
|
||||||
|
|| isIdentifierStart(cp)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isClosingDelimiter(cp: CodePoint): boolean {
|
||||||
|
return cp === CLOSE_PAREN || cp === CLOSE_BRACKET;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdentifierStart(cp: CodePoint): boolean {
|
||||||
|
return isAsciiAlpha(cp) || cp === DASH || cp === UNDERSCORE;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isIdentifierPart(cp: CodePoint): boolean {
|
||||||
|
return isAsciiAlphanumeric(cp) || cp === DASH || cp === UNDERSCORE;
|
||||||
|
}
|
||||||
152
src/languages/lisp/syntax.ts
Normal file
152
src/languages/lisp/syntax.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
||||||
|
import type { CodePointSpan } from 'source-region';
|
||||||
|
import type { ParseError } from './parse_errors';
|
||||||
|
|
||||||
|
export type ConcreteInfo = { span: CodePointSpan };
|
||||||
|
|
||||||
|
export type ConcreteError = ConcreteErrorNode[] // Convention: can't be empty.
|
||||||
|
export type ConcreteErrorNode = {
|
||||||
|
span: CodePointSpan,
|
||||||
|
error: ParseError,
|
||||||
|
panickedOver?: CodePointSpan,
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace ConcreteError {
|
||||||
|
export function single(node: ConcreteErrorNode): ConcreteError {
|
||||||
|
return [node];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DelimiterToken =
|
||||||
|
| { tag: "open-paren"; span: CodePointSpan }
|
||||||
|
| { tag: "close-paren"; span: CodePointSpan }
|
||||||
|
| { tag: "open-bracket"; span: CodePointSpan }
|
||||||
|
| { tag: "close-bracket"; span: CodePointSpan };
|
||||||
|
|
||||||
|
export namespace DelimiterToken {
|
||||||
|
export function openParen(span: CodePointSpan): DelimiterToken {
|
||||||
|
return { tag: "open-paren", span };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeParen(span: CodePointSpan): DelimiterToken {
|
||||||
|
return { tag: "close-paren", span };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function openBracket(span: CodePointSpan): DelimiterToken {
|
||||||
|
return { tag: "open-bracket", span };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function closeBracket(span: CodePointSpan): DelimiterToken {
|
||||||
|
return { tag: "close-bracket", span };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Program<Info, Error> = {
|
||||||
|
tag: "program",
|
||||||
|
expressions: Expr<Info, Error>[],
|
||||||
|
error?: Error,
|
||||||
|
} & Info
|
||||||
|
|
||||||
|
export type Expr<Info, Error> =
|
||||||
|
| Literal<Info, Error>
|
||||||
|
| List<Info, Error>
|
||||||
|
| { tag: "error-expression", error: Error } & Info // This is for errors that don't really correspond to any sort of node. Unknown errors.
|
||||||
|
|
||||||
|
export type List<Info, Error> =
|
||||||
|
{ tag: "list", open: DelimiterToken, items: ListItem<Info, Error>[], close?: DelimiterToken, error?: Error } & Info
|
||||||
|
|
||||||
|
export type ListItem<Info, Error> =
|
||||||
|
| Expr<Info, Error>
|
||||||
|
| { tag: "error-list-separator", error: Error } & Info
|
||||||
|
|
||||||
|
export type Literal<Info, Error> =
|
||||||
|
// === number ===
|
||||||
|
| { tag: "number", value: number } & Info
|
||||||
|
| { tag: "error-number", error: Error } & Info
|
||||||
|
// === identifier ===
|
||||||
|
| { tag: "identifier", value: Identifier } & Info
|
||||||
|
| { tag: "error-identifier", error: Error } & Info
|
||||||
|
|
||||||
|
export type Identifier = string
|
||||||
|
|
||||||
|
export namespace Program {
|
||||||
|
export function make<Info, Error>(
|
||||||
|
expressions: Expr<Info, Error>[],
|
||||||
|
info: Info,
|
||||||
|
error?: Error,
|
||||||
|
): Program<Info, Error> {
|
||||||
|
return error === undefined
|
||||||
|
? { tag: "program", expressions, ...info }
|
||||||
|
: { tag: "program", expressions, error, ...info };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function show<Info, Error>(program: Program<Info, Error>): string {
|
||||||
|
return program.expressions.map(Expr.show).join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace Expr {
|
||||||
|
export function number(value: number, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
|
||||||
|
return { tag: "number", value, span };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorNumber(error: ConcreteError, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
|
||||||
|
return { tag: "error-number", error, span };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function identifier(value: Identifier, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
|
||||||
|
return { tag: "identifier", value, span };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorIdentifier(error: ConcreteError, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
|
||||||
|
return { tag: "error-identifier", error, span };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function list(
|
||||||
|
open: DelimiterToken,
|
||||||
|
items: ListItem<ConcreteInfo, ConcreteError>[],
|
||||||
|
span: CodePointSpan,
|
||||||
|
close?: DelimiterToken,
|
||||||
|
error?: ConcreteError,
|
||||||
|
): Expr<ConcreteInfo, ConcreteError> {
|
||||||
|
return { tag: "list", open, items, close, error, span };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorExpression(error: ConcreteError, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
|
||||||
|
return { tag: "error-expression", error, span };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function show<Info, Error>(expr: Expr<Info, Error>): string {
|
||||||
|
switch (expr.tag) {
|
||||||
|
case "number":
|
||||||
|
return `${expr.value}`;
|
||||||
|
case "identifier":
|
||||||
|
return expr.value;
|
||||||
|
case "error-number":
|
||||||
|
return "<error-number>";
|
||||||
|
case "error-identifier":
|
||||||
|
return "<error-identifier>";
|
||||||
|
case "error-expression":
|
||||||
|
return "<error-expression>";
|
||||||
|
case "list":
|
||||||
|
return showList(expr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showList<Info, Error>(list: List<Info, Error>): string {
|
||||||
|
const open = list.open.tag === "open-bracket" ? "[" : "(";
|
||||||
|
const close = list.open.tag === "open-bracket" ? "]" : ")";
|
||||||
|
const sep = list.open.tag === "open-bracket" ? ", " : " ";
|
||||||
|
return `${open}${list.items.map(ListItem.show).join(sep)}${close}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export namespace ListItem {
|
||||||
|
export function errorSeparator(error: ConcreteError, span: CodePointSpan): ListItem<ConcreteInfo, ConcreteError> {
|
||||||
|
return { tag: "error-list-separator", error, span };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function show<Info, Error>(item: ListItem<Info, Error>): string {
|
||||||
|
if (item.tag === "error-list-separator") return "<error-separator>";
|
||||||
|
return Expr.show(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,40 +1 @@
|
||||||
import type { CodePoint, CodePointSpan } from 'source-region';
|
export * from './languages/lisp/parse_errors';
|
||||||
|
|
||||||
export type ParseError =
|
|
||||||
| {
|
|
||||||
tag: "expected-expression";
|
|
||||||
span: CodePointSpan;
|
|
||||||
found: FoundSyntax;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
tag: "expected-close-delimiter";
|
|
||||||
span: CodePointSpan;
|
|
||||||
open: CodePointSpan;
|
|
||||||
expected: "paren" | "bracket";
|
|
||||||
found: FoundSyntax;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
tag: "unexpected-close-delimiter";
|
|
||||||
span: CodePointSpan;
|
|
||||||
delimiter: "paren" | "bracket";
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
tag: "expected-list-separator";
|
|
||||||
span: CodePointSpan;
|
|
||||||
found: FoundSyntax;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
tag: "unexpected-code-point";
|
|
||||||
span: CodePointSpan;
|
|
||||||
found: FoundSyntax;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
tag: "invalid-number";
|
|
||||||
span: CodePointSpan;
|
|
||||||
text: string;
|
|
||||||
reason: "unsafe-integer" | "identifier-suffix";
|
|
||||||
};
|
|
||||||
|
|
||||||
export type FoundSyntax =
|
|
||||||
| { tag: "code-point"; value: CodePoint; span: CodePointSpan }
|
|
||||||
| { tag: "eof"; span: CodePointSpan };
|
|
||||||
|
|
|
||||||
|
|
@ -1,78 +1 @@
|
||||||
import { CodePointString, sourceText } from 'source-region';
|
import './languages/lisp/experiments';
|
||||||
import { parseDocument, programOf } from './parser';
|
|
||||||
import { matchCodePointString } from './recognizers';
|
|
||||||
import { Program } from './syntax';
|
|
||||||
|
|
||||||
// === Experiments ===
|
|
||||||
|
|
||||||
function experiment00_emptyDocument(): void {
|
|
||||||
logParse("empty document", "");
|
|
||||||
}
|
|
||||||
|
|
||||||
function experiment01_topLevelExpressions(): void {
|
|
||||||
logParse("top-level expressions", "foo 123 (bar baz_1 qux-2) [a, b, c]");
|
|
||||||
}
|
|
||||||
|
|
||||||
function experiment02_nestedLists(): void {
|
|
||||||
logParse("nested lists", "(define square (_ x) (* x x))");
|
|
||||||
}
|
|
||||||
|
|
||||||
function experiment03_unclosedList(): void {
|
|
||||||
logParse("unclosed list", "(foo 123\n (bar 456)");
|
|
||||||
}
|
|
||||||
|
|
||||||
function experiment04_recoverAtDocumentLevel(): void {
|
|
||||||
logParse("document recovery", "foo ) @@@ (bar 1) 99");
|
|
||||||
}
|
|
||||||
|
|
||||||
function experiment05_recoverInsideList(): void {
|
|
||||||
logParse("list recovery", "(foo @@@ 1 (bar # 2) baz)");
|
|
||||||
}
|
|
||||||
|
|
||||||
function experiment06_unicodeSpans(): void {
|
|
||||||
logParse("unicode spans", "alpha 💥 (beta 2)");
|
|
||||||
}
|
|
||||||
|
|
||||||
function experiment07_matchCodePointString(): void {
|
|
||||||
const region = sourceText("λx").fullRegion();
|
|
||||||
const cursor = region.makeCursor();
|
|
||||||
const lambda = CodePointString.makeFromString("λ");
|
|
||||||
console.log("==== recognizer:match code point string ====");
|
|
||||||
console.dir(matchCodePointString(cursor, lambda), { depth: null });
|
|
||||||
console.log("cursor", cursor.current());
|
|
||||||
}
|
|
||||||
|
|
||||||
function experiment08_squareListSeparator(): void {
|
|
||||||
logParse("square list separator", "[a, b c, d]");
|
|
||||||
}
|
|
||||||
|
|
||||||
function experiment09_invalidNumberFragment(): void {
|
|
||||||
logParse("invalid number fragment", "123fasd");
|
|
||||||
}
|
|
||||||
|
|
||||||
function experiment10_repeatedLeadingComma(): void {
|
|
||||||
logParse("repeated leading comma", "[, , foo, bar]");
|
|
||||||
}
|
|
||||||
|
|
||||||
function logParse(name: string, input: string): void {
|
|
||||||
const region = sourceText(input).fullRegion();
|
|
||||||
const result = parseDocument(region);
|
|
||||||
console.log(`==== parser:${name} ====`);
|
|
||||||
console.log(input);
|
|
||||||
console.log(result.syntax.tag, Program.show(programOf(result.syntax)));
|
|
||||||
console.dir(result.errors, { depth: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
[
|
|
||||||
experiment00_emptyDocument,
|
|
||||||
experiment01_topLevelExpressions,
|
|
||||||
experiment02_nestedLists,
|
|
||||||
experiment03_unclosedList,
|
|
||||||
experiment04_recoverAtDocumentLevel,
|
|
||||||
experiment05_recoverInsideList,
|
|
||||||
experiment06_unicodeSpans,
|
|
||||||
experiment07_matchCodePointString,
|
|
||||||
experiment08_squareListSeparator,
|
|
||||||
experiment09_invalidNumberFragment,
|
|
||||||
experiment10_repeatedLeadingComma,
|
|
||||||
].forEach((experiment) => experiment());
|
|
||||||
|
|
|
||||||
401
src/parser.ts
401
src/parser.ts
|
|
@ -1,400 +1 @@
|
||||||
import {
|
export * from './languages/lisp/parser';
|
||||||
SourceCursor,
|
|
||||||
char,
|
|
||||||
isAsciiAlpha,
|
|
||||||
isAsciiAlphanumeric,
|
|
||||||
isAsciiWhitespace,
|
|
||||||
isDigit,
|
|
||||||
} from 'source-region';
|
|
||||||
import type {
|
|
||||||
CodePoint,
|
|
||||||
CodePointIndex,
|
|
||||||
CodePointSpan,
|
|
||||||
SourceRegion,
|
|
||||||
} from 'source-region';
|
|
||||||
import type { FoundSyntax, ParseError } from './parse_errors';
|
|
||||||
import { consumeWhile, consumeWhile1, skipWhile } from './recognizers';
|
|
||||||
import {
|
|
||||||
ConcreteError,
|
|
||||||
DelimiterToken,
|
|
||||||
Expr,
|
|
||||||
ListItem,
|
|
||||||
Program,
|
|
||||||
} from './syntax';
|
|
||||||
import type {
|
|
||||||
ConcreteInfo,
|
|
||||||
ListItem as ListItemType,
|
|
||||||
Expr as ExprType,
|
|
||||||
} from './syntax';
|
|
||||||
|
|
||||||
// Whitespace convention:
|
|
||||||
// - parseDocument consumes leading whitespace before each top-level expression.
|
|
||||||
// - parseExpr assumes leading whitespace has already been consumed.
|
|
||||||
// - Successful expression parsers stop immediately after the expression.
|
|
||||||
// - list parsers own whitespace between list items and before the closing delimiter.
|
|
||||||
//
|
|
||||||
// Recovery policy:
|
|
||||||
// - Unknown expressions consume at least one code point, then panic until a
|
|
||||||
// delimiter, whitespace, or plausible expression start.
|
|
||||||
// - Round lists do not require separators.
|
|
||||||
// - Square lists require commas between neighboring expressions, but allow
|
|
||||||
// optional leading and trailing commas.
|
|
||||||
//
|
|
||||||
// Span convention:
|
|
||||||
// - Parser internals and diagnostics use CodePointSpan.
|
|
||||||
// - Rendering can convert these later with SourceText.getSpan.
|
|
||||||
|
|
||||||
const OPEN_PAREN = char('(');
|
|
||||||
const CLOSE_PAREN = char(')');
|
|
||||||
const OPEN_BRACKET = char('[');
|
|
||||||
const CLOSE_BRACKET = char(']');
|
|
||||||
const COMMA = char(',');
|
|
||||||
const DASH = char('-');
|
|
||||||
const UNDERSCORE = char('_');
|
|
||||||
|
|
||||||
|
|
||||||
export type ConcreteSyntaxResult =
|
|
||||||
| { tag: "valid", value: ValidConcreteSyntax }
|
|
||||||
| { tag: "invalid", value: PartialConcreteSyntax }
|
|
||||||
|
|
||||||
export type ParseDocumentResult = {
|
|
||||||
syntax: ConcreteSyntaxResult;
|
|
||||||
errors: ParseError[];
|
|
||||||
};
|
|
||||||
|
|
||||||
// The main constraints are
|
|
||||||
// - `ValidConcreteSyntax` should be a subtype of `PartialConcreteSyntax`
|
|
||||||
// - if `PartialConcreteSyntax` doesn't contain any sort of error nodes, we should be able to coerce it to `ValidConcreteSyntax` without rebuilding the whole tree
|
|
||||||
export type ValidConcreteSyntax = Program<ConcreteInfo, never>
|
|
||||||
export type PartialConcreteSyntax = Program<ConcreteInfo, ConcreteError>
|
|
||||||
type PartialExpr = ExprType<ConcreteInfo, ConcreteError>;
|
|
||||||
type PartialListItem = ListItemType<ConcreteInfo, ConcreteError>;
|
|
||||||
|
|
||||||
export namespace ConcreteSyntaxResult {
|
|
||||||
export function valid(value: ValidConcreteSyntax): ConcreteSyntaxResult {
|
|
||||||
return { tag: "valid", value };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function invalid(value: PartialConcreteSyntax): ConcreteSyntaxResult {
|
|
||||||
return { tag: "invalid", value };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function programOf(result: ConcreteSyntaxResult): PartialConcreteSyntax {
|
|
||||||
return result.value;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function parseDocument(region: SourceRegion): ParseDocumentResult {
|
|
||||||
return new Parser(region).parseDocument();
|
|
||||||
}
|
|
||||||
|
|
||||||
class Parser {
|
|
||||||
private readonly cursor: SourceCursor;
|
|
||||||
private readonly errors: ParseError[] = [];
|
|
||||||
|
|
||||||
constructor(private readonly region: SourceRegion) {
|
|
||||||
this.cursor = region.makeCursor();
|
|
||||||
}
|
|
||||||
|
|
||||||
parseDocument(): ParseDocumentResult {
|
|
||||||
const expressions: PartialExpr[] = [];
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
this.skipWhitespace();
|
|
||||||
if (this.cursor.isAtEnd()) break;
|
|
||||||
|
|
||||||
expressions.push(this.parseExpr());
|
|
||||||
}
|
|
||||||
|
|
||||||
const program = Program.make(expressions, { span: this.region.codePointSpan });
|
|
||||||
return {
|
|
||||||
syntax: this.errors.length === 0
|
|
||||||
? ConcreteSyntaxResult.valid(program as ValidConcreteSyntax)
|
|
||||||
: ConcreteSyntaxResult.invalid(program as PartialConcreteSyntax),
|
|
||||||
errors: this.errors,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseExpr(): PartialExpr {
|
|
||||||
const cp = this.cursor.peek();
|
|
||||||
|
|
||||||
if (cp === undefined) {
|
|
||||||
return this.errorExpression(this.makeError({
|
|
||||||
tag: "expected-expression",
|
|
||||||
span: this.cursor.eofSpan(),
|
|
||||||
found: this.found(),
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cp === CLOSE_PAREN || cp === CLOSE_BRACKET) {
|
|
||||||
const delimiter = cp === CLOSE_PAREN ? "paren" : "bracket";
|
|
||||||
const span = this.cursor.currentSpan();
|
|
||||||
this.cursor.advance();
|
|
||||||
return this.errorExpression(this.makeError({
|
|
||||||
tag: "unexpected-close-delimiter",
|
|
||||||
span,
|
|
||||||
delimiter,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cp === OPEN_PAREN) return this.parseRoundList();
|
|
||||||
if (cp === OPEN_BRACKET) return this.parseSquareList();
|
|
||||||
if (isDigit(cp)) return this.parseNumber();
|
|
||||||
if (isIdentifierStart(cp)) return this.parseIdentifier();
|
|
||||||
|
|
||||||
return this.parseUnknownExpression();
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseRoundList(): PartialExpr {
|
|
||||||
const start = this.cursor.checkpoint();
|
|
||||||
const open = DelimiterToken.openParen(this.cursor.currentSpan());
|
|
||||||
this.cursor.advance();
|
|
||||||
|
|
||||||
const items: PartialListItem[] = [];
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
this.skipWhitespace();
|
|
||||||
|
|
||||||
const cp = this.cursor.peek();
|
|
||||||
if (cp === CLOSE_PAREN) {
|
|
||||||
const close = DelimiterToken.closeParen(this.cursor.currentSpan());
|
|
||||||
this.cursor.advance();
|
|
||||||
return Expr.list(open, items, this.cursor.spanFrom(start), close);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cp === CLOSE_BRACKET) {
|
|
||||||
const close = DelimiterToken.closeBracket(this.cursor.currentSpan());
|
|
||||||
const error = this.makeError({
|
|
||||||
tag: "expected-close-delimiter",
|
|
||||||
span: this.cursor.currentSpan(),
|
|
||||||
open: open.span,
|
|
||||||
expected: "paren",
|
|
||||||
found: this.found(),
|
|
||||||
});
|
|
||||||
this.cursor.advance();
|
|
||||||
return Expr.list(open, items, this.cursor.spanFrom(start), close, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cp === undefined) {
|
|
||||||
const error = this.makeError({
|
|
||||||
tag: "expected-close-delimiter",
|
|
||||||
span: this.cursor.eofSpan(),
|
|
||||||
open: open.span,
|
|
||||||
expected: "paren",
|
|
||||||
found: this.found(),
|
|
||||||
});
|
|
||||||
return Expr.list(open, items, this.cursor.spanFrom(start), undefined, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push(this.parseExpr());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseSquareList(): PartialExpr {
|
|
||||||
const start = this.cursor.checkpoint();
|
|
||||||
const open = DelimiterToken.openBracket(this.cursor.currentSpan());
|
|
||||||
this.cursor.advance();
|
|
||||||
|
|
||||||
const items: PartialListItem[] = [];
|
|
||||||
let sawExpression = false;
|
|
||||||
let sawLeadingComma = false;
|
|
||||||
let needsSeparator = false;
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
this.skipWhitespace();
|
|
||||||
|
|
||||||
const cp = this.cursor.peek();
|
|
||||||
if (cp === CLOSE_BRACKET) {
|
|
||||||
const close = DelimiterToken.closeBracket(this.cursor.currentSpan());
|
|
||||||
this.cursor.advance();
|
|
||||||
return Expr.list(open, items, this.cursor.spanFrom(start), close);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cp === CLOSE_PAREN) {
|
|
||||||
const close = DelimiterToken.closeParen(this.cursor.currentSpan());
|
|
||||||
const error = this.makeError({
|
|
||||||
tag: "expected-close-delimiter",
|
|
||||||
span: this.cursor.currentSpan(),
|
|
||||||
open: open.span,
|
|
||||||
expected: "bracket",
|
|
||||||
found: this.found(),
|
|
||||||
});
|
|
||||||
this.cursor.advance();
|
|
||||||
return Expr.list(open, items, this.cursor.spanFrom(start), close, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cp === undefined) {
|
|
||||||
const error = this.makeError({
|
|
||||||
tag: "expected-close-delimiter",
|
|
||||||
span: this.cursor.eofSpan(),
|
|
||||||
open: open.span,
|
|
||||||
expected: "bracket",
|
|
||||||
found: this.found(),
|
|
||||||
});
|
|
||||||
return Expr.list(open, items, this.cursor.spanFrom(start), undefined, error);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsSeparator) {
|
|
||||||
if (cp === COMMA) {
|
|
||||||
this.cursor.advance();
|
|
||||||
needsSeparator = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const error = this.makeError({
|
|
||||||
tag: "expected-list-separator",
|
|
||||||
span: this.cursor.currentSpan(),
|
|
||||||
found: this.found(),
|
|
||||||
});
|
|
||||||
items.push(ListItem.errorSeparator(error, this.cursor.currentSpan()));
|
|
||||||
needsSeparator = false;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (cp === COMMA) {
|
|
||||||
const commaSpan = this.cursor.currentSpan();
|
|
||||||
this.cursor.advance();
|
|
||||||
|
|
||||||
if (sawExpression) {
|
|
||||||
const error = this.makeError({
|
|
||||||
tag: "expected-expression",
|
|
||||||
span: commaSpan,
|
|
||||||
found: { tag: "code-point", value: COMMA, span: commaSpan },
|
|
||||||
});
|
|
||||||
items.push(this.errorExpression(error, commaSpan));
|
|
||||||
} else if (sawLeadingComma) {
|
|
||||||
const error = this.makeError({
|
|
||||||
tag: "expected-expression",
|
|
||||||
span: commaSpan,
|
|
||||||
found: { tag: "code-point", value: COMMA, span: commaSpan },
|
|
||||||
});
|
|
||||||
items.push(this.errorExpression(error, commaSpan));
|
|
||||||
} else {
|
|
||||||
sawLeadingComma = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
items.push(this.parseExpr());
|
|
||||||
sawExpression = true;
|
|
||||||
needsSeparator = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseNumber(): PartialExpr {
|
|
||||||
const start = this.cursor.checkpoint();
|
|
||||||
const match = consumeWhile1(this.cursor, isDigit);
|
|
||||||
if (match.tag === "none") {
|
|
||||||
throw new Error("parseNumber called when cursor is not at a number");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isIdentifierStart(this.cursor.peek() ?? -1)) {
|
|
||||||
consumeWhile(this.cursor, isIdentifierPart);
|
|
||||||
const span = this.cursor.spanFrom(start);
|
|
||||||
const text = this.cursor.slice(span);
|
|
||||||
return Expr.errorNumber(this.makeError({
|
|
||||||
tag: "invalid-number",
|
|
||||||
span,
|
|
||||||
text,
|
|
||||||
reason: "identifier-suffix",
|
|
||||||
}), span);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { span, text } = match;
|
|
||||||
const value = Number(text);
|
|
||||||
|
|
||||||
if (!Number.isSafeInteger(value)) {
|
|
||||||
return Expr.errorNumber(this.makeError({
|
|
||||||
tag: "invalid-number",
|
|
||||||
span,
|
|
||||||
text,
|
|
||||||
reason: "unsafe-integer",
|
|
||||||
}), span);
|
|
||||||
}
|
|
||||||
|
|
||||||
return Expr.number(value, span);
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseIdentifier(): PartialExpr {
|
|
||||||
const start = this.cursor.checkpoint();
|
|
||||||
this.cursor.advance();
|
|
||||||
|
|
||||||
consumeWhile(this.cursor, isIdentifierPart);
|
|
||||||
const span = this.cursor.spanFrom(start);
|
|
||||||
return Expr.identifier(this.cursor.slice(span), span);
|
|
||||||
}
|
|
||||||
|
|
||||||
private parseUnknownExpression(): PartialExpr {
|
|
||||||
const start = this.cursor.checkpoint();
|
|
||||||
const focus = this.cursor.currentSpan();
|
|
||||||
|
|
||||||
this.cursor.advance();
|
|
||||||
while (true) {
|
|
||||||
const cp = this.cursor.peek();
|
|
||||||
if (
|
|
||||||
cp === undefined
|
|
||||||
|| isAsciiWhitespace(cp)
|
|
||||||
|| isClosingDelimiter(cp)
|
|
||||||
|| isExpressionStart(cp)
|
|
||||||
) {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
this.cursor.advance();
|
|
||||||
}
|
|
||||||
|
|
||||||
const panickedOver = this.cursor.spanFrom(start);
|
|
||||||
const error = this.makeError({
|
|
||||||
tag: "expected-expression",
|
|
||||||
span: focus,
|
|
||||||
found: { tag: "code-point", value: this.region.source.codePointAt(focus.start), span: focus },
|
|
||||||
}, panickedOver);
|
|
||||||
|
|
||||||
return this.errorExpression(error, panickedOver);
|
|
||||||
}
|
|
||||||
|
|
||||||
private skipWhitespace(): void {
|
|
||||||
skipWhile(this.cursor, isAsciiWhitespace);
|
|
||||||
}
|
|
||||||
|
|
||||||
private found(): FoundSyntax {
|
|
||||||
const cp = this.cursor.peek();
|
|
||||||
if (cp === undefined) return { tag: "eof", span: this.cursor.eofSpan() };
|
|
||||||
return { tag: "code-point", value: cp, span: this.cursor.currentSpan() };
|
|
||||||
}
|
|
||||||
|
|
||||||
private makeError(error: ParseError, panickedOver?: CodePointSpan): ConcreteError {
|
|
||||||
this.errors.push(error);
|
|
||||||
return ConcreteError.single({
|
|
||||||
span: error.span,
|
|
||||||
error,
|
|
||||||
panickedOver,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private errorExpression(error: ConcreteError, span?: CodePointSpan): PartialExpr {
|
|
||||||
return Expr.errorExpression(error, span ?? error[0].span);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExpressionStart(cp: CodePoint | undefined): boolean {
|
|
||||||
return cp !== undefined && (
|
|
||||||
cp === OPEN_PAREN
|
|
||||||
|| cp === OPEN_BRACKET
|
|
||||||
|| isDigit(cp)
|
|
||||||
|| isIdentifierStart(cp)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isClosingDelimiter(cp: CodePoint): boolean {
|
|
||||||
return cp === CLOSE_PAREN || cp === CLOSE_BRACKET;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIdentifierStart(cp: CodePoint): boolean {
|
|
||||||
return isAsciiAlpha(cp) || cp === DASH || cp === UNDERSCORE;
|
|
||||||
}
|
|
||||||
|
|
||||||
function isIdentifierPart(cp: CodePoint): boolean {
|
|
||||||
return isAsciiAlphanumeric(cp) || cp === DASH || cp === UNDERSCORE;
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,33 @@
|
||||||
|
.app-root {
|
||||||
|
height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
min-width: 1200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: var(--gap-2);
|
||||||
|
padding: var(--gap-2) var(--gap-4);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--panel);
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: var(--text-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.language-bar select {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--panel-raised);
|
||||||
|
padding: var(--gap-1) var(--gap-2);
|
||||||
|
}
|
||||||
|
|
||||||
.app-shell {
|
.app-shell {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--left-width) 0.45rem var(--middle-width) 0.45rem minmax(360px, 1fr);
|
grid-template-columns: var(--left-width) 0.45rem var(--middle-width) 0.45rem minmax(360px, 1fr);
|
||||||
gap: var(--gap-2);
|
gap: var(--gap-2);
|
||||||
height: 100vh;
|
min-height: 0;
|
||||||
padding: var(--gap-4);
|
padding: var(--gap-4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
153
src/syntax.ts
153
src/syntax.ts
|
|
@ -1,152 +1 @@
|
||||||
import type { CodePointSpan } from 'source-region';
|
export * from './languages/lisp/syntax';
|
||||||
import type { ParseError } from './parse_errors';
|
|
||||||
|
|
||||||
export type ConcreteInfo = { span: CodePointSpan };
|
|
||||||
|
|
||||||
export type ConcreteError = ConcreteErrorNode[] // Convention: can't be empty.
|
|
||||||
export type ConcreteErrorNode = {
|
|
||||||
span: CodePointSpan,
|
|
||||||
error: ParseError,
|
|
||||||
panickedOver?: CodePointSpan,
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace ConcreteError {
|
|
||||||
export function single(node: ConcreteErrorNode): ConcreteError {
|
|
||||||
return [node];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type DelimiterToken =
|
|
||||||
| { tag: "open-paren"; span: CodePointSpan }
|
|
||||||
| { tag: "close-paren"; span: CodePointSpan }
|
|
||||||
| { tag: "open-bracket"; span: CodePointSpan }
|
|
||||||
| { tag: "close-bracket"; span: CodePointSpan };
|
|
||||||
|
|
||||||
export namespace DelimiterToken {
|
|
||||||
export function openParen(span: CodePointSpan): DelimiterToken {
|
|
||||||
return { tag: "open-paren", span };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeParen(span: CodePointSpan): DelimiterToken {
|
|
||||||
return { tag: "close-paren", span };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function openBracket(span: CodePointSpan): DelimiterToken {
|
|
||||||
return { tag: "open-bracket", span };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function closeBracket(span: CodePointSpan): DelimiterToken {
|
|
||||||
return { tag: "close-bracket", span };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Program<Info, Error> = {
|
|
||||||
tag: "program",
|
|
||||||
expressions: Expr<Info, Error>[],
|
|
||||||
error?: Error,
|
|
||||||
} & Info
|
|
||||||
|
|
||||||
export type Expr<Info, Error> =
|
|
||||||
| Literal<Info, Error>
|
|
||||||
| List<Info, Error>
|
|
||||||
| { tag: "error-expression", error: Error } & Info // This is for errors that don't really correspond to any sort of node. Unknown errors.
|
|
||||||
|
|
||||||
export type List<Info, Error> =
|
|
||||||
{ tag: "list", open: DelimiterToken, items: ListItem<Info, Error>[], close?: DelimiterToken, error?: Error } & Info
|
|
||||||
|
|
||||||
export type ListItem<Info, Error> =
|
|
||||||
| Expr<Info, Error>
|
|
||||||
| { tag: "error-list-separator", error: Error } & Info
|
|
||||||
|
|
||||||
export type Literal<Info, Error> =
|
|
||||||
// === number ===
|
|
||||||
| { tag: "number", value: number } & Info
|
|
||||||
| { tag: "error-number", error: Error } & Info
|
|
||||||
// === identifier ===
|
|
||||||
| { tag: "identifier", value: Identifier } & Info
|
|
||||||
| { tag: "error-identifier", error: Error } & Info
|
|
||||||
|
|
||||||
export type Identifier = string
|
|
||||||
|
|
||||||
export namespace Program {
|
|
||||||
export function make<Info, Error>(
|
|
||||||
expressions: Expr<Info, Error>[],
|
|
||||||
info: Info,
|
|
||||||
error?: Error,
|
|
||||||
): Program<Info, Error> {
|
|
||||||
return error === undefined
|
|
||||||
? { tag: "program", expressions, ...info }
|
|
||||||
: { tag: "program", expressions, error, ...info };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function show<Info, Error>(program: Program<Info, Error>): string {
|
|
||||||
return program.expressions.map(Expr.show).join(" ");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace Expr {
|
|
||||||
export function number(value: number, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
|
|
||||||
return { tag: "number", value, span };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function errorNumber(error: ConcreteError, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
|
|
||||||
return { tag: "error-number", error, span };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function identifier(value: Identifier, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
|
|
||||||
return { tag: "identifier", value, span };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function errorIdentifier(error: ConcreteError, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
|
|
||||||
return { tag: "error-identifier", error, span };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function list(
|
|
||||||
open: DelimiterToken,
|
|
||||||
items: ListItem<ConcreteInfo, ConcreteError>[],
|
|
||||||
span: CodePointSpan,
|
|
||||||
close?: DelimiterToken,
|
|
||||||
error?: ConcreteError,
|
|
||||||
): Expr<ConcreteInfo, ConcreteError> {
|
|
||||||
return { tag: "list", open, items, close, error, span };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function errorExpression(error: ConcreteError, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
|
|
||||||
return { tag: "error-expression", error, span };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function show<Info, Error>(expr: Expr<Info, Error>): string {
|
|
||||||
switch (expr.tag) {
|
|
||||||
case "number":
|
|
||||||
return `${expr.value}`;
|
|
||||||
case "identifier":
|
|
||||||
return expr.value;
|
|
||||||
case "error-number":
|
|
||||||
return "<error-number>";
|
|
||||||
case "error-identifier":
|
|
||||||
return "<error-identifier>";
|
|
||||||
case "error-expression":
|
|
||||||
return "<error-expression>";
|
|
||||||
case "list":
|
|
||||||
return showList(expr);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function showList<Info, Error>(list: List<Info, Error>): string {
|
|
||||||
const open = list.open.tag === "open-bracket" ? "[" : "(";
|
|
||||||
const close = list.open.tag === "open-bracket" ? "]" : ")";
|
|
||||||
const sep = list.open.tag === "open-bracket" ? ", " : " ";
|
|
||||||
return `${open}${list.items.map(ListItem.show).join(sep)}${close}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export namespace ListItem {
|
|
||||||
export function errorSeparator(error: ConcreteError, span: CodePointSpan): ListItem<ConcreteInfo, ConcreteError> {
|
|
||||||
return { tag: "error-list-separator", error, span };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function show<Info, Error>(item: ListItem<Info, Error>): string {
|
|
||||||
if (item.tag === "error-list-separator") return "<error-separator>";
|
|
||||||
return Expr.show(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
132
src/ui/App.tsx
132
src/ui/App.tsx
|
|
@ -1,121 +1,29 @@
|
||||||
import { createMemo, createSignal } from 'solid-js';
|
import { createSignal, Switch, Match } from 'solid-js';
|
||||||
import { sourceText } from 'source-region';
|
import { LispApp } from './languages/lisp/LispApp';
|
||||||
import type { CodePointSpan, SourceRegion, SourceText } from 'source-region';
|
|
||||||
import { parseDocument, programOf } from '../parser';
|
|
||||||
import type { ConcreteSyntaxResult, PartialConcreteSyntax } from '../parser';
|
|
||||||
import type { ParseError } from '../parse_errors';
|
|
||||||
import { spanLabel } from './format';
|
|
||||||
import { PaneHeader, PaneSplitter } from './Pane';
|
|
||||||
import { SourceGrid } from './SourceGrid';
|
|
||||||
import type { SourceGridAnnotation } from './SourceGrid';
|
|
||||||
import { StructureTree } from './SyntaxPane';
|
|
||||||
import type { HoverTarget } from './types';
|
|
||||||
|
|
||||||
type ParsedDocument = {
|
type LanguageId = "lisp";
|
||||||
source: SourceText;
|
|
||||||
region: SourceRegion;
|
|
||||||
syntax: ConcreteSyntaxResult;
|
|
||||||
program: PartialConcreteSyntax;
|
|
||||||
errors: ParseError[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const SAMPLE_INPUT = `(define square (_ x) (mul x x))
|
|
||||||
|
|
||||||
[add, 1, 2]
|
|
||||||
|
|
||||||
(define pyth (_ x y) (+ (square x) (square y)))
|
|
||||||
|
|
||||||
foo ) @@@ (bar 1)
|
|
||||||
(nested [list, 123, abc_9, name-with-dash])
|
|
||||||
[a, b c, d]
|
|
||||||
123fasd`;
|
|
||||||
|
|
||||||
export function App() {
|
export function App() {
|
||||||
const [input, setInput] = createSignal(SAMPLE_INPUT);
|
const [language, setLanguage] = createSignal<LanguageId>("lisp");
|
||||||
const [hovered, setHovered] = createSignal<HoverTarget | undefined>();
|
|
||||||
const [leftWidth, setLeftWidth] = createSignal(420);
|
|
||||||
const [middleWidth, setMiddleWidth] = createSignal(420);
|
|
||||||
|
|
||||||
const parsed = createMemo<ParsedDocument>(() => {
|
|
||||||
const source = sourceText(input());
|
|
||||||
const region = source.fullRegion();
|
|
||||||
const result = parseDocument(region);
|
|
||||||
return { source, region, syntax: result.syntax, program: programOf(result.syntax), errors: result.errors };
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main
|
<div class="app-root">
|
||||||
class="app-shell"
|
<header class="language-bar">
|
||||||
style={{
|
<label for="language-select">Language</label>
|
||||||
"--left-width": `${leftWidth()}px`,
|
<select
|
||||||
"--middle-width": `${middleWidth()}px`,
|
id="language-select"
|
||||||
}}
|
value={language()}
|
||||||
|
onChange={(event) => setLanguage(event.currentTarget.value as LanguageId)}
|
||||||
>
|
>
|
||||||
<section class="pane input-pane">
|
<option value="lisp">Lisp</option>
|
||||||
<PaneHeader title="Source" detail={`${input().length} UTF-16 units`} />
|
</select>
|
||||||
<textarea
|
</header>
|
||||||
class="source-input"
|
|
||||||
spellcheck={false}
|
|
||||||
value={input()}
|
|
||||||
onInput={(event) => {
|
|
||||||
setInput(event.currentTarget.value);
|
|
||||||
setHovered(undefined);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<PaneSplitter
|
<Switch>
|
||||||
label="Resize source and structure panes"
|
<Match when={language() === "lisp"}>
|
||||||
onDrag={(delta) => {
|
<LispApp />
|
||||||
setLeftWidth((width) => clamp(width + delta, 280, 760));
|
</Match>
|
||||||
}}
|
</Switch>
|
||||||
/>
|
</div>
|
||||||
|
|
||||||
<section class="pane structure-pane">
|
|
||||||
<PaneHeader
|
|
||||||
title="Structure"
|
|
||||||
detail={`${parsed().syntax.tag}, ${parsed().program.expressions.length} expressions, ${parsed().errors.length} errors`}
|
|
||||||
/>
|
|
||||||
<StructureTree
|
|
||||||
program={parsed().program}
|
|
||||||
isValid={parsed().syntax.tag === "valid"}
|
|
||||||
errorCount={parsed().errors.length}
|
|
||||||
onHover={setHovered}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<PaneSplitter
|
|
||||||
label="Resize structure and source grid panes"
|
|
||||||
onDrag={(delta) => {
|
|
||||||
setMiddleWidth((width) => clamp(width + delta, 260, 760));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<section class="pane source-pane">
|
|
||||||
<PaneHeader
|
|
||||||
title="Source Grid"
|
|
||||||
detail={hovered() ? spanLabel(hovered()!.span) : "nothing hovered"}
|
|
||||||
/>
|
|
||||||
<SourceGrid
|
|
||||||
source={parsed().source}
|
|
||||||
region={parsed().region}
|
|
||||||
annotations={hovered() ? [hoverAnnotation(hovered()!)] : []}
|
|
||||||
/>
|
|
||||||
</section>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function hoverAnnotation(target: HoverTarget): SourceGridAnnotation {
|
|
||||||
return {
|
|
||||||
id: "hovered",
|
|
||||||
span: target.span,
|
|
||||||
label: target.label,
|
|
||||||
cellClass: "annotation-hovered",
|
|
||||||
markerClass: "annotation-hovered-marker",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value: number, min: number, max: number): number {
|
|
||||||
return Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
|
||||||
21
src/ui/HoverBlock.tsx
Normal file
21
src/ui/HoverBlock.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { JSX } from 'solid-js';
|
||||||
|
import type { CodePointSpan } from 'source-region';
|
||||||
|
import type { HoverTarget } from './types';
|
||||||
|
|
||||||
|
export function HoverBlock(props: {
|
||||||
|
class: string;
|
||||||
|
label: string;
|
||||||
|
span: CodePointSpan;
|
||||||
|
onHover: (target: HoverTarget | undefined) => void;
|
||||||
|
children: JSX.Element;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
class={props.class}
|
||||||
|
onMouseEnter={() => props.onHover({ label: props.label, span: props.span })}
|
||||||
|
onMouseLeave={() => props.onHover(undefined)}
|
||||||
|
>
|
||||||
|
{props.children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
21
src/ui/SpanChip.tsx
Normal file
21
src/ui/SpanChip.tsx
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
import type { CodePointSpan } from 'source-region';
|
||||||
|
import { spanLabel } from './format';
|
||||||
|
import type { HoverTarget } from './types';
|
||||||
|
|
||||||
|
export function SpanChip(props: {
|
||||||
|
label: string;
|
||||||
|
span: CodePointSpan;
|
||||||
|
onHover: (target: HoverTarget | undefined) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
class="span-chip"
|
||||||
|
type="button"
|
||||||
|
onMouseEnter={() => props.onHover({ label: props.label, span: props.span })}
|
||||||
|
onMouseLeave={() => props.onHover(undefined)}
|
||||||
|
>
|
||||||
|
<span>{props.label}</span>
|
||||||
|
<span>{spanLabel(props.span)}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
12
src/ui/annotations.ts
Normal file
12
src/ui/annotations.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
import type { SourceGridAnnotation } from './SourceGrid';
|
||||||
|
import type { HoverTarget } from './types';
|
||||||
|
|
||||||
|
export function hoverAnnotation(target: HoverTarget): SourceGridAnnotation {
|
||||||
|
return {
|
||||||
|
id: "hovered",
|
||||||
|
span: target.span,
|
||||||
|
label: target.label,
|
||||||
|
cellClass: "annotation-hovered",
|
||||||
|
markerClass: "annotation-hovered-marker",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -1,52 +1,4 @@
|
||||||
import type { CodePointSpan } from 'source-region';
|
import type { CodePointSpan } from 'source-region';
|
||||||
import type { FoundSyntax, ParseError } from '../parse_errors';
|
|
||||||
|
|
||||||
export function errorTitle(error: ParseError): string {
|
|
||||||
switch (error.tag) {
|
|
||||||
case "expected-expression":
|
|
||||||
return "Expected expression";
|
|
||||||
case "expected-close-delimiter":
|
|
||||||
return "Expected closing delimiter";
|
|
||||||
case "unexpected-close-delimiter":
|
|
||||||
return "Unexpected closing delimiter";
|
|
||||||
case "expected-list-separator":
|
|
||||||
return "Expected list separator";
|
|
||||||
case "unexpected-code-point":
|
|
||||||
return "Unexpected code point";
|
|
||||||
case "invalid-number":
|
|
||||||
return "Invalid number";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function errorLabel(error: ParseError): string {
|
|
||||||
return `${errorTitle(error)} ${spanLabel(error.span)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function errorDetail(error: ParseError): string {
|
|
||||||
switch (error.tag) {
|
|
||||||
case "expected-expression":
|
|
||||||
return `found ${foundLabel(error.found)}`;
|
|
||||||
case "expected-close-delimiter":
|
|
||||||
return `expected ${error.expected}, opened at ${spanLabel(error.open)}, found ${foundLabel(error.found)}`;
|
|
||||||
case "unexpected-close-delimiter":
|
|
||||||
return `${error.delimiter} ${spanLabel(error.span)}`;
|
|
||||||
case "expected-list-separator":
|
|
||||||
return `found ${foundLabel(error.found)}`;
|
|
||||||
case "unexpected-code-point":
|
|
||||||
return `found ${foundLabel(error.found)}`;
|
|
||||||
case "invalid-number":
|
|
||||||
return `${error.reason}: ${error.text}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function foundLabel(found: FoundSyntax): string {
|
|
||||||
switch (found.tag) {
|
|
||||||
case "eof":
|
|
||||||
return `EOF ${spanLabel(found.span)}`;
|
|
||||||
case "code-point":
|
|
||||||
return `${String.fromCodePoint(found.value)} ${spanLabel(found.span)}`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function spanLabel(span: CodePointSpan): string {
|
export function spanLabel(span: CodePointSpan): string {
|
||||||
return `[${span.start}, ${span.end})`;
|
return `[${span.start}, ${span.end})`;
|
||||||
|
|
|
||||||
114
src/ui/languages/lisp/LispApp.tsx
Normal file
114
src/ui/languages/lisp/LispApp.tsx
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
import { createMemo, createSignal } from 'solid-js';
|
||||||
|
import { sourceText } from 'source-region';
|
||||||
|
import type { CodePointSpan, SourceRegion, SourceText } from 'source-region';
|
||||||
|
import {
|
||||||
|
parseDocument,
|
||||||
|
programOf,
|
||||||
|
} from '../../../languages/lisp';
|
||||||
|
import type {
|
||||||
|
ConcreteSyntaxResult,
|
||||||
|
ParseError,
|
||||||
|
PartialConcreteSyntax,
|
||||||
|
} from '../../../languages/lisp';
|
||||||
|
import { spanLabel } from '../../format';
|
||||||
|
import { hoverAnnotation } from '../../annotations';
|
||||||
|
import { PaneHeader, PaneSplitter } from '../../Pane';
|
||||||
|
import { SourceGrid } from '../../SourceGrid';
|
||||||
|
import type { HoverTarget } from '../../types';
|
||||||
|
import { clamp } from '../../utils';
|
||||||
|
import { StructureTree } from './LispStructurePane';
|
||||||
|
|
||||||
|
type ParsedDocument = {
|
||||||
|
source: SourceText;
|
||||||
|
region: SourceRegion;
|
||||||
|
syntax: ConcreteSyntaxResult;
|
||||||
|
program: PartialConcreteSyntax;
|
||||||
|
errors: ParseError[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const SAMPLE_INPUT = `(define square (_ x) (mul x x))
|
||||||
|
|
||||||
|
[add, 1, 2]
|
||||||
|
|
||||||
|
(define pyth (_ x y) (+ (square x) (square y)))
|
||||||
|
|
||||||
|
foo ) @@@ (bar 1)
|
||||||
|
(nested [list, 123, abc_9, name-with-dash])
|
||||||
|
[a, b c, d]
|
||||||
|
123fasd`;
|
||||||
|
|
||||||
|
export function LispApp() {
|
||||||
|
const [input, setInput] = createSignal(SAMPLE_INPUT);
|
||||||
|
const [hovered, setHovered] = createSignal<HoverTarget | undefined>();
|
||||||
|
const [leftWidth, setLeftWidth] = createSignal(420);
|
||||||
|
const [middleWidth, setMiddleWidth] = createSignal(420);
|
||||||
|
|
||||||
|
const parsed = createMemo<ParsedDocument>(() => {
|
||||||
|
const source = sourceText(input());
|
||||||
|
const region = source.fullRegion();
|
||||||
|
const result = parseDocument(region);
|
||||||
|
return { source, region, syntax: result.syntax, program: programOf(result.syntax), errors: result.errors };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main
|
||||||
|
class="app-shell"
|
||||||
|
style={{
|
||||||
|
"--left-width": `${leftWidth()}px`,
|
||||||
|
"--middle-width": `${middleWidth()}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<section class="pane input-pane">
|
||||||
|
<PaneHeader title="Source" detail={`${input().length} UTF-16 units`} />
|
||||||
|
<textarea
|
||||||
|
class="source-input"
|
||||||
|
spellcheck={false}
|
||||||
|
value={input()}
|
||||||
|
onInput={(event) => {
|
||||||
|
setInput(event.currentTarget.value);
|
||||||
|
setHovered(undefined);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PaneSplitter
|
||||||
|
label="Resize source and structure panes"
|
||||||
|
onDrag={(delta) => {
|
||||||
|
setLeftWidth((width) => clamp(width + delta, 280, 760));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="pane structure-pane">
|
||||||
|
<PaneHeader
|
||||||
|
title="Structure"
|
||||||
|
detail={`${parsed().syntax.tag}, ${parsed().program.expressions.length} expressions, ${parsed().errors.length} errors`}
|
||||||
|
/>
|
||||||
|
<StructureTree
|
||||||
|
program={parsed().program}
|
||||||
|
isValid={parsed().syntax.tag === "valid"}
|
||||||
|
errorCount={parsed().errors.length}
|
||||||
|
onHover={setHovered}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<PaneSplitter
|
||||||
|
label="Resize structure and source grid panes"
|
||||||
|
onDrag={(delta) => {
|
||||||
|
setMiddleWidth((width) => clamp(width + delta, 260, 760));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<section class="pane source-pane">
|
||||||
|
<PaneHeader
|
||||||
|
title="Source Grid"
|
||||||
|
detail={hovered() ? spanLabel(hovered()!.span) : "nothing hovered"}
|
||||||
|
/>
|
||||||
|
<SourceGrid
|
||||||
|
source={parsed().source}
|
||||||
|
region={parsed().region}
|
||||||
|
annotations={hovered() ? [hoverAnnotation(hovered()!)] : []}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,22 +1,18 @@
|
||||||
import { For, Show } from 'solid-js';
|
import { For, Show } from 'solid-js';
|
||||||
import type { JSX } from 'solid-js';
|
|
||||||
import type { CodePointSpan } from 'source-region';
|
|
||||||
import type {
|
import type {
|
||||||
ConcreteError,
|
ConcreteError,
|
||||||
ConcreteErrorNode,
|
ConcreteErrorNode,
|
||||||
ConcreteInfo,
|
PartialConcreteSyntax,
|
||||||
List,
|
PartialExpr,
|
||||||
ListItem,
|
PartialList,
|
||||||
Expr as SyntaxExpr,
|
PartialListItem,
|
||||||
} from '../syntax';
|
} from '../../../languages/lisp';
|
||||||
import type { PartialConcreteSyntax } from '../parser';
|
import { LispExpr } from '../../../languages/lisp';
|
||||||
import { Expr } from '../syntax';
|
import { spanLabel } from '../../format';
|
||||||
import { errorDetail, errorTitle, spanLabel } from './format';
|
import { HoverBlock } from '../../HoverBlock';
|
||||||
import type { HoverTarget } from './types';
|
import { SpanChip } from '../../SpanChip';
|
||||||
|
import type { HoverTarget } from '../../types';
|
||||||
type PartialExpr = SyntaxExpr<ConcreteInfo, ConcreteError>;
|
import { errorDetail, errorTitle } from './format';
|
||||||
type PartialList = List<ConcreteInfo, ConcreteError>;
|
|
||||||
type PartialListItem = ListItem<ConcreteInfo, ConcreteError>;
|
|
||||||
|
|
||||||
export function StructureTree(props: {
|
export function StructureTree(props: {
|
||||||
program: PartialConcreteSyntax;
|
program: PartialConcreteSyntax;
|
||||||
|
|
@ -77,7 +73,7 @@ function ExprView(props: {
|
||||||
>
|
>
|
||||||
<div class="node-header">
|
<div class="node-header">
|
||||||
<span class="node-kind">{props.expr.tag}</span>
|
<span class="node-kind">{props.expr.tag}</span>
|
||||||
<span class="node-value">{Expr.show(props.expr)}</span>
|
<span class="node-value">{LispExpr.show(props.expr)}</span>
|
||||||
</div>
|
</div>
|
||||||
</HoverBlock>
|
</HoverBlock>
|
||||||
);
|
);
|
||||||
|
|
@ -217,42 +213,6 @@ function ConcreteErrorNodeView(props: {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SpanChip(props: {
|
|
||||||
label: string;
|
|
||||||
span: CodePointSpan;
|
|
||||||
onHover: (target: HoverTarget | undefined) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
class="span-chip"
|
|
||||||
type="button"
|
|
||||||
onMouseEnter={() => props.onHover({ label: props.label, span: props.span })}
|
|
||||||
onMouseLeave={() => props.onHover(undefined)}
|
|
||||||
>
|
|
||||||
<span>{props.label}</span>
|
|
||||||
<span>{spanLabel(props.span)}</span>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function HoverBlock(props: {
|
|
||||||
class: string;
|
|
||||||
label: string;
|
|
||||||
span: CodePointSpan;
|
|
||||||
onHover: (target: HoverTarget | undefined) => void;
|
|
||||||
children: JSX.Element;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
class={props.class}
|
|
||||||
onMouseEnter={() => props.onHover({ label: props.label, span: props.span })}
|
|
||||||
onMouseLeave={() => props.onHover(undefined)}
|
|
||||||
>
|
|
||||||
{props.children}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function listLabel(tag: string): string {
|
function listLabel(tag: string): string {
|
||||||
return tag === "open-bracket" ? "square-list" : "round-list";
|
return tag === "open-bracket" ? "square-list" : "round-list";
|
||||||
}
|
}
|
||||||
45
src/ui/languages/lisp/format.ts
Normal file
45
src/ui/languages/lisp/format.ts
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
import type { FoundSyntax, ParseError } from '../../../languages/lisp';
|
||||||
|
import { spanLabel } from '../../format';
|
||||||
|
|
||||||
|
export function errorTitle(error: ParseError): string {
|
||||||
|
switch (error.tag) {
|
||||||
|
case "expected-expression":
|
||||||
|
return "Expected expression";
|
||||||
|
case "expected-close-delimiter":
|
||||||
|
return "Expected closing delimiter";
|
||||||
|
case "unexpected-close-delimiter":
|
||||||
|
return "Unexpected closing delimiter";
|
||||||
|
case "expected-list-separator":
|
||||||
|
return "Expected list separator";
|
||||||
|
case "unexpected-code-point":
|
||||||
|
return "Unexpected code point";
|
||||||
|
case "invalid-number":
|
||||||
|
return "Invalid number";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function errorDetail(error: ParseError): string {
|
||||||
|
switch (error.tag) {
|
||||||
|
case "expected-expression":
|
||||||
|
return `found ${foundLabel(error.found)}`;
|
||||||
|
case "expected-close-delimiter":
|
||||||
|
return `expected ${error.expected}, opened at ${spanLabel(error.open)}, found ${foundLabel(error.found)}`;
|
||||||
|
case "unexpected-close-delimiter":
|
||||||
|
return `${error.delimiter} ${spanLabel(error.span)}`;
|
||||||
|
case "expected-list-separator":
|
||||||
|
return `found ${foundLabel(error.found)}`;
|
||||||
|
case "unexpected-code-point":
|
||||||
|
return `found ${foundLabel(error.found)}`;
|
||||||
|
case "invalid-number":
|
||||||
|
return `${error.reason}: ${error.text}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function foundLabel(found: FoundSyntax): string {
|
||||||
|
switch (found.tag) {
|
||||||
|
case "eof":
|
||||||
|
return `EOF ${spanLabel(found.span)}`;
|
||||||
|
case "code-point":
|
||||||
|
return `${String.fromCodePoint(found.value)} ${spanLabel(found.span)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/ui/utils.ts
Normal file
3
src/ui/utils.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(max, value));
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue