diff --git a/Cargo.lock b/Cargo.lock index c2f3772..edc3887 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -12,6 +12,8 @@ version = "0.1.0" dependencies = [ "automata", "console_error_panic_hook", + "serde", + "serde_json", "wasm-bindgen", "web-sys", ] @@ -38,6 +40,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "itoa" +version = "1.0.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2" + [[package]] name = "js-sys" version = "0.3.83" @@ -48,6 +56,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "memchr" +version = "2.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f52b00d39961fc5b2736ea853c9cc86238e165017a493d1d5c8eac6bdc4cc273" + [[package]] name = "once_cell" version = "1.21.3" @@ -78,6 +92,49 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + [[package]] name = "syn" version = "2.0.111" @@ -149,3 +206,9 @@ dependencies = [ "js-sys", "wasm-bindgen", ] + +[[package]] +name = "zmij" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2fc5a66a20078bf1251bde995aa2fdcc4b800c70b5d92dd2c62abc5c60f679f8" diff --git a/src/automata/mod.rs b/src/automata/mod.rs index 3f8826c..e39209f 100644 --- a/src/automata/mod.rs +++ b/src/automata/mod.rs @@ -72,9 +72,21 @@ pub struct StateMap(Vec); index!(StateMap, self, self.0, index.0 as usize, index = State); +impl StateMap{ + pub fn entries(&self) -> impl Iterator{ + self.0.iter().enumerate().map(|(i, v)|(State(i as u16), v)) + } +} + #[derive(Clone, Debug)] pub struct SymbolMap(Vec); +impl SymbolMap{ + pub fn entries(&self) -> impl Iterator{ + self.0.iter().enumerate().map(|(i, v)|(Symbol(i as u16), v)) + } +} + index!(SymbolMap, self, self.0, index.0 as usize, index = Symbol); #[derive(Clone, Debug, Default)] @@ -83,6 +95,16 @@ pub struct StateSymbolMap { max_state: u16, } +impl StateSymbolMap{ + pub fn entries(&self) -> impl Iterator{ + self.map.iter().enumerate().map(|(i, v)|{ + let state = State((i % self.max_state as usize) as u16); + let symbol = Symbol((i / self.max_state as usize) as u16); + ((state, symbol), v) + }) + } +} + index!( StateSymbolMap, self, @@ -101,6 +123,12 @@ index!( #[derive(Clone, Debug, Default)] pub struct CharMap(HashMap); +impl CharMap{ + pub fn entries(&self) -> impl Iterator{ + self.0.iter().map(|(k, v)|(*k, v)) + } +} + index!( CharMap, self, @@ -113,6 +141,12 @@ index!( #[derive(Clone, Debug, Default)] pub struct CharEpsilonMap(HashMap, T>); +impl CharEpsilonMap{ + pub fn entries(&self) -> impl Iterator, &T)>{ + self.0.iter().map(|(k, v)|(*k, v)) + } +} + index!( CharEpsilonMap, self, @@ -122,3 +156,4 @@ index!( self.0.entry(Some(char)).or_default() ); index!(CharEpsilonMap, self, self.0, &char, char = Option, self.0.entry(char).or_default()); + diff --git a/src/automata/npda.rs b/src/automata/npda.rs index cafcb08..52af692 100644 --- a/src/automata/npda.rs +++ b/src/automata/npda.rs @@ -3,7 +3,17 @@ use std::collections::HashSet; use super::*; #[derive(Clone, Debug, PartialEq, Eq, Hash)] -struct To(State, Vec); +pub struct To(State, Vec); + +impl To{ + pub fn state(&self) -> State{ + self.0 + } + + pub fn stack(&self) -> &[Symbol]{ + &self.1 + } +} #[derive(Clone, Debug)] #[allow(unused)] @@ -18,6 +28,46 @@ pub struct Npda { transitions: StateSymbolMap>>, } +#[derive(Clone, Copy, PartialEq, Eq, Hash)] +pub struct StateTransition { + pub from: T, + pub to: T, +} + +impl Npda { + pub fn get_state_name(&self, state: State) -> Option<&str>{ + self.state_names.get(state).map(String::as_str) + } + + pub fn get_symbol_name(&self, symbol: Symbol) -> Option<&str>{ + self.symbol_names.get(symbol).map(String::as_str) + } + + pub fn initial_state(&self) -> State{ + self.initial_state + } + + pub fn initial_stack(&self) -> Symbol{ + self.initial_stack + } + + pub fn final_states(&self) -> Option>{ + Some(self.final_states.as_ref()?.entries().filter(|&(_, f)| *f).map(|(s, _)| s)) + } + + pub fn states(&self) -> impl Iterator{ + self.state_names.entries().map(|s|(s.0, s.1.as_str())) + } + + pub fn symbols(&self) -> impl Iterator{ + self.symbol_names.entries().map(|s|(s.0, s.1.as_str())) + } + + pub fn transitions(&self) -> &StateSymbolMap>>{ + &self.transitions + } +} + #[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct NpdaState { pub state: State, @@ -120,13 +170,14 @@ impl Simulator { // ------ parser/semantics use crate::loader::{ - Context, DELTA_LOWER, GAMMA_UPPER, SIGMA_UPPER, Spanned, ast::{self, Symbol as Sym} + Context, DELTA_LOWER, GAMMA_UPPER, SIGMA_UPPER, Spanned, + ast::{self, Symbol as Sym}, }; impl Npda { pub fn load_from_ast<'a>( items: impl Iterator>>, - ctx: &mut Context<'a> + ctx: &mut Context<'a>, ) -> Option { let mut initial_state = None; let mut initial_stack = None; @@ -275,10 +326,7 @@ impl Npda { ctx.emit_error(format!("unknown item {name:?}, expected 'Q' | 'E' | '{SIGMA_UPPER}' | 'sigma' | 'F' | 'T' | '{GAMMA_UPPER}' | 'gamma' | 'I' | 'q0' | 'S' | 'z0'"), dest_s); } - TL::TransitionFunc( - S((S("d" | DELTA_LOWER | "delta", _), tuple), _), - list, - ) => { + TL::TransitionFunc(S((S("d" | DELTA_LOWER | "delta", _), tuple), _), list) => { let list = list.set_weak(); let Some((state, letter, stack_symbol)) = tuple.as_ref().expect_npda_transition_function(ctx) @@ -343,10 +391,7 @@ impl Npda { let ident = symbol.expect_ident(ctx)?; let Some(symbol) = stack_symbols.get(ident).copied() else { - ctx.emit_error( - "transition stack symbol not defined", - symbol.1, - ); + ctx.emit_error("transition stack symbol not defined", symbol.1); return None; }; Some(symbol) @@ -364,7 +409,9 @@ impl Npda { } TL::TransitionFunc(S((S(name, _), _), dest_s), _) => { ctx.emit_error( - format!("unknown function {name:?}, expected 'd' | 'delta' | '{DELTA_LOWER}'"), + format!( + "unknown function {name:?}, expected 'd' | 'delta' | '{DELTA_LOWER}'" + ), dest_s, ); } @@ -449,7 +496,7 @@ impl Npda { } } - if ctx.contains_errors(){ + if ctx.contains_errors() { return None; } diff --git a/web/Cargo.toml b/web/Cargo.toml index e566b50..599e65e 100644 --- a/web/Cargo.toml +++ b/web/Cargo.toml @@ -7,6 +7,9 @@ edition = "2024" crate-type = ["cdylib", "rlib"] [dependencies] +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } + automata = {path=".."} console_error_panic_hook = "0.1.7" wasm-bindgen = "*" diff --git a/web/root/src/editor.ts b/web/root/src/editor.ts index 27fdd82..8db2a28 100644 --- a/web/root/src/editor.ts +++ b/web/root/src/editor.ts @@ -18,6 +18,9 @@ import { closeBrackets } from "npm:@codemirror/autocomplete"; import wasm from "./wasm.ts" +import { terminalPlugin } from "./terminal.ts"; + +import { setAutomaton } from "./visualizer.ts"; function tokenize(text: string) { @@ -35,7 +38,7 @@ function compile(text: string): wasm.CompileResult { } catch (e) { console.log(e); // @ts-expect-error wasm defines extra cleanup - return {log: [], log_formatted: ""}; + return {log: [], log_formatted: "", graph: ""}; } } @@ -72,7 +75,11 @@ function sevRank(sev: string) { function buildAnalysis(text: string, doc: Text) { const tokens = tokenize(text); - const { log, log_formatted } = compile(text); + const { log, log_formatted, graph } = compile(text); + + if (graph){ + setAutomaton(JSON.parse(graph)) + } // Build ONE Decoration set: syntax + diagnostics const marks = []; @@ -108,7 +115,7 @@ function buildAnalysis(text: string, doc: Text) { return { tokens, log, log_formatted, deco }; } -const analysisField = StateField.define({ +export const analysisField = StateField.define({ create(state) { const text = state.doc.toString(); return buildAnalysis(text, state.doc); @@ -158,109 +165,6 @@ const diagHover = hoverTooltip((view, pos) => { }); -function escapeHtml(s: string) { - return s - .replace(/&/g, "&") - .replace(//g, ">"); -} - - -function ansiToHtml(input: string) { - // deno-lint-ignore no-control-regex - const ESC_RE = /\x1b\[([0-9;]*)m/g; - - let out = ""; - let lastIndex = 0; - - // current style state - let fg: number|null = null; // e.g. 31, 92 - let bg: number|null = null; // e.g. 41 - let bold = false; - let dim = false; - - function openSpanIfNeeded(text: string) { - if (text.length === 0) return ""; - const classes = []; - if (bold) classes.push("ansi-bold"); - if (dim) classes.push("ansi-dim"); - if (fg != null) classes.push(`ansi-fg-${fg}`); - if (bg != null) classes.push(`ansi-bg-${bg}`); - if (classes.length === 0) return escapeHtml(text); - return `${escapeHtml(text)}`; - } - - function applyCodes(codes: string[]) { - if (codes.length === 0) codes = ["0"]; - for (const c of codes) { - const code = Number(c); - if (Number.isNaN(code)) continue; - - if (code === 0) { - fg = null; bg = null; bold = false; dim = false; - } else if (code === 1) { - bold = true; - } else if (code === 2) { - dim = true; - } else if (code === 22) { - bold = false; dim = false; - } else if (code === 39) { - fg = null; - } else if (code === 49) { - bg = null; - } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { - fg = code; - } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { - bg = code; - } - } - } - - let m; - while ((m = ESC_RE.exec(input)) !== null) { - const chunk = input.slice(lastIndex, m.index); - out += openSpanIfNeeded(chunk); - - const codes = m[1] ? m[1].split(";") : []; - applyCodes(codes); - - lastIndex = ESC_RE.lastIndex; - } - - out += openSpanIfNeeded(input.slice(lastIndex)); - return out; -} - - // @ts-expect-error bad library -function formatTerminal(view) { - const term = document.getElementById("terminal"); - if (!term) return; - - const { log, log_formatted } = view.state.field(analysisField); - - let s = ""; - s += `\x1b[90m[compile]\x1b[0m ${log.length} diagnostics\n`; - - term.innerHTML = ansiToHtml(s + log_formatted); -} - -const terminalPlugin = ViewPlugin.fromClass( - class { - - // @ts-expect-error bad library - constructor(view) { - // @ts-expect-error bad library - this.view = view; - formatTerminal(view); - } - // @ts-expect-error bad library - update(update) { - if (update.docChanged) formatTerminal(update.view); - } - } -); - - const initialText = `type=NPDA Q = {q0, q1} // states E = {a, b} // alphabet diff --git a/web/root/src/terminal.ts b/web/root/src/terminal.ts new file mode 100644 index 0000000..fd83210 --- /dev/null +++ b/web/root/src/terminal.ts @@ -0,0 +1,109 @@ +// deno-lint-ignore-file + +import { + ViewPlugin, +} from "npm:@codemirror/view"; + +import { analysisField } from "./editor.ts"; + +function escapeHtml(s: string) { + return s + .replace(/&/g, "&") + .replace(//g, ">"); +} + + +function ansiToHtml(input: string) { + // deno-lint-ignore no-control-regex + const ESC_RE = /\x1b\[([0-9;]*)m/g; + + let out = ""; + let lastIndex = 0; + + // current style state + let fg: number|null = null; // e.g. 31, 92 + let bg: number|null = null; // e.g. 41 + let bold = false; + let dim = false; + + function openSpanIfNeeded(text: string) { + if (text.length === 0) return ""; + const classes = []; + if (bold) classes.push("ansi-bold"); + if (dim) classes.push("ansi-dim"); + if (fg != null) classes.push(`ansi-fg-${fg}`); + if (bg != null) classes.push(`ansi-bg-${bg}`); + if (classes.length === 0) return escapeHtml(text); + return `${escapeHtml(text)}`; + } + + function applyCodes(codes: string[]) { + if (codes.length === 0) codes = ["0"]; + for (const c of codes) { + const code = Number(c); + if (Number.isNaN(code)) continue; + + if (code === 0) { + fg = null; bg = null; bold = false; dim = false; + } else if (code === 1) { + bold = true; + } else if (code === 2) { + dim = true; + } else if (code === 22) { + bold = false; dim = false; + } else if (code === 39) { + fg = null; + } else if (code === 49) { + bg = null; + } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { + fg = code; + } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { + bg = code; + } + } + } + + let m; + while ((m = ESC_RE.exec(input)) !== null) { + const chunk = input.slice(lastIndex, m.index); + out += openSpanIfNeeded(chunk); + + const codes = m[1] ? m[1].split(";") : []; + applyCodes(codes); + + lastIndex = ESC_RE.lastIndex; + } + + out += openSpanIfNeeded(input.slice(lastIndex)); + return out; +} + + // @ts-expect-error bad library +function formatTerminal(view) { + const term = document.getElementById("terminal"); + if (!term) return; + + const { log, log_formatted } = view.state.field(analysisField); + + let s = ""; + s += `\x1b[90m[compile]\x1b[0m ${log.length} diagnostics\n`; + + term.innerHTML = ansiToHtml(s + log_formatted); +} + +export const terminalPlugin = ViewPlugin.fromClass( + class { + + // @ts-expect-error bad library + constructor(view) { + // @ts-expect-error bad library + this.view = view; + formatTerminal(view); + } + // @ts-expect-error bad library + update(update) { + if (update.docChanged) formatTerminal(update.view); + } + } +); \ No newline at end of file diff --git a/web/root/src/theme.ts b/web/root/src/theme.ts index b55bc10..1fd5944 100644 --- a/web/root/src/theme.ts +++ b/web/root/src/theme.ts @@ -1,4 +1,4 @@ -import { network } from "./visualizer.ts"; +import { invalidateGraphThemeCache, network } from "./visualizer.ts"; function cssVar(name: string, fallback = ""): string { return getComputedStyle(document.documentElement) @@ -49,27 +49,39 @@ globalThis.window.matchMedia?.("(prefers-color-scheme: light)") setTheme(getPreferredTheme()); }); -export function applyGraphTheme() { +function applyGraphTheme() { + invalidateGraphThemeCache(); + network.setOptions({ nodes: { - color: { - background: cssVar("--graph-node-bg"), - border: cssVar("--graph-node-border"), - highlight: { - background: cssVar("--graph-node-active-bg"), - border: cssVar("--graph-node-active-border"), - }, - }, font: { color: cssVar("--graph-node-text"), }, }, edges: { + labelHighlightBold: true, + font: { + align: "middle", + color: cssVar("--fg-0"), + strokeColor: cssVar("--bg-0"), + bold: { + color: cssVar("--fg-1"), + mod: '' + }, + }, color: { color: cssVar("--graph-edge"), highlight: cssVar("--graph-edge-active"), hover: cssVar("--graph-edge-hover"), }, + shadow: { + enabled: true, + color: cssVar("--bg-2") + } }, }); } + + + + diff --git a/web/root/src/visualizer.ts b/web/root/src/visualizer.ts index 5a64693..6fd2e68 100644 --- a/web/root/src/visualizer.ts +++ b/web/root/src/visualizer.ts @@ -6,101 +6,79 @@ import * as vis from "npm:vis-network/standalone"; export const nodes = new vis.DataSet(); export const edges = new vis.DataSet(); -const automaton = { - states: ["q0", "q1"], - initialState: "q0", - acceptStates: ["q1"], - - transitions: [ - { - from: "q0", - to: "q0", - label: "ε, z0 → A z0\n", - }, - { - from: "q0", - to: "q0", - label: "ε, z0 → B z0", - }, - { - from: "q0", - to: "q1", - label: "ε, z0 → z0", - }, - { - from: "q1", - to: "q1", - label: "a, A → ε", - }, - { - from: "q1", - to: "q1", - label: "b, B → ε", - }, - ], +type StateId = string; +type GraphDef = { + initial: StateId; + final: StateId[]; + states: StateId[]; + transitions: Record; }; -function renderNode({ - ctx, - id, - x, - y, - state: { selected, hover }, - style, - label, -}: any) { - return { - drawNode() { - ctx.save(); - const r = style.size; +let automaton: GraphDef = { + initial: "", + final: [], + states: [], + transitions: {}, +}; - ctx.beginPath(); - ctx.arc(x, y, r, 0, 2 * Math.PI); - ctx.fillStyle = "red"; - ctx.fill(); - ctx.lineWidth = 4; - ctx.strokeStyle = "blue"; - ctx.stroke(); - - ctx.fillStyle = "black"; - ctx.textAlign = "center"; - ctx.fillText(label, x, y, r); - - ctx.textAlign = "center"; - ctx.strokeStyle = "white"; - ctx.fillStyle = "black"; - let cy = y - (r + 10); - for (const part of "meow[]\nbeeep".split("\n").reverse()) { - const metrics = ctx.measureText(part); - cy -= metrics.actualBoundingBoxAscent + - metrics.actualBoundingBoxDescent; - ctx.strokeText(part, x, cy); - ctx.fillText(part, x, cy); - } - - ctx.restore(); - }, - nodeDimensions: { width: 20, height: 20 }, - }; -} - -// Populate nodes -for (const state of automaton.states) { - nodes.add({ - id: state, - label: state, +export function clearAutomaton() { + setAutomaton({ + initial: "", + final: [], + states: [], + transitions: {}, }); } -// Populate edges -automaton.transitions.forEach((t, i) => { - edges.add({ - id: `e${i}`, - from: t.from, - to: t.to, - label: t.label, - }); -}); +export function setAutomaton(auto: GraphDef) { + automaton = auto; + // Populate nodes + for (const state of automaton.states) { + if (nodes.get(state)) { + nodes.update({ + id: state, + label: state, + }); + } else { + nodes.add({ + id: state, + label: state, + }); + } + } + + // Populate edges + for (const [k, v] of Object.entries(automaton.transitions)) { + const to_from = k.split("#"); + if (edges.get(k)) { + edges.update({ + id: k, + from: to_from[0], + to: to_from[1], + label: v, + }); + } else { + edges.add({ + id: k, + from: to_from[0], + to: to_from[1], + label: v, + }); + } + } + + for (const edge_id of edges.getIds()){ + if (auto.transitions[edge_id as string] === undefined){ + edges.remove(edge_id) + } + } + + for (const node_id of nodes.getIds()){ + if (!auto.states.includes(node_id as string)){ + nodes.remove(node_id) + } + } +} function chosen_edge( _: vis.ChosenNodeValues, @@ -108,7 +86,6 @@ function chosen_edge( selected: boolean, hovered: boolean, ) { - console.log("edge", id, selected, hovered); } function chosen_node( @@ -117,7 +94,6 @@ function chosen_node( selected: boolean, hovered: boolean, ) { - console.log("node", id, selected, hovered); } export const network: vis.Network = createGraph(); @@ -154,18 +130,19 @@ function createGraph(): vis.Network { border: "#79c0ff", highlight: { background: "#388bfd", border: "#a5d6ff" }, }, - // @ts-expect-error bad library - chosen: { - node: chosen_node, - }, + // // @ts-expect-error bad library + // chosen: { + // node: chosen_node, + // }, shape: "custom", + // @ts-expect-error bad library ctxRenderer: renderNode, size: 18, }, edges: { chosen: { - // @ts-expect-error bad library - edge: chosen_edge, + // // @ts-expect-error bad library + // edge: chosen_edge, }, arrowStrikethrough: false, font: { align: "middle", color: "#000000ff" }, @@ -188,4 +165,295 @@ function createGraph(): vis.Network { }); return network; -} \ No newline at end of file +} + +export type GraphTheme = { + bg_0: string; + bg_1: string; + bg_2: string; + fg_0: string; + + anchor: string; + selected: string; + node: string; + current: string; + edge: string; + glow: string; +}; + +let _graphTheme: GraphTheme | null = null; + +export function invalidateGraphThemeCache() { + _graphTheme = null; +} + +function getGraphTheme(): GraphTheme { + function cssVar(name: string, fallback = ""): string { + return getComputedStyle(document.documentElement) + .getPropertyValue(name) + .trim() || fallback; + } + + if (_graphTheme) return _graphTheme; + + _graphTheme = { + bg_0: cssVar("--bg-0"), + bg_1: cssVar("--bg-1"), + bg_2: cssVar("--bg-2"), + fg_0: cssVar("--fg-0"), + + selected: cssVar("--bg-2"), + + node: cssVar("--focus"), + current: cssVar("--success"), + + anchor: cssVar("--warning"), + + edge: cssVar("--graph-edge", "rgba(201,209,217,0.55)"), + + glow: cssVar("--accent", "#79c0ff"), + }; + + return _graphTheme; +} + +function renderNode({ + ctx, + id, + x, + y, + state: { selected, hover }, + style, + label, +}: any) { + return { + drawNode() { + // @ts-expect-error bad library + const node: vis.Node = nodes.get(id)!; + + const t = getGraphTheme(); + const r = Math.max(14, style?.size ?? 18); + + const isInitial = id === "q0"; + const isFinal = id === "q1"; // <-- change if your schema differs + const isActive = id === "q0"; // <-- change if your schema differs + + const fill = selected ? t.glow : hover ? t.bg_1 : t.bg_0; + const stroke = isActive ? t.current : t.node; + + const emphasis = (selected ? 1 : 0) + (hover ? 0.6 : 0); + + const outerW = isFinal ? 3.5 : 3; + const innerW = 2; + + ctx.save(); + + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + + ctx.lineWidth = outerW + emphasis; + ctx.strokeStyle = stroke; + ctx.fillStyle = fill; + ctx.beginPath(); + ctx.arc(x, y, r - ctx.lineWidth * 0.5, 0, Math.PI * 2); + ctx.stroke(); + ctx.fill(); + + if (isFinal) { + ctx.lineWidth = innerW; + ctx.strokeStyle = stroke; + ctx.beginPath(); + ctx.arc(x, y, r - 7, 0, Math.PI * 2); + ctx.stroke(); + } + + ctx.lineWidth = 2; + ctx.fillStyle = t.fg_0; + ctx.strokeStyle = t.bg_0; + ctx.strokeText(label, x, y); + ctx.fillText(label, x, y); + + if (isInitial) { + drawInitialArrow(ctx, x, y, r, t.edge); + } + + // const badgeText = "bleh\npee"; + // if (badgeText) { + // const lines = badgeText.split("\n").slice(0, 3); + // const padX = 8; + // const padY = 6; + // const lineH = 14; + + // let w = 0; + // for (const ln of lines) w = Math.max(w, ctx.measureText(ln).width); + // const boxW = w + padX * 2; + // const boxH = lines.length * lineH + padY * 2; + + // const bx = x - boxW / 2; + // const by = y - r - 12 - boxH; + + // ctx.fillStyle = t.bg_1; + // ctx.strokeStyle = t.bg_2; + // ctx.lineWidth = 1; + // roundRect(ctx, bx, by, boxW, boxH, 8); + // ctx.fill(); + // ctx.stroke(); + + // ctx.fillStyle = t.fg_0; + // ctx.textBaseline = "top"; + // for (let i = 0; i < lines.length; i++) { + // ctx.fillText(lines[i], x, by + padY + i * lineH); + // } + // } + + const physicsOff = node.physics === false; + if (physicsOff) { + drawPinIndicator(ctx, x, y, r, t.anchor); + } + + ctx.restore(); + }, + }; +} + +function drawInitialArrow( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + color: string, +) { + const len = Math.max(14, r * 0.95); // arrow length + const head = Math.max(7, r * 0.32); // arrow head size + const lineW = Math.max(2, r * 0.12); // stroke width + const gap = 4; // distance from node edge + + // Direction: from top-left → center (45° down-right) + const dx = Math.SQRT1_2; + const dy = Math.SQRT1_2; + + // Tip position (just outside node) + const tipX = x - dx * (r + gap); + const tipY = y - dy * (r + gap); + + // Tail start + const tailX = tipX - dx * len; + const tailY = tipY - dy * len; + + // Perpendicular for arrow head + const px = -dy; + const py = dx; + + ctx.save(); + + ctx.lineCap = "round"; + ctx.lineJoin = "round"; + ctx.lineWidth = lineW; + ctx.strokeStyle = color; + ctx.fillStyle = color; + + // Shaft + ctx.beginPath(); + ctx.moveTo(tailX, tailY); + ctx.lineTo( + tipX - dx * head * 0.6, + tipY - dy * head * 0.6, + ); + ctx.stroke(); + + // Head + ctx.beginPath(); + ctx.moveTo(tipX, tipY); + ctx.lineTo( + tipX - dx * head + px * head * 0.7, + tipY - dy * head + py * head * 0.7, + ); + ctx.lineTo( + tipX - dx * head - px * head * 0.7, + tipY - dy * head - py * head * 0.7, + ); + ctx.closePath(); + ctx.fill(); + + ctx.restore(); +} + +function drawPinIndicator( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + r: number, + color: string, +) { + const size = Math.max(7, Math.round(r * 0.28)); + const ox = x + r - size * 0.55; + const oy = y + r - size * 0.55; + + const stroke = color; + const fill = "rgba(0,0,0,0)"; + + ctx.save(); + + ctx.shadowColor = "rgba(0,0,0,0)"; + ctx.shadowBlur = 6; + ctx.shadowOffsetX = 0; + ctx.shadowOffsetY = 2; + + // Pin head (circle) + ctx.beginPath(); + ctx.arc(ox, oy, size * 0.55, 0, Math.PI * 2); + ctx.fillStyle = fill; + ctx.fill(); + + // Pin stem (triangle-ish) + ctx.beginPath(); + ctx.moveTo(ox, oy + size * 0.25); + ctx.lineTo(ox - size * 0.35, oy + size * 0.95); + ctx.lineTo(ox + size * 0.35, oy + size * 0.95); + ctx.closePath(); + ctx.fillStyle = fill; + ctx.fill(); + + // Outline + ctx.shadowBlur = 0; + ctx.lineWidth = Math.max(1.25, Math.round(r * 0.06)); + ctx.strokeStyle = stroke; + + ctx.beginPath(); + ctx.arc(ox, oy, size * 0.55, 0, Math.PI * 2); + ctx.stroke(); + + ctx.beginPath(); + ctx.moveTo(ox, oy + size * 0.25); + ctx.lineTo(ox - size * 0.35, oy + size * 0.95); + ctx.lineTo(ox + size * 0.35, oy + size * 0.95); + ctx.closePath(); + ctx.stroke(); + + // Inner dot + ctx.beginPath(); + ctx.arc(ox, oy, size * 0.18, 0, Math.PI * 2); + ctx.fillStyle = stroke; + ctx.fill(); + + ctx.restore(); +} + +// Small helper for rounded rectangles +function roundRect( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +) { + const rr = Math.min(r, w / 2, h / 2); + ctx.beginPath(); + ctx.moveTo(x + rr, y); + ctx.arcTo(x + w, y, x + w, y + h, rr); + ctx.arcTo(x + w, y + h, x, y + h, rr); + ctx.arcTo(x, y + h, x, y, rr); + ctx.arcTo(x, y, x + w, y, rr); + ctx.closePath(); +} diff --git a/web/root/style/themes.scss b/web/root/style/themes.scss index 4a6de23..58710a4 100644 --- a/web/root/style/themes.scss +++ b/web/root/style/themes.scss @@ -56,14 +56,14 @@ --graph-node-bg: #1f6feb; --graph-node-border: #388bfd; - --graph-node-text: #e6edf3; + --graph-node-text: var(--fg-0); --graph-node-active-bg: #79c0ff; - --graph-node-active-border: #a5d6ff; + --graph-node-active-border: #ff0000; --graph-edge: rgba(201, 209, 217, 0.55); - --graph-edge-hover: #79c0ff; - --graph-edge-active: #a5d6ff; + --graph-edge-hover: rgba(201, 209, 217, 0.864); + --graph-edge-active: var(--accent); --ansi-fg-30: #0b0f14; /* black */ diff --git a/web/src/lib.rs b/web/src/lib.rs index f0ae5bb..1075cdb 100644 --- a/web/src/lib.rs +++ b/web/src/lib.rs @@ -1,7 +1,8 @@ -use automata::{ - loader::{self, Context, Span, Spanned, lexer::Lexer}, -}; +use std::collections::HashMap; +use automata::loader::{self, Context, Span, Spanned, lexer::Lexer}; + +use serde::Serialize; use wasm_bindgen::prelude::wasm_bindgen; #[wasm_bindgen] @@ -148,24 +149,84 @@ pub struct CompileLog { pub end: Option, } + +#[derive(Serialize, Debug)] +pub struct Graph<'a> { + initial: &'a str, + final_states: Vec<&'a str>, + states: Vec<&'a str>, + transitions: HashMap, +} + #[wasm_bindgen(getter_with_clone)] pub struct CompileResult { pub log: Vec, pub log_formatted: String, + pub graph: Option, } #[wasm_bindgen] pub fn compile(input: &str) -> CompileResult { let mut ctx = Context::new(input); - _ = automata::loader::parse_universal(&mut ctx); - + let result = automata::loader::parse_universal(&mut ctx); + + let graph = if let Some(result) = result { + match result { + loader::Machine::Npda(npda) => { + let mut transitions = HashMap::new(); + for ((from, symbol), to_transitions) in npda.transitions().entries(){ + let from = npda.get_state_name(from).unwrap_or(""); + let symbol = npda.get_symbol_name(symbol).unwrap_or(""); + for (char, to) in to_transitions.entries(){ + for to in to{ + let to_state = npda.get_state_name(to.state()).unwrap_or(""); + let string: &mut String = transitions.entry(format!("{from}#{to_state}")).or_default(); + if !string.is_empty(){ + string.push('\n'); + } + let char = char.unwrap_or('ε'); + let stack = to.stack().iter().map(|s|npda.get_symbol_name(*s).unwrap_or("")).fold(String::new(), |mut s, b|{ + if !s.is_empty(){ + s.push_str(", "); + } + s.push_str(b); + s + }); + write!(string, "{char}, {symbol} -> [{stack}]").unwrap(); + + } + } + } + let graph = Graph { + states: npda.states().map(|(_, n)| n).collect(), + initial: npda + .get_state_name(npda.initial_state()) + .unwrap_or(""), + final_states: npda + .final_states() + .map(|i| { + i.map(|s| npda.get_state_name(s).unwrap_or("")) + .collect::>() + }) + .unwrap_or_default(), + transitions + }; + + Some(serde_json::to_string(&graph).unwrap()) + } + } + } else { + None + }; + use std::fmt::Write; let log_formatted = ctx.logs_display().fold(String::new(), |mut s, e| { write!(&mut s, "{e}").unwrap(); s }); - let log = ctx.into_logs() + let log = ctx + .into_logs() .into_entries() .map(|e| CompileLog { level: match e.level { @@ -183,5 +244,9 @@ pub fn compile(input: &str) -> CompileResult { }) .collect(); - CompileResult { log, log_formatted } + CompileResult { + log, + log_formatted, + graph, + } } diff --git a/web/tools/dev.ts b/web/tools/dev.ts index b67c388..7e1efae 100644 --- a/web/tools/dev.ts +++ b/web/tools/dev.ts @@ -46,7 +46,7 @@ await startServer(); console.log("👀 watching for changes…"); -const watcher = Deno.watchFs(["root", "../src"]); +const watcher = Deno.watchFs(["root", "src"]); for await (const event of watcher) { if ( event.kind === "modify" ||