Develop a partial syntax parser.

This commit is contained in:
Yura Dupyn 2026-04-25 15:21:44 +02:00
parent 84cfc5863e
commit b2e96b9a22
9 changed files with 721 additions and 257 deletions

View file

@ -1,5 +1,4 @@
export { parseDocument } from './parser'; export { parseDocument } from './parser';
export type { ParseDocumentResult } from './parser'; export type { ParseDocumentResult } from './parser';
export type { FoundSyntax, ParseError } from './parse_errors'; export type { FoundSyntax, ParseError } from './parse_errors';
export { ConcreteSyntax, Expr } from './syntax'; export * from './syntax';
export type { ConcreteSyntax as ConcreteSyntaxNode, Expr as ExprNode } from './syntax';

View file

@ -7,14 +7,21 @@ export type ParseError =
found: FoundSyntax; found: FoundSyntax;
} }
| { | {
tag: "expected-close-paren"; tag: "expected-close-delimiter";
span: CodePointSpan; span: CodePointSpan;
openParen: CodePointSpan; open: CodePointSpan;
expected: "paren" | "bracket";
found: FoundSyntax; found: FoundSyntax;
} }
| { | {
tag: "unexpected-close-paren"; tag: "unexpected-close-delimiter";
span: CodePointSpan; span: CodePointSpan;
delimiter: "paren" | "bracket";
}
| {
tag: "expected-list-separator";
span: CodePointSpan;
found: FoundSyntax;
} }
| { | {
tag: "unexpected-code-point"; tag: "unexpected-code-point";
@ -25,7 +32,7 @@ export type ParseError =
tag: "invalid-number"; tag: "invalid-number";
span: CodePointSpan; span: CodePointSpan;
text: string; text: string;
reason: "unsafe-integer"; reason: "unsafe-integer" | "identifier-suffix";
}; };
export type FoundSyntax = export type FoundSyntax =

View file

@ -1,7 +1,7 @@
import { CodePointString, sourceText } from 'source-region'; import { CodePointString, sourceText } from 'source-region';
import { parseDocument } from './parser'; import { parseDocument } from './parser';
import { matchCodePointString } from './recognizers'; import { matchCodePointString } from './recognizers';
import { Expr } from './syntax'; import { Program, programOf } from './syntax';
// === Experiments === // === Experiments ===
@ -10,7 +10,7 @@ function experiment00_emptyDocument(): void {
} }
function experiment01_topLevelExpressions(): void { function experiment01_topLevelExpressions(): void {
logParse("top-level expressions", "foo 123 (bar baz_1 qux-2)"); logParse("top-level expressions", "foo 123 (bar baz_1 qux-2) [a, b, c]");
} }
function experiment02_nestedLists(): void { function experiment02_nestedLists(): void {
@ -42,12 +42,24 @@ function experiment07_matchCodePointString(): void {
console.log("cursor", cursor.current()); 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 { function logParse(name: string, input: string): void {
const region = sourceText(input).fullRegion(); const region = sourceText(input).fullRegion();
const result = parseDocument(region); const result = parseDocument(region);
console.log(`==== parser:${name} ====`); console.log(`==== parser:${name} ====`);
console.log(input); console.log(input);
console.log(result.values.map(Expr.show)); console.log(result.syntax.tag, Program.show(programOf(result.syntax)));
console.dir(result.errors, { depth: null }); console.dir(result.errors, { depth: null });
} }
@ -60,4 +72,7 @@ function logParse(name: string, input: string): void {
experiment05_recoverInsideList, experiment05_recoverInsideList,
experiment06_unicodeSpans, experiment06_unicodeSpans,
experiment07_matchCodePointString, experiment07_matchCodePointString,
experiment08_squareListSeparator,
experiment09_invalidNumberFragment,
experiment10_repeatedLeadingComma,
].forEach((experiment) => experiment()); ].forEach((experiment) => experiment());

View file

@ -14,20 +14,34 @@ import type {
} from 'source-region'; } from 'source-region';
import type { FoundSyntax, ParseError } from './parse_errors'; import type { FoundSyntax, ParseError } from './parse_errors';
import { consumeWhile, consumeWhile1, skipWhile } from './recognizers'; import { consumeWhile, consumeWhile1, skipWhile } from './recognizers';
import { ConcreteSyntax } from './syntax'; import {
ConcreteError,
ConcreteSyntaxResult,
DelimiterToken,
Expr,
ListItem,
Program,
} from './syntax';
import type {
ConcreteInfo,
ListItem as ListItemType,
PartialConcreteSyntax,
ValidConcreteSyntax,
Expr as ExprType,
} from './syntax';
// Whitespace convention: // Whitespace convention:
// - parseDocument consumes leading whitespace before each top-level expression. // - parseDocument consumes leading whitespace before each top-level expression.
// - parseExpr assumes leading whitespace has already been consumed. // - parseExpr assumes leading whitespace has already been consumed.
// - Successful expression parsers stop immediately after the expression. // - Successful expression parsers stop immediately after the expression.
// - parseList owns whitespace between list elements and before the closing paren. // - list parsers own whitespace between list items and before the closing delimiter.
// //
// Recovery policy: // Recovery policy:
// - At document level, invalid input is skipped until EOF or a plausible expression // - Unknown expressions consume at least one code point, then panic until a
// start. Unexpected ")" is reported and consumed immediately. // delimiter, whitespace, or plausible expression start.
// - Inside lists, invalid input is skipped until EOF, ")", or a plausible // - Round lists do not require separators.
// expression start. Recovery always consumes at least one code point when it // - Square lists require commas between neighboring expressions, but allow
// cannot stop at a synchronization point. // optional leading and trailing commas.
// //
// Span convention: // Span convention:
// - Parser internals and diagnostics use CodePointSpan. // - Parser internals and diagnostics use CodePointSpan.
@ -35,14 +49,20 @@ import { ConcreteSyntax } from './syntax';
const OPEN_PAREN = char('('); const OPEN_PAREN = char('(');
const CLOSE_PAREN = char(')'); const CLOSE_PAREN = char(')');
const OPEN_BRACKET = char('[');
const CLOSE_BRACKET = char(']');
const COMMA = char(',');
const DASH = char('-'); const DASH = char('-');
const UNDERSCORE = char('_'); const UNDERSCORE = char('_');
export type ParseDocumentResult = { export type ParseDocumentResult = {
values: ConcreteSyntax[]; syntax: ConcreteSyntaxResult;
errors: ParseError[]; errors: ParseError[];
}; };
type PartialExpr = ExprType<ConcreteInfo, ConcreteError>;
type PartialListItem = ListItemType<ConcreteInfo, ConcreteError>;
export function parseDocument(region: SourceRegion): ParseDocumentResult { export function parseDocument(region: SourceRegion): ParseDocumentResult {
return new Parser(region).parseDocument(); return new Parser(region).parseDocument();
} }
@ -56,152 +76,260 @@ class Parser {
} }
parseDocument(): ParseDocumentResult { parseDocument(): ParseDocumentResult {
const values: ConcreteSyntax[] = []; const expressions: PartialExpr[] = [];
while (true) { while (true) {
this.skipWhitespace(); this.skipWhitespace();
if (this.cursor.isAtEnd()) break; if (this.cursor.isAtEnd()) break;
const before = this.cursor.checkpoint(); expressions.push(this.parseExpr());
const value = this.parseExpr();
if (value) {
values.push(value);
continue;
}
this.recoverDocument(before);
} }
return { values, errors: this.errors }; 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(): ConcreteSyntax | undefined { private parseExpr(): PartialExpr {
const cp = this.cursor.peek(); const cp = this.cursor.peek();
if (cp === undefined) { if (cp === undefined) {
this.errors.push({ return this.errorExpression(this.makeError({
tag: "expected-expression", tag: "expected-expression",
span: this.cursor.eofSpan(), span: this.cursor.eofSpan(),
found: this.found(), found: this.found(),
}); }));
return undefined;
} }
if (cp === CLOSE_PAREN) { if (cp === CLOSE_PAREN || cp === CLOSE_BRACKET) {
this.errors.push({ const delimiter = cp === CLOSE_PAREN ? "paren" : "bracket";
tag: "unexpected-close-paren", const span = this.cursor.currentSpan();
span: this.cursor.currentSpan(), this.cursor.advance();
}); return this.errorExpression(this.makeError({
return undefined; tag: "unexpected-close-delimiter",
span,
delimiter,
}));
} }
if (cp === OPEN_PAREN) return this.parseList(); if (cp === OPEN_PAREN) return this.parseRoundList();
if (cp === OPEN_BRACKET) return this.parseSquareList();
if (isDigit(cp)) return this.parseNumber(); if (isDigit(cp)) return this.parseNumber();
if (isIdentifierStart(cp)) return this.parseIdentifier(); if (isIdentifierStart(cp)) return this.parseIdentifier();
this.errors.push({ return this.parseUnknownExpression();
tag: "expected-expression",
span: this.cursor.currentSpan(),
found: this.found(),
});
return undefined;
} }
private parseList(): ConcreteSyntax | undefined { private parseRoundList(): PartialExpr {
const start = this.cursor.checkpoint(); const start = this.cursor.checkpoint();
const openParen = this.cursor.currentSpan(); const open = DelimiterToken.openParen(this.cursor.currentSpan());
this.cursor.advance(); this.cursor.advance();
const values: ConcreteSyntax[] = []; const items: PartialListItem[] = [];
// === Body Parsing ===
while (true) { while (true) {
this.skipWhitespace(); this.skipWhitespace();
const cp = this.cursor.peek(); const cp = this.cursor.peek();
if (cp === CLOSE_PAREN) { if (cp === CLOSE_PAREN) {
const close = DelimiterToken.closeParen(this.cursor.currentSpan());
this.cursor.advance(); this.cursor.advance();
return ConcreteSyntax.list(values, this.cursor.spanFrom(start)); 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) { if (cp === undefined) {
this.errors.push({ const error = this.makeError({
tag: "expected-close-paren", tag: "expected-close-delimiter",
span: this.cursor.eofSpan(), span: this.cursor.eofSpan(),
openParen, open: open.span,
expected: "paren",
found: this.found(), found: this.found(),
}); });
return ConcreteSyntax.list(values, this.cursor.spanFrom(start)); return Expr.list(open, items, this.cursor.spanFrom(start), undefined, error);
} }
const before = this.cursor.checkpoint(); items.push(this.parseExpr());
const value = this.parseExpr();
if (value) {
values.push(value);
continue;
}
this.recoverList(before);
} }
} }
private parseNumber(): ConcreteSyntax { 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); const match = consumeWhile1(this.cursor, isDigit);
if (match.tag === "none") { if (match.tag === "none") {
throw new Error("parseNumber called when cursor is not at a number"); 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 { span, text } = match;
const value = Number(text); const value = Number(text);
if (!Number.isSafeInteger(value)) { if (!Number.isSafeInteger(value)) {
this.errors.push({ return Expr.errorNumber(this.makeError({
tag: "invalid-number", tag: "invalid-number",
span, span,
text, text,
reason: "unsafe-integer", reason: "unsafe-integer",
}); }), span);
} }
return ConcreteSyntax.number(value, span); return Expr.number(value, span);
} }
private parseIdentifier(): ConcreteSyntax { private parseIdentifier(): PartialExpr {
const start = this.cursor.checkpoint(); const start = this.cursor.checkpoint();
this.cursor.advance(); this.cursor.advance();
consumeWhile(this.cursor, isIdentifierPart); consumeWhile(this.cursor, isIdentifierPart);
const span = this.cursor.spanFrom(start); const span = this.cursor.spanFrom(start);
return ConcreteSyntax.identifier(this.cursor.slice(span), span); return Expr.identifier(this.cursor.slice(span), span);
} }
private recoverDocument(failedAt: CodePointIndex): void { private parseUnknownExpression(): PartialExpr {
if (this.cursor.current() === failedAt) this.cursor.advance(); const start = this.cursor.checkpoint();
const focus = this.cursor.currentSpan();
while (!this.cursor.isAtEnd()) { this.cursor.advance();
while (true) {
const cp = this.cursor.peek(); const cp = this.cursor.peek();
if (cp === CLOSE_PAREN) { if (
this.errors.push({ cp === undefined
tag: "unexpected-close-paren", || isAsciiWhitespace(cp)
span: this.cursor.currentSpan(), || isClosingDelimiter(cp)
}); || isExpressionStart(cp)
this.cursor.advance(); ) {
return; break;
} }
if (isExpressionStart(cp)) return;
this.cursor.advance(); this.cursor.advance();
} }
}
private recoverList(failedAt: CodePointIndex): void { const panickedOver = this.cursor.spanFrom(start);
if (this.cursor.current() === failedAt) this.cursor.advance(); const error = this.makeError({
tag: "expected-expression",
span: focus,
found: { tag: "code-point", value: this.region.source.codePointAt(focus.start), span: focus },
}, panickedOver);
while (!this.cursor.isAtEnd()) { return this.errorExpression(error, panickedOver);
const cp = this.cursor.peek();
if (cp === CLOSE_PAREN || isExpressionStart(cp)) return;
this.cursor.advance();
}
} }
private skipWhitespace(): void { private skipWhitespace(): void {
@ -213,10 +341,32 @@ class Parser {
if (cp === undefined) return { tag: "eof", span: this.cursor.eofSpan() }; if (cp === undefined) return { tag: "eof", span: this.cursor.eofSpan() };
return { tag: "code-point", value: cp, span: this.cursor.currentSpan() }; 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 { function isExpressionStart(cp: CodePoint | undefined): boolean {
return cp !== undefined && (cp === OPEN_PAREN || isDigit(cp) || isIdentifierStart(cp)); 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 { function isIdentifierStart(cp: CodePoint): boolean {

View file

@ -1,40 +1,36 @@
.section-label { .structure-tree {
margin: var(--gap-2) 0; min-height: 0;
text-transform: uppercase; overflow: auto;
padding: var(--gap-3);
} }
.error-list, .syntax-node {
.expr-list,
.list-children {
display: flex;
flex-direction: column;
gap: var(--gap-2);
}
.error-card,
.expr-node {
border: 1px solid var(--border); border: 1px solid var(--border);
background: var(--panel-raised); background: var(--panel-raised);
}
.error-card {
padding: var(--gap-3);
border-color: oklch(42% 0.05 28);
background: var(--error-bg);
}
.expr-node {
padding: var(--gap-2); padding: var(--gap-2);
} }
.error-card:hover, .syntax-node:hover {
.expr-node:hover {
border-color: var(--border-strong); border-color: var(--border-strong);
background: var(--accent-bg); background: var(--accent-bg);
} }
.syntax-error-node {
border-color: oklch(42% 0.05 28);
background: var(--error-bg);
}
.node-header,
.list-node-header {
display: flex;
align-items: baseline;
gap: var(--gap-2);
min-width: 0;
}
.item-title, .item-title,
.node-kind { .node-kind,
.error-title {
color: var(--text); color: var(--text);
font-size: var(--text-sm); font-size: var(--text-sm);
font-weight: 650; font-weight: 650;
@ -42,7 +38,7 @@
.node-kind { .node-kind {
display: inline-flex; display: inline-flex;
min-width: 4.5rem; min-width: 7rem;
color: var(--accent); color: var(--accent);
} }
@ -52,15 +48,65 @@
font-size: var(--text-sm); font-size: var(--text-sm);
} }
.list-node-header { .item-meta {
display: flex; color: var(--text-muted);
align-items: baseline; font-size: var(--text-xs);
justify-content: space-between;
gap: var(--gap-2);
} }
.list-children { .list-children,
.concrete-error-list {
display: flex;
flex-direction: column;
gap: var(--gap-2);
margin-top: var(--gap-2); margin-top: var(--gap-2);
padding-left: var(--gap-3); padding-left: var(--gap-3);
border-left: 1px solid var(--border); border-left: 1px solid var(--border);
} }
.delimiter-row,
.span-chip-row {
display: flex;
flex-wrap: wrap;
gap: var(--gap-2);
margin-top: var(--gap-2);
}
.concrete-error {
padding: var(--gap-2);
border: 1px solid oklch(42% 0.05 28);
background: oklch(21% 0.035 28);
}
.span-chip {
display: inline-flex;
align-items: center;
gap: var(--gap-2);
border: 1px solid var(--border);
padding: var(--gap-1) var(--gap-2);
color: var(--text-muted);
background: var(--panel);
font-family: var(--font-mono);
font-size: var(--text-xs);
cursor: default;
}
.span-chip:hover {
color: var(--text);
border-color: var(--accent);
background: var(--accent-bg);
}
.status-dot {
width: 0.65rem;
height: 0.65rem;
flex: 0 0 auto;
border-radius: 50%;
}
.status-valid {
background: oklch(72% 0.12 150);
}
.status-invalid {
background: var(--error);
}

View file

@ -1,59 +1,171 @@
import type { CodePointSpan } from 'source-region'; import type { CodePointSpan } from 'source-region';
import type { ParseError } from './parse_errors';
export type ConcreteSyntax = Expr<{ span: CodePointSpan }> export type ConcreteInfo = { span: CodePointSpan };
export type Expr<A> = export type ConcreteSyntaxResult =
| { tag: "literal", value: Literal } & A | { tag: "valid", value: ValidConcreteSyntax }
| { tag: "list", values: Expr<A>[] } & A | { tag: "invalid", value: PartialConcreteSyntax }
export namespace ConcreteSyntax { export type ValidConcreteSyntax = Program<ConcreteInfo, never>
export function number(value: number, span: CodePointSpan): ConcreteSyntax { export type PartialConcreteSyntax = Program<ConcreteInfo, ConcreteError>
return { tag: "literal", value: { tag: "number", value }, span };
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 identifier(value: Identifier, span: CodePointSpan): ConcreteSyntax { export function closeParen(span: CodePointSpan): DelimiterToken {
return { tag: "literal", value: { tag: "identifier", value }, span }; return { tag: "close-paren", span };
} }
export function list(values: ConcreteSyntax[], span: CodePointSpan): ConcreteSyntax { export function openBracket(span: CodePointSpan): DelimiterToken {
return { tag: "list", values, span }; 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
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> =
| { tag: "number", value: number } & Info
| { tag: "error-number", error: Error } & Info
| { tag: "identifier", value: Identifier } & Info
| { tag: "error-identifier", error: Error } & Info
export type Identifier = string
export namespace ConcreteSyntaxResult {
export function valid(value: ValidConcreteSyntax): ConcreteSyntaxResult {
return { tag: "valid", value };
}
export function invalid(value: PartialConcreteSyntax): ConcreteSyntaxResult {
return { tag: "invalid", value };
}
}
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 namespace Expr {
export function number(value: number): Expr<void> { export function number(value: number, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
return { tag: "literal", value: { tag: "number", value } }; return { tag: "number", value, span };
} }
export function identifier(value: Identifier): Expr<void> { export function errorNumber(error: ConcreteError, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
return { tag: "literal", value: { tag: "identifier", value } }; return { tag: "error-number", error, span };
} }
export function list(values: Expr<void>[]): Expr<void> { export function identifier(value: Identifier, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
return { tag: "list", values }; return { tag: "identifier", value, span };
} }
export function show<A>(e: Expr<A>): string { export function errorIdentifier(error: ConcreteError, span: CodePointSpan): Expr<ConcreteInfo, ConcreteError> {
switch (e.tag) { return { tag: "error-identifier", error, span };
case "literal":
return showLiteral(e.value);
case "list":
return `(${e.values.map(show).join(" ")})`;
}
} }
function showLiteral(e: Literal): string { export function list(
switch (e.tag) { 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": case "number":
return `${e.value}`; return `${expr.value}`;
case "identifier": case "identifier":
return `${e.value}`; 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}`;
}
} }
type Literal = export namespace ListItem {
| { tag: "number", value: number } export function errorSeparator(error: ConcreteError, span: CodePointSpan): ListItem<ConcreteInfo, ConcreteError> {
| { tag: "identifier", value: Identifier } return { tag: "error-list-separator", error, span };
}
type Identifier = string export function show<Info, Error>(item: ListItem<Info, Error>): string {
if (item.tag === "error-list-separator") return "<error-separator>";
return Expr.show(item);
}
}
export function programOf(result: ConcreteSyntaxResult): PartialConcreteSyntax {
return result.value;
}

View file

@ -1,31 +1,35 @@
import { createMemo, createSignal, Show } from 'solid-js'; import { createMemo, createSignal } from 'solid-js';
import { sourceText } from 'source-region'; import { sourceText } from 'source-region';
import type { CodePointSpan, SourceRegion, SourceText } from 'source-region'; import type { CodePointSpan, SourceRegion, SourceText } from 'source-region';
import { parseDocument } from '../parser'; import { parseDocument } from '../parser';
import type { ParseError } from '../parse_errors'; import type { ParseError } from '../parse_errors';
import type { ConcreteSyntax } from '../syntax'; import { programOf } from '../syntax';
import type { ConcreteSyntaxResult, PartialConcreteSyntax } from '../syntax';
import { spanLabel } from './format'; import { spanLabel } from './format';
import { PaneHeader, PaneSplitter } from './Pane'; import { PaneHeader, PaneSplitter } from './Pane';
import { SourceGrid } from './SourceGrid'; import { SourceGrid } from './SourceGrid';
import type { SourceGridAnnotation } from './SourceGrid'; import type { SourceGridAnnotation } from './SourceGrid';
import { ErrorList, ExpressionList } from './SyntaxPane'; import { StructureTree } from './SyntaxPane';
import type { HoverTarget } from './types'; import type { HoverTarget } from './types';
type ParsedDocument = { type ParsedDocument = {
source: SourceText; source: SourceText;
region: SourceRegion; region: SourceRegion;
values: ConcreteSyntax[]; syntax: ConcreteSyntaxResult;
program: PartialConcreteSyntax;
errors: ParseError[]; errors: ParseError[];
}; };
const SAMPLE_INPUT = `(define square (_ x) (mul x x)) const SAMPLE_INPUT = `(define square (_ x) (mul x x))
(add 1 2) [add, 1, 2]
(define pyth (_ x y) (+ (square x) (square y))) (define pyth (_ x y) (+ (square x) (square y)))
foo ) @@@ (bar 1) foo ) @@@ (bar 1)
(nested (list 123 abc_9 name-with-dash))`; (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 [input, setInput] = createSignal(SAMPLE_INPUT);
@ -37,7 +41,7 @@ export function App() {
const source = sourceText(input()); const source = sourceText(input());
const region = source.fullRegion(); const region = source.fullRegion();
const result = parseDocument(region); const result = parseDocument(region);
return { source, region, values: result.values, errors: result.errors }; return { source, region, syntax: result.syntax, program: programOf(result.syntax), errors: result.errors };
}); });
return ( return (
@ -71,23 +75,14 @@ export function App() {
<section class="pane structure-pane"> <section class="pane structure-pane">
<PaneHeader <PaneHeader
title="Structure" title="Structure"
detail={`${parsed().values.length} expressions, ${parsed().errors.length} errors`} 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}
/> />
<Show
when={parsed().errors.length > 0}
fallback={
<ExpressionList
values={parsed().values}
onHover={setHovered}
/>
}
>
<ErrorList
errors={parsed().errors}
values={parsed().values}
onHover={setHovered}
/>
</Show>
</section> </section>
<PaneSplitter <PaneSplitter

View file

@ -1,55 +1,195 @@
import { For, Show } from 'solid-js'; import { For, Show } from 'solid-js';
import type { JSX } from 'solid-js'; import type { JSX } from 'solid-js';
import type { CodePointSpan } from 'source-region'; import type { CodePointSpan } from 'source-region';
import type { ParseError } from '../parse_errors'; import type {
import type { ConcreteSyntax } from '../syntax'; ConcreteError,
ConcreteErrorNode,
ConcreteInfo,
List,
ListItem,
PartialConcreteSyntax,
Expr as SyntaxExpr,
} from '../syntax';
import { Expr } from '../syntax'; import { Expr } from '../syntax';
import { errorDetail, errorLabel, errorTitle } from './format'; import { errorDetail, errorTitle, spanLabel } from './format';
import type { HoverTarget } from './types'; import type { HoverTarget } from './types';
export function ErrorList(props: { type PartialExpr = SyntaxExpr<ConcreteInfo, ConcreteError>;
errors: ParseError[]; type PartialList = List<ConcreteInfo, ConcreteError>;
values: ConcreteSyntax[]; type PartialListItem = ListItem<ConcreteInfo, ConcreteError>;
export function StructureTree(props: {
program: PartialConcreteSyntax;
isValid: boolean;
errorCount: number;
onHover: (target: HoverTarget | undefined) => void; onHover: (target: HoverTarget | undefined) => void;
}) { }) {
return ( return (
<div class="scroll-stack"> <div class="structure-tree">
<div class="section-label">Errors</div> <HoverBlock
<div class="error-list"> class="syntax-node program-node"
<For each={props.errors}> label="program"
{(error) => ( span={props.program.span}
<HoverBlock onHover={props.onHover}
class="error-card" >
label={errorLabel(error)} <div class="node-header">
span={error.span} <span class={props.isValid ? "status-dot status-valid" : "status-dot status-invalid"} />
onHover={props.onHover} <span class="node-kind">program</span>
> <span class="item-meta">
<div class="item-title">{errorTitle(error)}</div> {props.isValid ? "valid" : "invalid"} · {props.program.expressions.length} expressions · {props.errorCount} errors
<div class="item-meta">{errorDetail(error)}</div> </span>
</HoverBlock> </div>
)}
</For>
</div>
<Show when={props.values.length > 0}> <Show when={props.program.error}>
<div class="section-label">Recovered Expressions</div> {(error) => <ConcreteErrorView error={error()} label="program error" onHover={props.onHover} />}
<ExpressionList values={props.values} onHover={props.onHover} /> </Show>
</Show>
<div class="list-children">
<For each={props.program.expressions}>
{(expr, index) => (
<ExprView
expr={expr}
label={`expression ${index() + 1}`}
onHover={props.onHover}
/>
)}
</For>
</div>
</HoverBlock>
</div> </div>
); );
} }
export function ExpressionList(props: { function ExprView(props: {
values: ConcreteSyntax[]; expr: PartialExpr;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
switch (props.expr.tag) {
case "number":
case "identifier":
return (
<HoverBlock
class="syntax-node literal-node"
label={`${props.label}: ${props.expr.tag}`}
span={props.expr.span}
onHover={props.onHover}
>
<div class="node-header">
<span class="node-kind">{props.expr.tag}</span>
<span class="node-value">{Expr.show(props.expr)}</span>
</div>
</HoverBlock>
);
case "error-number":
case "error-identifier":
case "error-expression":
return (
<HoverBlock
class="syntax-node syntax-error-node"
label={`${props.label}: ${props.expr.tag}`}
span={props.expr.span}
onHover={props.onHover}
>
<div class="node-header">
<span class="status-dot status-invalid" />
<span class="node-kind">{props.expr.tag}</span>
<span class="item-meta">{spanLabel(props.expr.span)}</span>
</div>
<ConcreteErrorView error={props.expr.error} label={props.expr.tag} onHover={props.onHover} />
</HoverBlock>
);
case "list":
return <ListView list={props.expr} label={props.label} onHover={props.onHover} />;
}
}
function ListView(props: {
list: PartialList;
label: string;
onHover: (target: HoverTarget | undefined) => void; onHover: (target: HoverTarget | undefined) => void;
}) { }) {
return ( return (
<div class="expr-list"> <HoverBlock
<For each={props.values}> class={props.list.error ? "syntax-node list-node syntax-error-node" : "syntax-node list-node"}
{(value, index) => ( label={`${props.label}: list`}
<ExprView span={props.list.span}
expr={value} onHover={props.onHover}
label={`expression ${index() + 1}`} >
<div class="node-header">
<Show when={props.list.error}>
<span class="status-dot status-invalid" />
</Show>
<span class="node-kind">{listLabel(props.list.open.tag)}</span>
<span class="item-meta">{props.list.items.length} items · {delimiterLabel(props.list)}</span>
</div>
<div class="delimiter-row">
<SpanChip label={props.list.open.tag} span={props.list.open.span} onHover={props.onHover} />
<Show when={props.list.close}>
{(close) => <SpanChip label={close().tag} span={close().span} onHover={props.onHover} />}
</Show>
</div>
<Show when={props.list.error}>
{(error) => <ConcreteErrorView error={error()} label="list error" onHover={props.onHover} />}
</Show>
<div class="list-children">
<For each={props.list.items}>
{(item, index) => (
<ListItemView
item={item}
label={`${props.label}.${index() + 1}`}
onHover={props.onHover}
/>
)}
</For>
</div>
</HoverBlock>
);
}
function ListItemView(props: {
item: PartialListItem;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
if (props.item.tag === "error-list-separator") {
return (
<HoverBlock
class="syntax-node syntax-error-node"
label={`${props.label}: separator`}
span={props.item.span}
onHover={props.onHover}
>
<div class="node-header">
<span class="status-dot status-invalid" />
<span class="node-kind">error-list-separator</span>
<span class="item-meta">{spanLabel(props.item.span)}</span>
</div>
<ConcreteErrorView error={props.item.error} label="separator error" onHover={props.onHover} />
</HoverBlock>
);
}
return <ExprView expr={props.item} label={props.label} onHover={props.onHover} />;
}
function ConcreteErrorView(props: {
error: ConcreteError;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
return (
<div class="concrete-error-list">
<For each={props.error}>
{(node, index) => (
<ConcreteErrorNodeView
node={node}
label={`${props.label} ${index() + 1}`}
onHover={props.onHover} onHover={props.onHover}
/> />
)} )}
@ -58,48 +198,40 @@ export function ExpressionList(props: {
); );
} }
function ExprView(props: { function ConcreteErrorNodeView(props: {
expr: ConcreteSyntax; node: ConcreteErrorNode;
label: string; label: string;
onHover: (target: HoverTarget | undefined) => void; onHover: (target: HoverTarget | undefined) => void;
}) { }) {
if (props.expr.tag === "literal") {
return (
<HoverBlock
class="expr-node literal-node"
label={`${props.label}: ${props.expr.value.tag}`}
span={props.expr.span}
onHover={props.onHover}
>
<span class="node-kind">{props.expr.value.tag}</span>
<span class="node-value">{literalValue(props.expr)}</span>
</HoverBlock>
);
}
return ( return (
<HoverBlock <div class="concrete-error">
class="expr-node list-node" <div class="error-title">{errorTitle(props.node.error)}</div>
label={`${props.label}: list`} <div class="item-meta">{errorDetail(props.node.error)}</div>
span={props.expr.span} <div class="span-chip-row">
onHover={props.onHover} <SpanChip label="focus" span={props.node.span} onHover={props.onHover} />
<Show when={props.node.panickedOver}>
{(span) => <SpanChip label="panicked over" span={span()} onHover={props.onHover} />}
</Show>
</div>
</div>
);
}
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)}
> >
<div class="list-node-header"> <span>{props.label}</span>
<span class="node-kind">list</span> <span>{spanLabel(props.span)}</span>
<span class="item-meta">{props.expr.values.length} children</span> </button>
</div>
<div class="list-children">
<For each={props.expr.values}>
{(child, index) => (
<ExprView
expr={child}
label={`${props.label}.${index() + 1}`}
onHover={props.onHover}
/>
)}
</For>
</div>
</HoverBlock>
); );
} }
@ -121,6 +253,10 @@ function HoverBlock(props: {
); );
} }
function literalValue(expr: ConcreteSyntax): string { function listLabel(tag: string): string {
return expr.tag === "literal" ? Expr.show(expr) : ""; return tag === "open-bracket" ? "square-list" : "round-list";
}
function delimiterLabel(list: PartialList): string {
return list.close ? `${list.open.tag} / ${list.close.tag}` : `${list.open.tag} / missing close`;
} }

View file

@ -5,10 +5,12 @@ export function errorTitle(error: ParseError): string {
switch (error.tag) { switch (error.tag) {
case "expected-expression": case "expected-expression":
return "Expected expression"; return "Expected expression";
case "expected-close-paren": case "expected-close-delimiter":
return "Expected closing paren"; return "Expected closing delimiter";
case "unexpected-close-paren": case "unexpected-close-delimiter":
return "Unexpected closing paren"; return "Unexpected closing delimiter";
case "expected-list-separator":
return "Expected list separator";
case "unexpected-code-point": case "unexpected-code-point":
return "Unexpected code point"; return "Unexpected code point";
case "invalid-number": case "invalid-number":
@ -24,10 +26,12 @@ export function errorDetail(error: ParseError): string {
switch (error.tag) { switch (error.tag) {
case "expected-expression": case "expected-expression":
return `found ${foundLabel(error.found)}`; return `found ${foundLabel(error.found)}`;
case "expected-close-paren": case "expected-close-delimiter":
return `opened at ${spanLabel(error.openParen)}, found ${foundLabel(error.found)}`; return `expected ${error.expected}, opened at ${spanLabel(error.open)}, found ${foundLabel(error.found)}`;
case "unexpected-close-paren": case "unexpected-close-delimiter":
return spanLabel(error.span); return `${error.delimiter} ${spanLabel(error.span)}`;
case "expected-list-separator":
return `found ${foundLabel(error.found)}`;
case "unexpected-code-point": case "unexpected-code-point":
return `found ${foundLabel(error.found)}`; return `found ${foundLabel(error.found)}`;
case "invalid-number": case "invalid-number":