This commit is contained in:
Yura Dupyn 2026-04-25 18:20:26 +02:00
parent 1b4b07c1fa
commit a5baf0d33a
4 changed files with 586 additions and 1 deletions

View file

@ -1,7 +1,8 @@
import { createSignal, Switch, Match } from 'solid-js'; import { createSignal, Switch, Match } from 'solid-js';
import { App as LispApp } from './languages/lisp/App'; import { App as LispApp } from './languages/lisp/App';
import { App as JsonApp } from './languages/json/App';
type LanguageId = "lisp"; type LanguageId = "lisp" | "json";
export function App() { export function App() {
const [language, setLanguage] = createSignal<LanguageId>("lisp"); const [language, setLanguage] = createSignal<LanguageId>("lisp");
@ -16,6 +17,7 @@ export function App() {
onChange={(event) => setLanguage(event.currentTarget.value as LanguageId)} onChange={(event) => setLanguage(event.currentTarget.value as LanguageId)}
> >
<option value="lisp">Lisp</option> <option value="lisp">Lisp</option>
<option value="json">JSON</option>
</select> </select>
</header> </header>
@ -23,6 +25,9 @@ export function App() {
<Match when={language() === "lisp"}> <Match when={language() === "lisp"}>
<LispApp /> <LispApp />
</Match> </Match>
<Match when={language() === "json"}>
<JsonApp />
</Match>
</Switch> </Switch>
</div> </div>
); );

View file

@ -0,0 +1,116 @@
import { createMemo, createSignal } from 'solid-js';
import { sourceText } from 'source-region';
import type { CodePointSpan, SourceRegion, SourceText } from 'source-region';
import {
parseDocument,
programOf,
} from '../../../languages/json';
import type {
ConcreteSyntaxResult,
ParseError,
PartialConcreteSyntax,
} from '../../../languages/json';
import { hoverAnnotation } from '../../annotations';
import { spanLabel } from '../../format';
import { PaneHeader, PaneSplitter } from '../../Pane';
import { SourceGrid } from '../../SourceGrid';
import type { HoverTarget } from '../../types';
import { clamp } from '../../utils';
import { StructureTree } from './StructurePane';
type ParsedDocument = {
source: SourceText;
region: SourceRegion;
syntax: ConcreteSyntaxResult;
program: PartialConcreteSyntax;
errors: ParseError[];
};
const SAMPLE_INPUT = `{
"name": "Ada",
"scores": [1, 2, 3],
"active": true,
"nested": { "ok": null }
}
[1 2, 3]
{"x" 1, "y": 2}
{"bad": "escape \\x"}
01 - 1. 1e+ 123abc
@@@ {"recovered": true}`;
export function App() {
const [input, setInput] = createSignal(SAMPLE_INPUT);
const [hovered, setHovered] = createSignal<HoverTarget | undefined>();
const [leftWidth, setLeftWidth] = createSignal(420);
const [middleWidth, setMiddleWidth] = createSignal(480);
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} values, ${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, 820));
}}
/>
<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>
);
}

View file

@ -0,0 +1,407 @@
import { For, Show } from 'solid-js';
import type {
ArrayItem,
ConcreteError,
ConcreteErrorNode,
ConcreteInfo,
JsonArray,
JsonObject,
JsonValue,
MemberItem,
PartialConcreteSyntax,
PartialJsonValue,
StringLiteral,
} from '../../../languages/json';
import { JsonValue as JsonValueOps } from '../../../languages/json';
import { HoverBlock } from '../../HoverBlock';
import { SpanChip } from '../../SpanChip';
import { spanLabel } from '../../format';
import type { HoverTarget } from '../../types';
import { errorDetail, errorTitle } from './format';
type PartialObject = JsonObject<ConcreteInfo, ConcreteError>;
type PartialArray = JsonArray<ConcreteInfo, ConcreteError>;
type PartialMemberItem = MemberItem<ConcreteInfo, ConcreteError>;
type PartialArrayItem = ArrayItem<ConcreteInfo, ConcreteError>;
type PartialString = StringLiteral<ConcreteInfo, ConcreteError>;
export function StructureTree(props: {
program: PartialConcreteSyntax;
isValid: boolean;
errorCount: number;
onHover: (target: HoverTarget | undefined) => void;
}) {
return (
<div class="structure-tree">
<HoverBlock
class="syntax-node program-node"
label="program"
span={props.program.span}
onHover={props.onHover}
>
<div class="node-header">
<span class={props.isValid ? "status-dot status-valid" : "status-dot status-invalid"} />
<span class="node-kind">program</span>
<span class="item-meta">
{props.isValid ? "valid" : "invalid"} · {props.program.expressions.length} values · {props.errorCount} errors
</span>
</div>
<Show when={props.program.error}>
{(error) => <ConcreteErrorView error={error()} label="program error" onHover={props.onHover} />}
</Show>
<div class="list-children">
<For each={props.program.expressions}>
{(value, index) => (
<ValueView
value={value}
label={`value ${index() + 1}`}
onHover={props.onHover}
/>
)}
</For>
</div>
</HoverBlock>
</div>
);
}
function ValueView(props: {
value: PartialJsonValue;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
switch (props.value.tag) {
case "object":
return <ObjectView object={props.value} label={props.label} onHover={props.onHover} />;
case "array":
return <ArrayView array={props.value} label={props.label} onHover={props.onHover} />;
case "string":
case "number":
case "null":
case "true":
case "false":
return <ScalarView value={props.value} label={props.label} onHover={props.onHover} />;
case "error-string":
case "error-number":
case "error-expression":
return <ErrorValueView value={props.value} label={props.label} onHover={props.onHover} />;
}
}
function ObjectView(props: {
object: PartialObject;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
return (
<HoverBlock
class={props.object.error ? "syntax-node list-node syntax-error-node" : "syntax-node list-node"}
label={`${props.label}: object`}
span={props.object.span}
onHover={props.onHover}
>
<div class="node-header">
<Show when={props.object.error}>
<span class="status-dot status-invalid" />
</Show>
<span class="node-kind">object</span>
<span class="item-meta">{props.object.members.length} members · {delimiterLabel(props.object)}</span>
</div>
<div class="delimiter-row">
<SpanChip label={props.object.open.tag} span={props.object.open.span} onHover={props.onHover} />
<Show when={props.object.close}>
{(close) => <SpanChip label={close().tag} span={close().span} onHover={props.onHover} />}
</Show>
</div>
<Show when={props.object.error}>
{(error) => <ConcreteErrorView error={error()} label="object error" onHover={props.onHover} />}
</Show>
<div class="list-children">
<For each={props.object.members}>
{(member, index) => (
<MemberItemView
item={member}
label={`${props.label}.member ${index() + 1}`}
onHover={props.onHover}
/>
)}
</For>
</div>
</HoverBlock>
);
}
function MemberItemView(props: {
item: PartialMemberItem;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
if (props.item.tag === "error-object-separator") {
return (
<HoverBlock
class="syntax-node syntax-error-node"
label={`${props.label}: object separator`}
span={props.item.span}
onHover={props.onHover}
>
<div class="node-header">
<span class="status-dot status-invalid" />
<span class="node-kind">error-object-separator</span>
<span class="item-meta">{spanLabel(props.item.span)}</span>
</div>
<ConcreteErrorView error={props.item.error} label="object separator error" onHover={props.onHover} />
</HoverBlock>
);
}
return (
<HoverBlock
class={props.item.error ? "syntax-node syntax-error-node" : "syntax-node"}
label={`${props.label}: member`}
span={props.item.span}
onHover={props.onHover}
>
<div class="node-header">
<Show when={props.item.error}>
<span class="status-dot status-invalid" />
</Show>
<span class="node-kind">member</span>
<span class="item-meta">{spanLabel(props.item.span)}</span>
</div>
<div class="delimiter-row">
<Show when={props.item.colon}>
{(colon) => <SpanChip label="colon" span={colon().span} onHover={props.onHover} />}
</Show>
</div>
<Show when={props.item.error}>
{(error) => <ConcreteErrorView error={error()} label="member error" onHover={props.onHover} />}
</Show>
<div class="list-children">
<StringView string={props.item.key} label={`${props.label}.key`} onHover={props.onHover} />
<ValueView value={props.item.value} label={`${props.label}.value`} onHover={props.onHover} />
</div>
</HoverBlock>
);
}
function ArrayView(props: {
array: PartialArray;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
return (
<HoverBlock
class={props.array.error ? "syntax-node list-node syntax-error-node" : "syntax-node list-node"}
label={`${props.label}: array`}
span={props.array.span}
onHover={props.onHover}
>
<div class="node-header">
<Show when={props.array.error}>
<span class="status-dot status-invalid" />
</Show>
<span class="node-kind">array</span>
<span class="item-meta">{props.array.items.length} items · {delimiterLabel(props.array)}</span>
</div>
<div class="delimiter-row">
<SpanChip label={props.array.open.tag} span={props.array.open.span} onHover={props.onHover} />
<Show when={props.array.close}>
{(close) => <SpanChip label={close().tag} span={close().span} onHover={props.onHover} />}
</Show>
</div>
<Show when={props.array.error}>
{(error) => <ConcreteErrorView error={error()} label="array error" onHover={props.onHover} />}
</Show>
<div class="list-children">
<For each={props.array.items}>
{(item, index) => (
<ArrayItemView
item={item}
label={`${props.label}.${index() + 1}`}
onHover={props.onHover}
/>
)}
</For>
</div>
</HoverBlock>
);
}
function ArrayItemView(props: {
item: PartialArrayItem;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
if (props.item.tag === "error-array-separator") {
return (
<HoverBlock
class="syntax-node syntax-error-node"
label={`${props.label}: array separator`}
span={props.item.span}
onHover={props.onHover}
>
<div class="node-header">
<span class="status-dot status-invalid" />
<span class="node-kind">error-array-separator</span>
<span class="item-meta">{spanLabel(props.item.span)}</span>
</div>
<ConcreteErrorView error={props.item.error} label="array separator error" onHover={props.onHover} />
</HoverBlock>
);
}
return <ValueView value={props.item} label={props.label} onHover={props.onHover} />;
}
function ScalarView(props: {
value: Exclude<PartialJsonValue, PartialObject | PartialArray | { tag: "error-expression"; error: ConcreteError } & ConcreteInfo>;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
if (props.value.tag === "string") {
return <StringView string={props.value} label={props.label} onHover={props.onHover} />;
}
return (
<HoverBlock
class={props.value.error ? "syntax-node literal-node syntax-error-node" : "syntax-node literal-node"}
label={`${props.label}: ${props.value.tag}`}
span={props.value.span}
onHover={props.onHover}
>
<div class="node-header">
<Show when={props.value.error}>
<span class="status-dot status-invalid" />
</Show>
<span class="node-kind">{props.value.tag}</span>
<span class="node-value">{JsonValueOps.show(props.value as JsonValue<ConcreteInfo, ConcreteError>)}</span>
</div>
<Show when={props.value.error}>
{(error) => <ConcreteErrorView error={error()} label={`${props.label} error`} onHover={props.onHover} />}
</Show>
</HoverBlock>
);
}
function StringView(props: {
string: PartialString;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
if (props.string.tag === "error-string") {
return (
<HoverBlock
class="syntax-node syntax-error-node"
label={`${props.label}: error-string`}
span={props.string.span}
onHover={props.onHover}
>
<div class="node-header">
<span class="status-dot status-invalid" />
<span class="node-kind">error-string</span>
<span class="item-meta">{spanLabel(props.string.span)}</span>
</div>
<ConcreteErrorView error={props.string.error} label="string error" onHover={props.onHover} />
</HoverBlock>
);
}
return (
<HoverBlock
class={props.string.error ? "syntax-node literal-node syntax-error-node" : "syntax-node literal-node"}
label={`${props.label}: string`}
span={props.string.span}
onHover={props.onHover}
>
<div class="node-header">
<Show when={props.string.error}>
<span class="status-dot status-invalid" />
</Show>
<span class="node-kind">string</span>
<span class="node-value">{JSON.stringify(props.string.value)}</span>
</div>
<Show when={props.string.error}>
{(error) => <ConcreteErrorView error={error()} label="string error" onHover={props.onHover} />}
</Show>
</HoverBlock>
);
}
function ErrorValueView(props: {
value:
| Extract<PartialJsonValue, { tag: "error-expression" }>
| Extract<PartialJsonValue, { tag: "error-number" }>
| Extract<PartialJsonValue, { tag: "error-string" }>;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
return (
<HoverBlock
class="syntax-node syntax-error-node"
label={`${props.label}: ${props.value.tag}`}
span={props.value.span}
onHover={props.onHover}
>
<div class="node-header">
<span class="status-dot status-invalid" />
<span class="node-kind">{props.value.tag}</span>
<span class="item-meta">{spanLabel(props.value.span)}</span>
</div>
<ConcreteErrorView error={props.value.error} label={props.value.tag} onHover={props.onHover} />
</HoverBlock>
);
}
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}
/>
)}
</For>
</div>
);
}
function ConcreteErrorNodeView(props: {
node: ConcreteErrorNode;
label: string;
onHover: (target: HoverTarget | undefined) => void;
}) {
return (
<div class="concrete-error">
<div class="error-title">{errorTitle(props.node.error)}</div>
<div class="item-meta">{errorDetail(props.node.error)}</div>
<div class="span-chip-row">
<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 delimiterLabel(value: PartialObject | PartialArray): string {
return value.close ? `${value.open.tag} / ${value.close.tag}` : `${value.open.tag} / missing close`;
}

View file

@ -0,0 +1,57 @@
import type { FoundSyntax, ParseError } from '../../../languages/json';
import { spanLabel } from '../../format';
export function errorTitle(error: ParseError): string {
switch (error.tag) {
case "expected-value":
return "Expected value";
case "expected-member-key":
return "Expected member key";
case "expected-colon":
return "Expected colon";
case "expected-array-separator":
return "Expected array separator";
case "expected-object-separator":
return "Expected object separator";
case "expected-close-delimiter":
return "Expected closing delimiter";
case "unexpected-close-delimiter":
return "Unexpected closing delimiter";
case "invalid-string":
return "Invalid string";
case "invalid-number":
return "Invalid number";
}
}
export function errorDetail(error: ParseError): string {
switch (error.tag) {
case "expected-value":
return `found ${foundLabel(error.found)}`;
case "expected-member-key":
return `found ${foundLabel(error.found)}`;
case "expected-colon":
return `found ${foundLabel(error.found)}`;
case "expected-array-separator":
return `found ${foundLabel(error.found)}`;
case "expected-object-separator":
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 "invalid-string":
return error.reason;
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)}`;
}
}