diff --git a/src/main.ts b/src/main.ts index 111bc29..626c31b 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,5 +1,4 @@ export { parseDocument } from './parser'; export type { ParseDocumentResult } from './parser'; export type { FoundSyntax, ParseError } from './parse_errors'; -export { ConcreteSyntax, Expr } from './syntax'; -export type { ConcreteSyntax as ConcreteSyntaxNode, Expr as ExprNode } from './syntax'; +export * from './syntax'; diff --git a/src/parse_errors.ts b/src/parse_errors.ts index 5599ffb..d53f775 100644 --- a/src/parse_errors.ts +++ b/src/parse_errors.ts @@ -7,14 +7,21 @@ export type ParseError = found: FoundSyntax; } | { - tag: "expected-close-paren"; + tag: "expected-close-delimiter"; span: CodePointSpan; - openParen: CodePointSpan; + open: CodePointSpan; + expected: "paren" | "bracket"; found: FoundSyntax; } | { - tag: "unexpected-close-paren"; + tag: "unexpected-close-delimiter"; span: CodePointSpan; + delimiter: "paren" | "bracket"; + } +| { + tag: "expected-list-separator"; + span: CodePointSpan; + found: FoundSyntax; } | { tag: "unexpected-code-point"; @@ -25,7 +32,7 @@ export type ParseError = tag: "invalid-number"; span: CodePointSpan; text: string; - reason: "unsafe-integer"; + reason: "unsafe-integer" | "identifier-suffix"; }; export type FoundSyntax = diff --git a/src/parser.experiments.ts b/src/parser.experiments.ts index 9c5469f..6d3922e 100644 --- a/src/parser.experiments.ts +++ b/src/parser.experiments.ts @@ -1,7 +1,7 @@ import { CodePointString, sourceText } from 'source-region'; import { parseDocument } from './parser'; import { matchCodePointString } from './recognizers'; -import { Expr } from './syntax'; +import { Program, programOf } from './syntax'; // === Experiments === @@ -10,7 +10,7 @@ function experiment00_emptyDocument(): 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 { @@ -42,12 +42,24 @@ function experiment07_matchCodePointString(): void { 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.values.map(Expr.show)); + console.log(result.syntax.tag, Program.show(programOf(result.syntax))); console.dir(result.errors, { depth: null }); } @@ -60,4 +72,7 @@ function logParse(name: string, input: string): void { experiment05_recoverInsideList, experiment06_unicodeSpans, experiment07_matchCodePointString, + experiment08_squareListSeparator, + experiment09_invalidNumberFragment, + experiment10_repeatedLeadingComma, ].forEach((experiment) => experiment()); diff --git a/src/parser.ts b/src/parser.ts index da158f9..bb0ada3 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -14,20 +14,34 @@ import type { } from 'source-region'; import type { FoundSyntax, ParseError } from './parse_errors'; 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: // - 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. -// - 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: -// - At document level, invalid input is skipped until EOF or a plausible expression -// start. Unexpected ")" is reported and consumed immediately. -// - Inside lists, invalid input is skipped until EOF, ")", or a plausible -// expression start. Recovery always consumes at least one code point when it -// cannot stop at a synchronization point. +// - 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. @@ -35,14 +49,20 @@ import { ConcreteSyntax } from './syntax'; 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 ParseDocumentResult = { - values: ConcreteSyntax[]; + syntax: ConcreteSyntaxResult; errors: ParseError[]; }; +type PartialExpr = ExprType; +type PartialListItem = ListItemType; + export function parseDocument(region: SourceRegion): ParseDocumentResult { return new Parser(region).parseDocument(); } @@ -56,152 +76,260 @@ class Parser { } parseDocument(): ParseDocumentResult { - const values: ConcreteSyntax[] = []; + const expressions: PartialExpr[] = []; while (true) { this.skipWhitespace(); if (this.cursor.isAtEnd()) break; - const before = this.cursor.checkpoint(); - const value = this.parseExpr(); - if (value) { - values.push(value); - continue; - } - - this.recoverDocument(before); + expressions.push(this.parseExpr()); } - 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(); if (cp === undefined) { - this.errors.push({ + return this.errorExpression(this.makeError({ tag: "expected-expression", span: this.cursor.eofSpan(), found: this.found(), - }); - return undefined; + })); } - if (cp === CLOSE_PAREN) { - this.errors.push({ - tag: "unexpected-close-paren", - span: this.cursor.currentSpan(), - }); - return undefined; + 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.parseList(); + 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(); - this.errors.push({ - tag: "expected-expression", - span: this.cursor.currentSpan(), - found: this.found(), - }); - return undefined; + return this.parseUnknownExpression(); } - private parseList(): ConcreteSyntax | undefined { + private parseRoundList(): PartialExpr { const start = this.cursor.checkpoint(); - const openParen = this.cursor.currentSpan(); + const open = DelimiterToken.openParen(this.cursor.currentSpan()); this.cursor.advance(); - const values: ConcreteSyntax[] = []; + const items: PartialListItem[] = []; - // === Body Parsing === while (true) { this.skipWhitespace(); const cp = this.cursor.peek(); if (cp === CLOSE_PAREN) { + const close = DelimiterToken.closeParen(this.cursor.currentSpan()); 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) { - this.errors.push({ - tag: "expected-close-paren", + const error = this.makeError({ + tag: "expected-close-delimiter", span: this.cursor.eofSpan(), - openParen, + open: open.span, + expected: "paren", 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(); - const value = this.parseExpr(); - if (value) { - values.push(value); - continue; - } - - this.recoverList(before); + items.push(this.parseExpr()); } } - 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); 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)) { - this.errors.push({ + return Expr.errorNumber(this.makeError({ tag: "invalid-number", span, text, 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(); this.cursor.advance(); consumeWhile(this.cursor, isIdentifierPart); 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 { - if (this.cursor.current() === failedAt) this.cursor.advance(); + private parseUnknownExpression(): PartialExpr { + const start = this.cursor.checkpoint(); + const focus = this.cursor.currentSpan(); - while (!this.cursor.isAtEnd()) { + this.cursor.advance(); + while (true) { const cp = this.cursor.peek(); - if (cp === CLOSE_PAREN) { - this.errors.push({ - tag: "unexpected-close-paren", - span: this.cursor.currentSpan(), - }); - this.cursor.advance(); - return; + if ( + cp === undefined + || isAsciiWhitespace(cp) + || isClosingDelimiter(cp) + || isExpressionStart(cp) + ) { + break; } - - if (isExpressionStart(cp)) return; this.cursor.advance(); } - } - private recoverList(failedAt: CodePointIndex): void { - if (this.cursor.current() === failedAt) 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); - while (!this.cursor.isAtEnd()) { - const cp = this.cursor.peek(); - if (cp === CLOSE_PAREN || isExpressionStart(cp)) return; - this.cursor.advance(); - } + return this.errorExpression(error, panickedOver); } private skipWhitespace(): void { @@ -213,10 +341,32 @@ class Parser { 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 || 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 { diff --git a/src/styles/syntax-pane.css b/src/styles/syntax-pane.css index 5596238..17e7a1d 100644 --- a/src/styles/syntax-pane.css +++ b/src/styles/syntax-pane.css @@ -1,40 +1,36 @@ -.section-label { - margin: var(--gap-2) 0; - text-transform: uppercase; +.structure-tree { + min-height: 0; + overflow: auto; + padding: var(--gap-3); } -.error-list, -.expr-list, -.list-children { - display: flex; - flex-direction: column; - gap: var(--gap-2); -} - -.error-card, -.expr-node { +.syntax-node { border: 1px solid var(--border); 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); } -.error-card:hover, -.expr-node:hover { +.syntax-node:hover { border-color: var(--border-strong); 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, -.node-kind { +.node-kind, +.error-title { color: var(--text); font-size: var(--text-sm); font-weight: 650; @@ -42,7 +38,7 @@ .node-kind { display: inline-flex; - min-width: 4.5rem; + min-width: 7rem; color: var(--accent); } @@ -52,15 +48,65 @@ font-size: var(--text-sm); } -.list-node-header { - display: flex; - align-items: baseline; - justify-content: space-between; - gap: var(--gap-2); +.item-meta { + color: var(--text-muted); + font-size: var(--text-xs); } -.list-children { +.list-children, +.concrete-error-list { + display: flex; + flex-direction: column; + gap: var(--gap-2); margin-top: var(--gap-2); padding-left: var(--gap-3); 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); +} diff --git a/src/syntax.ts b/src/syntax.ts index bdbf4ad..bbf5f22 100644 --- a/src/syntax.ts +++ b/src/syntax.ts @@ -1,59 +1,171 @@ 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 = -| { tag: "literal", value: Literal } & A -| { tag: "list", values: Expr[] } & A +export type ConcreteSyntaxResult = +| { tag: "valid", value: ValidConcreteSyntax } +| { tag: "invalid", value: PartialConcreteSyntax } -export namespace ConcreteSyntax { - export function number(value: number, span: CodePointSpan): ConcreteSyntax { - return { tag: "literal", value: { tag: "number", value }, span }; +export type ValidConcreteSyntax = Program +export type PartialConcreteSyntax = Program + +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 { - return { tag: "literal", value: { tag: "identifier", value }, span }; + export function closeParen(span: CodePointSpan): DelimiterToken { + return { tag: "close-paren", span }; } - export function list(values: ConcreteSyntax[], span: CodePointSpan): ConcreteSyntax { - return { tag: "list", values, 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 = { + tag: "program", + expressions: Expr[], + error?: Error, +} & Info + +export type Expr = +| Literal +| List +| { tag: "error-expression", error: Error } & Info + +export type List = + { tag: "list", open: DelimiterToken, items: ListItem[], close?: DelimiterToken, error?: Error } & Info + +export type ListItem = +| Expr +| { tag: "error-list-separator", error: Error } & Info + +export type Literal = +| { 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( + expressions: Expr[], + info: Info, + error?: Error, + ): Program { + return error === undefined + ? { tag: "program", expressions, ...info } + : { tag: "program", expressions, error, ...info }; + } + + export function show(program: Program): string { + return program.expressions.map(Expr.show).join(" "); } } export namespace Expr { - export function number(value: number): Expr { - return { tag: "literal", value: { tag: "number", value } }; + export function number(value: number, span: CodePointSpan): Expr { + return { tag: "number", value, span }; } - export function identifier(value: Identifier): Expr { - return { tag: "literal", value: { tag: "identifier", value } }; + export function errorNumber(error: ConcreteError, span: CodePointSpan): Expr { + return { tag: "error-number", error, span }; } - export function list(values: Expr[]): Expr { - return { tag: "list", values }; + export function identifier(value: Identifier, span: CodePointSpan): Expr { + return { tag: "identifier", value, span }; } - export function show(e: Expr): string { - switch (e.tag) { - case "literal": - return showLiteral(e.value); - case "list": - return `(${e.values.map(show).join(" ")})`; - } + export function errorIdentifier(error: ConcreteError, span: CodePointSpan): Expr { + return { tag: "error-identifier", error, span }; } - function showLiteral(e: Literal): string { - switch (e.tag) { + export function list( + open: DelimiterToken, + items: ListItem[], + span: CodePointSpan, + close?: DelimiterToken, + error?: ConcreteError, + ): Expr { + return { tag: "list", open, items, close, error, span }; + } + + export function errorExpression(error: ConcreteError, span: CodePointSpan): Expr { + return { tag: "error-expression", error, span }; + } + + export function show(expr: Expr): string { + switch (expr.tag) { case "number": - return `${e.value}`; + return `${expr.value}`; case "identifier": - return `${e.value}`; + return expr.value; + case "error-number": + return ""; + case "error-identifier": + return ""; + case "error-expression": + return ""; + case "list": + return showList(expr); } } + + function showList(list: List): 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 = -| { tag: "number", value: number } -| { tag: "identifier", value: Identifier } +export namespace ListItem { + export function errorSeparator(error: ConcreteError, span: CodePointSpan): ListItem { + return { tag: "error-list-separator", error, span }; + } -type Identifier = string + export function show(item: ListItem): string { + if (item.tag === "error-list-separator") return ""; + return Expr.show(item); + } +} + +export function programOf(result: ConcreteSyntaxResult): PartialConcreteSyntax { + return result.value; +} diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 5c8103e..5483dc1 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -1,31 +1,35 @@ -import { createMemo, createSignal, Show } from 'solid-js'; +import { createMemo, createSignal } from 'solid-js'; import { sourceText } from 'source-region'; import type { CodePointSpan, SourceRegion, SourceText } from 'source-region'; import { parseDocument } from '../parser'; 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 { PaneHeader, PaneSplitter } from './Pane'; import { SourceGrid } from './SourceGrid'; import type { SourceGridAnnotation } from './SourceGrid'; -import { ErrorList, ExpressionList } from './SyntaxPane'; +import { StructureTree } from './SyntaxPane'; import type { HoverTarget } from './types'; type ParsedDocument = { source: SourceText; region: SourceRegion; - values: ConcreteSyntax[]; + syntax: ConcreteSyntaxResult; + program: PartialConcreteSyntax; errors: ParseError[]; }; const SAMPLE_INPUT = `(define square (_ x) (mul x x)) -(add 1 2) +[add, 1, 2] (define pyth (_ x y) (+ (square x) (square y))) 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() { const [input, setInput] = createSignal(SAMPLE_INPUT); @@ -37,7 +41,7 @@ export function App() { const source = sourceText(input()); const region = source.fullRegion(); 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 ( @@ -71,23 +75,14 @@ export function App() {
+ - 0} - fallback={ - - } - > - -
; +type PartialList = List; +type PartialListItem = ListItem; + +export function StructureTree(props: { + program: PartialConcreteSyntax; + isValid: boolean; + errorCount: number; onHover: (target: HoverTarget | undefined) => void; }) { return ( -
- -
- - {(error) => ( - -
{errorTitle(error)}
-
{errorDetail(error)}
-
- )} -
-
+
+ +
+ + program + + {props.isValid ? "valid" : "invalid"} · {props.program.expressions.length} expressions · {props.errorCount} errors + +
- 0}> - - - + + {(error) => } + + +
+ + {(expr, index) => ( + + )} + +
+
); } -export function ExpressionList(props: { - values: ConcreteSyntax[]; +function ExprView(props: { + expr: PartialExpr; + label: string; + onHover: (target: HoverTarget | undefined) => void; +}) { + switch (props.expr.tag) { + case "number": + case "identifier": + return ( + +
+ {props.expr.tag} + {Expr.show(props.expr)} +
+
+ ); + + case "error-number": + case "error-identifier": + case "error-expression": + return ( + +
+ + {props.expr.tag} + {spanLabel(props.expr.span)} +
+ +
+ ); + + case "list": + return ; + } +} + +function ListView(props: { + list: PartialList; + label: string; onHover: (target: HoverTarget | undefined) => void; }) { return ( -
- - {(value, index) => ( - +
+ + + + {listLabel(props.list.open.tag)} + {props.list.items.length} items · {delimiterLabel(props.list)} +
+ +
+ + + {(close) => } + +
+ + + {(error) => } + + +
+ + {(item, index) => ( + + )} + +
+ + ); +} + +function ListItemView(props: { + item: PartialListItem; + label: string; + onHover: (target: HoverTarget | undefined) => void; +}) { + if (props.item.tag === "error-list-separator") { + return ( + +
+ + error-list-separator + {spanLabel(props.item.span)} +
+ +
+ ); + } + + return ; +} + +function ConcreteErrorView(props: { + error: ConcreteError; + label: string; + onHover: (target: HoverTarget | undefined) => void; +}) { + return ( +
+ + {(node, index) => ( + )} @@ -58,48 +198,40 @@ export function ExpressionList(props: { ); } -function ExprView(props: { - expr: ConcreteSyntax; +function ConcreteErrorNodeView(props: { + node: ConcreteErrorNode; label: string; onHover: (target: HoverTarget | undefined) => void; }) { - if (props.expr.tag === "literal") { - return ( - - {props.expr.value.tag} - {literalValue(props.expr)} - - ); - } - return ( - +
{errorTitle(props.node.error)}
+
{errorDetail(props.node.error)}
+
+ + + {(span) => } + +
+
+ ); +} + +function SpanChip(props: { + label: string; + span: CodePointSpan; + onHover: (target: HoverTarget | undefined) => void; +}) { + return ( + ); } @@ -121,6 +253,10 @@ function HoverBlock(props: { ); } -function literalValue(expr: ConcreteSyntax): string { - return expr.tag === "literal" ? Expr.show(expr) : ""; +function listLabel(tag: string): string { + 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`; } diff --git a/src/ui/format.ts b/src/ui/format.ts index 63d8cf2..8722db6 100644 --- a/src/ui/format.ts +++ b/src/ui/format.ts @@ -5,10 +5,12 @@ export function errorTitle(error: ParseError): string { switch (error.tag) { case "expected-expression": return "Expected expression"; - case "expected-close-paren": - return "Expected closing paren"; - case "unexpected-close-paren": - return "Unexpected closing paren"; + 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": @@ -24,10 +26,12 @@ export function errorDetail(error: ParseError): string { switch (error.tag) { case "expected-expression": return `found ${foundLabel(error.found)}`; - case "expected-close-paren": - return `opened at ${spanLabel(error.openParen)}, found ${foundLabel(error.found)}`; - case "unexpected-close-paren": - return spanLabel(error.span); + 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":