Merge branch 'main' into gh-pages

This commit is contained in:
Parker TenBroeck 2026-01-10 11:16:16 -05:00
commit 401182bf72
8 changed files with 427 additions and 70 deletions

View file

@ -96,7 +96,7 @@ fn begin_ident(c: char) -> bool {
}
fn continue_ident(c: char) -> bool {
c.is_alphanumeric() || c == '_' || (!c.is_ascii() && !c.is_control() && !c.is_whitespace())
c.is_alphanumeric() || c == '_' || c=='\'' || (!c.is_ascii() && !c.is_control() && !c.is_whitespace())
}
impl<'a> std::iter::Iterator for Lexer<'a> {

View file

@ -28,10 +28,24 @@
<div class="vSplit" style="--split-default: 20%" title="Drag to resize canvas width"></div>
<section>
<section class="flexCol gap marginTop">
<div class="flexCenter sidePadding gap">
<button id="themeToggle" class="btn btn-grey" title="Toggle light/dark">
🌙 Dark
</button>
<button class="btn btm-grey" style="position: relative" id="shareBtn" type="button">
Share
<span class="share-toast" id="shareToast">
Copied to clipboard
</span>
</button>
</div>
<div class="flexCenter sidePadding">
<select id="exampleSelect" class="ex-select">
<option value="" selected disabled>Choose an example…</option>
</select>
</div>
<div class="hSplit styleOnly" title="Drag to resize canvas height"></div>
</section>
</section>

View file

@ -19,11 +19,19 @@ export function machine_from_json(json: string): Machine {
}
machine.edges = new Map();
machine.transitions_components = new Map();
switch (machine.type) {
case "fa":
{
for (const [from, tos] of machine.transitions) {
for (const to of tos) {
const layer_0 = machine.transitions_components;
if(!layer_0.has(from.state)) layer_0.set(from.state, new Map());
const layer_1 = machine.transitions_components.get(from.state)!;
if(!layer_1.has(from.letter)) layer_1.set(from.letter, []);
const layer_2 = layer_1.get(from.letter)!;
layer_2.push(to);
const edge = from.state + "#" + to.state;
if (!machine.edges.has(edge)) machine.edges.set(edge, []);
machine.edges.get(edge)?.push({
@ -40,6 +48,15 @@ export function machine_from_json(json: string): Machine {
machine.symbols = new Map(Object.entries(machine.symbols));
for (const [from, tos] of machine.transitions) {
for (const to of tos) {
const layer_0 = machine.transitions_components;
if(!layer_0.has(from.state)) layer_0.set(from.state, new Map());
const layer_1 = machine.transitions_components.get(from.state)!;
if(!layer_1.has(from.symbol)) layer_1.set(from.symbol, new Map());
const layer_2 = layer_1.get(from.symbol)!;
if(!layer_2.has(from.letter)) layer_2.set(from.letter, []);
const layer_3 = layer_2.get(from.letter)!;
layer_3.push(to);
const edge = from.state + "#" + to.state;
if (!machine.edges.has(edge)) machine.edges.set(edge, []);
machine.edges.get(edge)?.push({
@ -56,6 +73,13 @@ export function machine_from_json(json: string): Machine {
machine.symbols = new Map(Object.entries(machine.symbols));
for (const [from, tos] of machine.transitions) {
for (const to of tos) {
const layer_0 = machine.transitions_components;
if(!layer_0.has(from.state)) layer_0.set(from.state, new Map());
const layer_1 = machine.transitions_components.get(from.state)!;
if(!layer_1.has(from.symbol)) layer_1.set(from.symbol, []);
const layer_2 = layer_1.get(from.symbol)!;
layer_2.push(to);
const edge = from.state + "#" + to.state;
if (!machine.edges.has(edge)) machine.edges.set(edge, []);
machine.edges.get(edge)?.push({
@ -108,6 +132,7 @@ export type Fa = {
final_states: Map<State, StateInfo>;
transitions: Map<FaTransFrom, FaTransTo[]>;
transitions_components: Map<State, Map<Letter|null, FaTransTo[]>>;
edges: Map<string, Edge[]>;
};
@ -137,6 +162,7 @@ export type Pda = {
final_states: Map<State, StateInfo> | null;
transitions: Map<PdaTransFrom, PdaTransTo[]>;
transitions_components: Map<State, Map<Symbol, Map<Letter|null, PdaTransTo[]>>>;
edges: Map<string, Edge[]>;
};
@ -166,6 +192,8 @@ export type Tm = {
final_states: Map<State, StateInfo>;
transitions: Map<TmTransFrom, TmTransTo[]>;
transitions_components: Map<State, Map<Symbol, TmTransTo[]>>;
edges: Map<string, Edge[]>;
};

View file

@ -22,6 +22,8 @@ import { terminalPlugin } from "./terminal.ts";
import { setAutomaton } from "./visualizer.ts";
import { machine_from_json } from "./automata.ts";
import { sharedText } from "./share.ts";
import { examples } from "./examples.ts";
function tokenize(text: string) {
@ -43,38 +45,21 @@ function compile(text: string): wasm.CompileResult {
}
}
const tokenClass = (t: string) =>
({
comment: "tok-comment",
keyword: "tok-keyword",
error: "tok-error",
ident: "tok-ident",
punc: "tok-punc",
string: "tok-string",
lpar: "rb-",
lbrace: "rb-",
lbracket: "rb-",
rpar: "rb-",
rbrace: "rb-",
rbracket: "rb-",
}[t] || "tok-ident");
function severityClass(sev: string) {
const s = (sev || "error").toLowerCase();
if (s === "warning") return "cm-diag-warning";
if (s === "info") return "cm-diag-info";
return "cm-diag-error";
}
function sevRank(sev: string) {
if (sev === "error") return 3;
if (sev === "warning") return 2;
return 1;
}
export const analysisField = StateField.define({
create(state) {
const text = state.doc.toString();
return buildAnalysis(text, state.doc);
},
update(value, tr) {
if (!tr.docChanged) return value;
const text = tr.state.doc.toString();
return buildAnalysis(text, tr.state.doc);
},
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
});
function buildAnalysis(text: string, doc: Text) {
save(text);
const tokens = tokenize(text);
const { log, log_formatted, graph } = compile(text);
@ -86,7 +71,6 @@ function buildAnalysis(text: string, doc: Text) {
}
}
// Build ONE Decoration set: syntax + diagnostics
const marks = [];
const docLen = doc.length;
@ -120,18 +104,35 @@ function buildAnalysis(text: string, doc: Text) {
return { tokens, log, log_formatted, deco };
}
export const analysisField = StateField.define({
create(state) {
const text = state.doc.toString();
return buildAnalysis(text, state.doc);
},
update(value, tr) {
if (!tr.docChanged) return value;
const text = tr.state.doc.toString();
return buildAnalysis(text, tr.state.doc);
},
provide: (f) => EditorView.decorations.from(f, (v) => v.deco),
});
const tokenClass = (t: string) =>
({
comment: "tok-comment",
keyword: "tok-keyword",
error: "tok-error",
ident: "tok-ident",
punc: "tok-punc",
string: "tok-string",
lpar: "rb-",
lbrace: "rb-",
lbracket: "rb-",
rpar: "rb-",
rbrace: "rb-",
rbracket: "rb-",
}[t] || "tok-ident");
function severityClass(sev: string) {
const s = (sev || "error").toLowerCase();
if (s === "warning") return "cm-diag-warning";
if (s === "info") return "cm-diag-info";
return "cm-diag-error";
}
function sevRank(sev: string) {
if (sev === "error") return 3;
if (sev === "warning") return 2;
return 1;
}
// ===================== Hover tooltip (uses cached diags) =====================
const diagHover = hoverTooltip((view, pos) => {
@ -169,32 +170,24 @@ const diagHover = hoverTooltip((view, pos) => {
};
});
function save(text: string){
globalThis.localStorage.save = text;
}
const initialText = `type=NPDA
Q = {q0, q1} // states
E = {a, b} // alphabet
T = {z0, A, B} // stack
q0 = q0
z0 = z0
function getSaved(): string | undefined{
return globalThis.localStorage.save;
}
// construct all possible permutations of A's and B's
d(q0, epsilon, z0) = { (q0, [A z0]), (q0, [B z0]) }
d(q0, epsilon, A) = { (q0, [A A]), (q0, [B A]) }
export function setText(text: string){
editor.dispatch({ changes: { from: 0, to: editor.state.doc.length, insert: text } });
}
d(q0, epsilon, B) = { (q0, [A B]), (q0, [B B]) }
// transition to q1
d(q0, epsilon, z0) = { (q1, z0) }
d(q0, epsilon, A) = { (q1, A) }
d(q0, epsilon, B) = { (q1, B) }
// consume stack until empty
d(q1, a, A) = { (q1, epsilon) }
d(q1, b, B) = { (q1, epsilon) }
`;
export function getText(): string{
return editor.state.doc.toString()
}
const state = EditorState.create({
doc: initialText,
doc: sharedText() ?? getSaved() ?? examples[0].machine,
extensions: [
lineNumbers(),
highlightActiveLineGutter(),

215
web/root/src/examples.ts Normal file
View file

@ -0,0 +1,215 @@
import { setText } from "./editor.ts";
export type Category =
| "Tutorial"
| "DFA"
| "NFA"
| "DPDA"
| "NPDA"
| "TM"
| "NTM";
export class Example {
category: Category;
title: string;
machine: string;
constructor(category: Category, title: string, machine: string) {
this.category = category;
this.title = title;
this.machine = machine;
}
}
export const examples: Example[] = [
new Example(
"Tutorial",
"DFA",
`// strings over a,b which start and end with different letters
type = DFA // type of machine DFA, NFA, DPDA, NPDA, DTM, NTM
Q = {q0, qa, qa', qb, qb'} // set of states
E = {a, b} // alphabet
F = {qa', qb'} // set of final states
q0 = q0 // initial state
// transition function (state, letter) -> state
d(q0, a) = qa
d(q0, b) = qb
d(qa, a) = qa
d(qa, b) = qa'
d(qa', a) = qa
d(qa', b) = qa'
d(qb, a) = qb'
d(qb, b) = qb
d(qb', a) = qb'
d(qb', b) = qb`,
),
new Example(
"DFA",
"modulo",
`type=DFA
E={1,2,3}
Q={q0, q1, q2, q3, q4}
F = {q0}
q0=q0
d(q0, 1) = q1
d(q1, 1) = q2
d(q2, 1) = q3
d(q3, 1) = q4
d(q4, 1) = q0
d(q0, 2) = q2
d(q1, 2) = q3
d(q2, 2) = q4
d(q3, 2) = q0
d(q4, 2) = q1
d(q0, 3) = q3
d(q1, 3) = q4
d(q2, 3) = q0
d(q3, 3) = q1
d(q4, 3) = q2`,
),
new Example(
"DPDA",
"unequal",
`type=DPDA
Q = {q0, qas, qeq, qmb, qlb} // states
E = {a, b} // alphabet
T = {z0, A} // stack
F = {qmb, qlb} // final states
q0 = q0
z0 = z0
d(q0, a, z0) = (qas, z0)
d(qas, a, z0) = (qas, [A z0])
d(qas, b, z0) = (qeq, z0)
d(qas, a, A) = (qas, [A A])
d(qas, b, A) = (qlb, ~)
d(qlb, b, A) = (qeq, ~)
d(qlb, b, z0) = (qeq, z0)
d(qeq, b, z0) = (qmb, z0)
d(qmb, b, z0) = (qmb, z0)`,
),
new Example(
"NPDA",
"unequal",
`type=NPDA
Q = {q0, q1} // states
E = {a, b} // alphabet
T = {z0, A, B} // stack
q0 = q0
z0 = z0
// construct all possible permutations of A's and B's
d(q0, epsilon, z0) = { (q0, [A z0]), (q0, [B z0]) }
d(q0, epsilon, A) = { (q0, [A A]), (q0, [B A]) }
d(q0, epsilon, B) = { (q0, [A B]), (q0, [B B]) }
// transition to q1
d(q0, epsilon, z0) = { (q1, z0) }
d(q0, epsilon, A) = { (q1, A) }
d(q0, epsilon, B) = { (q1, B) }
// consume stack until empty
d(q1, a, A) = { (q1, epsilon) }
d(q1, b, B) = { (q1, epsilon) }`,
),
];
const CATEGORY_ORDER: Category[] = [
"Tutorial",
"DFA",
"NFA",
"DPDA",
"NPDA",
"TM",
"NTM",
];
function buildExamplesDropdown(
selectEl: HTMLSelectElement,
examples: Example[],
onPick?: (ex: Example) => void,
) {
// Clear everything except the first placeholder option (if present)
const keepFirstPlaceholder = selectEl.options.length > 0 &&
selectEl.options[0].disabled && selectEl.options[0].value === "";
selectEl.innerHTML = "";
if (keepFirstPlaceholder) {
const placeholder = document.createElement("option");
placeholder.value = "";
placeholder.disabled = true;
placeholder.selected = true;
placeholder.textContent = "Choose an example…";
selectEl.appendChild(placeholder);
}
// Group examples by category
const grouped = new Map<Category, Example[]>();
for (const ex of examples) {
if (!grouped.has(ex.category)) grouped.set(ex.category, []);
grouped.get(ex.category)!.push(ex);
}
// Optional: sort titles within each group
for (const [cat, list] of grouped) {
list.sort((a, b) => a.title.localeCompare(b.title));
grouped.set(cat, list);
}
// Create optgroups in your preferred order (and then any extras)
const categoriesToRender: Category[] = [
...CATEGORY_ORDER.filter((c) => grouped.has(c)),
...Array.from(grouped.keys()).filter((c) => !CATEGORY_ORDER.includes(c))
.sort(),
];
// We'll store a stable reference via an index into the examples array
// (simplest + avoids encoding large machine strings into <option value>)
const indexByIdentity = new Map<Example, number>();
examples.forEach((ex, i) => indexByIdentity.set(ex, i));
for (const category of categoriesToRender) {
const optgroup = document.createElement("optgroup");
optgroup.label = category;
for (const ex of grouped.get(category)!) {
const opt = document.createElement("option");
opt.value = String(indexByIdentity.get(ex)!); // index
opt.textContent = ex.title;
optgroup.appendChild(opt);
}
selectEl.appendChild(optgroup);
}
// Change handler
selectEl.onchange = () => {
const v = selectEl.value;
if (!v) return;
const picked = examples[Number(v)];
if (picked && onPick) onPick(picked);
selectEl.value = "";
};
}
const selectEl = document.getElementById("exampleSelect") as HTMLSelectElement;
buildExamplesDropdown(selectEl, examples, (picked) => {
setText(picked.machine);
});

View file

@ -3,3 +3,5 @@ import "./visualizer.ts"
import "./splitters.ts"
import "./controls.ts"
import "./theme.ts"
import "./share.ts"
import "./examples.ts"

35
web/root/src/share.ts Normal file
View file

@ -0,0 +1,35 @@
import { getText } from "./editor.ts";
const btn = document.getElementById("shareBtn")!;
const toast = document.getElementById("shareToast")!;
function generateShareLink() {
return `${globalThis.window.location.href}?share=${encodeURIComponent(getText())}`;
}
async function copy(text: string) {
await navigator.clipboard.writeText(text);
}
btn.addEventListener("click", async () => {
await copy(generateShareLink());
toast.classList.remove("show");
void toast.offsetWidth;
toast.classList.add("show");
});
export function sharedText(): string|null {
const url = new URL(globalThis.window.location.href);
const text: string | null = url.searchParams.get("share");
if (text !== null) {
url.searchParams.delete("share");
globalThis.window.history.replaceState(
{},
document.title,
url.pathname + url.search + url.hash
);
}
return text;
}

View file

@ -42,6 +42,23 @@ body {
flex-direction: row;
}
.flexCenter{
display: flex;
justify-content: center;
}
.gap {
gap: 1em
}
.marginTop {
margin-top: 1em;
}
.sidePadding {
padding: 0px 1em;
}
.hSplit {
:not( .styleOnly){
cursor: col-resize;
@ -72,3 +89,56 @@ body {
.vSplit:hover:not(.styleOnly) {
background: var(--separator-hover);
}
.ex-label {
display: block;
margin-bottom: 6px;
}
.ex-select {
width: 320px;
max-width: 100%;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid var(bg-1);
background: var(--bg-2);
}
.share-toast {
position: absolute;
left: 50%;
top: calc(100% + 10px);
transform: translateX(-50%);
padding: 0.45rem 0.65rem;
border-radius: 10px;
background: var(--bg-2);
color: var(--fg-0);
font-size: 12px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
}
.share-toast.show {
animation: toastFade 1.4s ease forwards;
}
@keyframes toastFade {
0% {
opacity: 0;
transform: translateX(-50%) translateY(-4px);
}
15% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
80% {
opacity: 1;
}
100% {
opacity: 0;
}
}