diff --git a/README.md b/README.md index f0b1314..37c96a3 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,10 @@ VS Code extension gives it. Click one and it fills the editor, read-only — a tree coloured as the extension colours trees, node names green, rewrites bold, offsets blue. Hover over a node to see the text it covers; go to definition (F12) opens the rule that built it, or selects a token's text in the input. Tick -**Debug** to also keep the tree after every pass. Trees can be large, so the run server keeps +**Debug** — the engine's `-DEV` — to also keep the tree after every pass: each pass in the +sequence then carries two buttons, its parse tree and what its rules matched (the input with +`<<>>` and `((( what one matched )))` marked, as the extension's Display +Matched Rules shows it). Trees can be large, so the run server keeps them for half an hour and sends one only when it is opened. Without the run server the studio still edits — colouring, hover, go to definition, completion, rename and problems all run in the browser, the language features in the NLP++ language server from `vscode-nlp` in a Web Worker. diff --git a/analyzer-views/README.md b/analyzer-views/README.md index e822608..b6ec160 100644 --- a/analyzer-views/README.md +++ b/analyzer-views/README.md @@ -29,7 +29,7 @@ sidebar.append(sequence, kb); | Element | Lists | Extension view | |---|---|---| -| `` | The passes in `analyzer.seq`, numbered as the engine numbers them | Analyzer sequence | +| `` | The passes in `analyzer.seq`, numbered as the engine numbers them, each offering the tree and the rule matches a `-DEV` run left | Analyzer sequence | | `` | The `.dict` and `.kbb` files under `kb/` | KB | | `` | The files a run wrote, by name | Output files | | `` | A run's parse trees: `final.tree`, then the tree after each pass | Output files (trees) | @@ -52,6 +52,26 @@ Every list element takes: `` also takes `sequence`, the text of `spec/analyzer.seq`. Its `passes` property returns the passes it read from that text. +With `-DEV` on, the engine writes the parse tree after every pass. Hand those trees to +`` as `trees` and each pass that has one gets two buttons, as the extension's +sequence view does: its **parse tree**, and **what its rules matched**. Both raise an event +with the tree's name and the pass number; the page fetches that tree and shows it, or turns +it into marked-up text with `ruleMatches()`: + +```js +sequence.trees = [{ name: "ana003.tree", pass: 3, passName: "greeting" }]; +sequence.addEventListener("nlp-open-tree", (e) => openTree(e.detail.path)); +sequence.addEventListener("nlp-open-matches", async (e) => { + const tree = await fetchTree(e.detail.path); // the pass's tree, from wherever the run kept it + show(ruleMatches(tree, inputText), "txxt"); // the input with every match marked +}); +``` + +`ruleMatches(treeText, inputText)` returns the input with `<<>>` around what a rule +built and `((( fired )))` around what it matched without building — the `.txxt` file the +extension writes, which `` colors. `{ builtOnly: true }` marks +only what built a node, and `matchesIn()` gives the spans themselves. + `` takes `trees`, as `{ name, pass, passName, bytes }` objects where `pass` is `null` for `final.tree`, and `skipped`, the names of trees too large to keep: @@ -132,6 +152,8 @@ follow the same light and dark choice. extension's token colors. - `problemsIn`, `problemsInLog`, `problemWhere` and `logLines` read the engine's `err.log` and `make_ana.log`. +- `ruleMatches`, `matchesIn` and `matchCount` read a pass's tree for what its rules matched + in the text. - `filledFields` and `readOutput` read `output.json`. ## Develop diff --git a/analyzer-views/package.json b/analyzer-views/package.json index 5242e3e..1d19b79 100644 --- a/analyzer-views/package.json +++ b/analyzer-views/package.json @@ -1,7 +1,7 @@ { "name": "@visualtext/analyzer-views", "private": true, - "version": "0.4.0", + "version": "0.5.0", "description": "An NLP++ analyzer's sequence, knowledge base, run results, values, code and log, shown the way the NLP++ extension for VS Code shows them, for any web page.", "type": "module", "repository": { diff --git a/analyzer-views/src/elements.ts b/analyzer-views/src/elements.ts index d0e3a83..3659f79 100644 --- a/analyzer-views/src/elements.ts +++ b/analyzer-views/src/elements.ts @@ -19,7 +19,8 @@ // Properties: files paths, or { path, bytes } to show sizes (all but ) // sequence the text of spec/analyzer.seq () // trees { name, pass, passName, bytes } and skipped, names too large -// to keep () +// to keep (); on , the trees a -DEV run +// wrote, which put a tree and a rule-matches button on their pass // selected the path open now, drawn as selected // changed paths with unsaved edits, marked, with a revert button when // the element has the `revertable` attribute @@ -27,6 +28,10 @@ // revertable offer a revert button on a changed file // Events: nlp-open { path } a file was clicked (a tree by its name) // nlp-revert { path } its revert button was clicked +// nlp-open-tree, nlp-open-matches { path, pass } a pass's tree, or what its +// rules matched, was asked for: the tree file's name and its pass. +// The page fetches that tree; ruleMatches() (matches.ts) turns it +// into the marked-up text. // // takes text, and a path or a language attribute to pick the grammar; // takes problems and lines; takes output (see their classes). @@ -46,6 +51,15 @@ import { type Pass, SEQUENCE_FILE, passes, passLabel, passTooltip } from "./sequ export interface OpenDetail { path: string; line?: number; // : the line the problem is on + pass?: number; // : the pass whose tree or matches were asked for +} + +// A button after a row's name: the tree a pass wrote, and what its rules matched. +interface RowAction { + event: "nlp-open-tree" | "nlp-open-matches"; + icon: IconName; + title: string; + detail: OpenDetail; } interface Row { @@ -56,6 +70,7 @@ interface Row { size?: string; note?: string; // a line under the name classes?: string[]; + actions?: RowAction[]; } // A property set on an element before it was defined sits on the instance and hides the @@ -155,6 +170,17 @@ abstract class AnalyzerList extends Base { size.textContent = row.size; line.append(size); } + for (const action of row.actions ?? []) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `nlp-act ${action.icon}`; + button.title = action.title; + button.setAttribute("aria-label", action.title); + button.append(iconElement(action.icon)); + button.addEventListener("click", () => this.dispatchEvent( + new CustomEvent(action.event, { detail: action.detail, bubbles: true, composed: true }))); + line.append(button); + } const path = row.path; if (path && name instanceof HTMLButtonElement) { name.type = "button"; @@ -207,6 +233,7 @@ abstract class FileList extends AnalyzerList { export class NlpSequence extends FileList { #sequence = ""; #passes: Pass[] | null = null; + #trees = new Map(); protected readonly defaultHeading = "Analyzer Sequence"; @@ -220,6 +247,21 @@ export class NlpSequence extends FileList { this.render(); } + // The trees a run kept, one per pass -- what the engine writes with -DEV on (the + // studio's Debug, the console's Log files). A pass with one gets two buttons: its + // tree, and what its rules matched. final.tree belongs to no pass and is ignored + // here; lists it. + get trees(): TreeFile[] { + return [...this.#trees.values()]; + } + set trees(trees: Iterable | null | undefined) { + this.#trees = new Map(); + for (const tree of trees ?? []) { + if (tree.pass !== null && tree.pass !== undefined) this.#trees.set(tree.pass, tree); + } + this.render(); + } + // The passes as listed: read from `sequence` against `files`. get passes(): Pass[] { return this.#passes ??= passes(this.#sequence, this.files.map((f) => f.path)); @@ -244,10 +286,23 @@ export class NlpSequence extends FileList { icon: passIcon(p.kind, p.active), title: p.active ? says : `${says} — switched off`, classes, + actions: this.actions(p), }); } return rows; } + + // The tree the run wrote after this pass, and the text it matched in it. + private actions(pass: Pass): RowAction[] { + const tree = pass.n === null ? undefined : this.#trees.get(pass.n); + if (!tree) return []; + const detail: OpenDetail = { path: tree.name, pass: pass.n! }; + const what = `pass ${pass.n} (${passLabel(pass)})`; + return [ + { event: "nlp-open-tree", icon: "tree", title: `Open the parse tree after ${what}`, detail }, + { event: "nlp-open-matches", icon: "matches", title: `Open what ${what} matched in the text`, detail }, + ]; + } } export class NlpKnowledgeBase extends FileList { @@ -621,5 +676,7 @@ declare global { interface HTMLElementEventMap { "nlp-open": CustomEvent; "nlp-revert": CustomEvent; + "nlp-open-tree": CustomEvent; + "nlp-open-matches": CustomEvent; } } diff --git a/analyzer-views/src/icons.ts b/analyzer-views/src/icons.ts index 08eee92..fa490d4 100644 --- a/analyzer-views/src/icons.ts +++ b/analyzer-views/src/icons.ts @@ -10,7 +10,8 @@ // both themes and the greyed state is the same shape rather than a second drawing. export type IconName = - "dna" | "dna-off" | "dnar" | "dot" | "folder" | "folder-off" | "dict" | "kbb" | "file" | "tree" | "json" | "log" | "blank"; + "dna" | "dna-off" | "dnar" | "dot" | "folder" | "folder-off" | "dict" | "kbb" | "file" | "tree" | "matches" + | "json" | "log" | "blank"; // dna.svg: the double helix, drawn as two strands. const HELIX = ``; @@ -36,6 +37,8 @@ const SVG: Record, string> = { kbb: ``, // file.svg file: ``, + // symbol-keyword.svg: the lines of text the extension's "Display Matched Rules" opens. + matches: ``, // tree.svg: the branching parse tree. tree: ``, // json.svg: the pair of braces. diff --git a/analyzer-views/src/matches.ts b/analyzer-views/src/matches.ts new file mode 100644 index 0000000..29808e2 --- /dev/null +++ b/analyzer-views/src/matches.ts @@ -0,0 +1,101 @@ +// What a pass's rules matched, read off the tree it wrote: the input text with every +// match marked, as the NLP++ extension for VS Code's "Display Matched Rules" writes it +// (vscode-nlp treeFile.ts parseFireds and writeFiredText, the .txxt file). +// +// The engine writes no such file. It writes a tree after every pass when it runs with +// -DEV -- output/ana003.tree is the tree as pass 3 left it -- and every node that a rule +// matched carries the "fired" flag, with "blt" on the ones a rule built. The marked text +// is those spans put back over the input: +// +// I live in <<>>, in (((California))). +// +// <<< >>> a rule built a node here +// ((( ))) a rule matched here without building one +// +// which is the .txxt grammar's own markers, so the result colours as NLP++ colours a +// rule-match file. +// +// OUTERMOST WINS. The dump is depth-first, parents before children, so a match inside one +// already marked is skipped rather than nested -- as the extension does it. Nesting would +// need markers the .txxt grammar does not have. +// +// OFFSETS. A node's ustart/uend count code points, inclusive; JavaScript counts UTF-16 +// units, which differ for everything past the Basic Multilingual Plane. They go through +// utf16Offsets, so a match after an emoji still covers the words it matched. +// +// PURE: no DOM, so a server or a test can use it as well as a page. + +// One rule match, as [from, to) in UTF-16 units of the input text. +export interface RuleMatch { + from: number; + to: number; + built: boolean; // a rule built a node here, rather than only matching +} + +// [start,end,ustart,uend,pass,ruleline,type<,flags>]. The name is +// non-greedy, so the token for an open bracket in the text keeps "[" as its name. +const LINE = /^(\s*)(.*?)\s+\[([^\]]*)\]\s*$/; + +export interface MatchOptions { + // Only the matches that built a node -- the extension's "Display Built Only". + builtOnly?: boolean; +} + +// offsets[c] is where code point c starts in UTF-16 units; the last entry is the length. +export function utf16Offsets(text: string): number[] { + const out: number[] = []; + let unit = 0; + for (const ch of text) { + out.push(unit); + unit += ch.length; + } + out.push(unit); + return out; +} + +// The matches in one pass's tree, over `text` -- the input exactly as it was run. +export function matchesIn(tree: string | null | undefined, text: string, options: MatchOptions = {}): RuleMatch[] { + const offsets = utf16Offsets(text); + const last = offsets.length - 1; + const found: RuleMatch[] = []; + let covered = -1; // the last code point already inside a match + for (const line of String(tree ?? "").split(/\r?\n/)) { + const m = LINE.exec(line); + if (!m) continue; + const fields = m[3]; + const cut = fields.indexOf("("); // attributes, when a dump has them + const parts = (cut >= 0 ? fields.slice(0, cut) : fields).split(",").map((p) => p.trim()).filter(Boolean); + if (parts.length < 7) continue; + const flags = new Set(parts.slice(7)); + if (!flags.has("fired")) continue; + const built = flags.has("blt"); + if (options.builtOnly && !built) continue; + const ustart = Number.parseInt(parts[2], 10); + const uend = Number.parseInt(parts[3], 10); + if (!Number.isFinite(ustart) || !Number.isFinite(uend)) continue; + if (uend <= covered || ustart < 0 || ustart > last || uend < ustart) continue; + covered = uend; + found.push({ from: offsets[ustart], to: offsets[Math.min(uend + 1, last)], built }); + } + return found; +} + +// The input text with the pass's matches marked: what the extension opens as +// output/ana003.txxt. Text the pass matched nothing in comes back unchanged. +export function ruleMatches(tree: string | null | undefined, text: string, options: MatchOptions = {}): string { + let out = ""; + let at = 0; + for (const match of matchesIn(tree, text, options)) { + const [open, close] = match.built ? ["<<<", ">>>"] : ["(((", ")))"]; + out += text.slice(at, match.from) + open + text.slice(match.from, match.to) + close; + at = match.to; + } + return out + text.slice(at); +} + +// How many matches a pass made, for a line under its name: "3 matches, 2 built". +export function matchCount(matches: readonly RuleMatch[]): string { + const built = matches.filter((m) => m.built).length; + const n = `${matches.length} match${matches.length === 1 ? "" : "es"}`; + return built ? `${n}, ${built} built` : n; +} diff --git a/analyzer-views/src/rules.ts b/analyzer-views/src/rules.ts index fc6ca8a..8f2262e 100644 --- a/analyzer-views/src/rules.ts +++ b/analyzer-views/src/rules.ts @@ -6,5 +6,6 @@ export * from "./runs.js"; export * from "./languages.js"; export * from "./colors.js"; export * from "./logs.js"; +export * from "./matches.js"; export * from "./values.js"; export { type IconName, fileIcon, iconSvg, passIcon } from "./icons.js"; diff --git a/analyzer-views/style.css b/analyzer-views/style.css index 0341426..612c192 100644 --- a/analyzer-views/style.css +++ b/analyzer-views/style.css @@ -38,6 +38,7 @@ --nlp-icon-file: #424242; --nlp-icon-log: #424242; --nlp-icon-tree: #424242; + --nlp-icon-matches: #424242; } @media (prefers-color-scheme: dark) { :root:not([data-theme="light"]) { @@ -47,6 +48,7 @@ --nlp-icon-dna: #edec81; --nlp-icon-dna-off: #939598; --nlp-icon-dot: #b5aaff; --nlp-icon-folder: #c5c5c5; --nlp-icon-folder-off: #7a7a7a; --nlp-icon-dict: #00aeef; --nlp-icon-kbb: #ffe680; --nlp-icon-json: #00aeef; --nlp-icon-file: #c5c5c5; --nlp-icon-log: #e6e6e6; --nlp-icon-tree: #c5c5c5; + --nlp-icon-matches: #c5c5c5; } } :root[data-theme="dark"] { @@ -54,7 +56,7 @@ --nlp-changed: #fdd663; --nlp-error: #f28b82; --nlp-line: rgba(255, 213, 0, .16); --nlp-icon-dna: #edec81; --nlp-icon-dna-off: #939598; --nlp-icon-dot: #b5aaff; --nlp-icon-folder: #c5c5c5; --nlp-icon-folder-off: #7a7a7a; --nlp-icon-dict: #00aeef; --nlp-icon-kbb: #ffe680; --nlp-icon-json: #00aeef; - --nlp-icon-file: #c5c5c5; --nlp-icon-log: #e6e6e6; --nlp-icon-tree: #c5c5c5; + --nlp-icon-file: #c5c5c5; --nlp-icon-log: #e6e6e6; --nlp-icon-tree: #c5c5c5; --nlp-icon-matches: #c5c5c5; } nlp-sequence, nlp-knowledge-base, nlp-output, nlp-trees { display: block; } @@ -93,6 +95,7 @@ nlp-sequence[hidden], nlp-knowledge-base[hidden], nlp-output[hidden], nlp-trees[ .nlp-icon.file { color: var(--nlp-icon-file); } .nlp-icon.log { color: var(--nlp-icon-log); } .nlp-icon.tree { color: var(--nlp-icon-tree); } +.nlp-icon.matches { color: var(--nlp-icon-matches); } .nlp-heading { font-size: 11px; text-transform: uppercase; letter-spacing: .06em; color: var(--nlp-muted); margin: 12px 4px 4px; } @@ -115,6 +118,14 @@ span.nlp-name { color: var(--nlp-muted); } .nlp-item.skipped .nlp-note { padding-left: 4px; } .nlp-size { flex: none; font-size: 11.5px; color: var(--nlp-muted); padding: 0 4px; } +/* What a -DEV run left on a pass: its parse tree, and what its rules matched. Faint until + the row is under the mouse, since they sit on every pass a debug run wrote a tree for. */ +.nlp-act { flex: none; display: inline-flex; align-items: center; border: 0; background: none; cursor: pointer; + padding: 3px 4px; border-radius: 5px; color: var(--nlp-muted); opacity: .55; } +.nlp-row:hover .nlp-act, .nlp-act:focus-visible { opacity: 1; } +.nlp-act:hover { background: var(--nlp-hover); } +.nlp-act .nlp-icon { color: inherit; } + /* A changed file: a mark after its name, and its revert button. */ .nlp-item.changed button.nlp-name::after { content: " ●"; color: var(--nlp-changed); } .nlp-revert { display: none; border: 0; background: none; padding: 2px 6px; color: var(--nlp-muted); font-size: 13px; diff --git a/analyzer-views/test/elements.test.ts b/analyzer-views/test/elements.test.ts index cea0488..792035d 100644 --- a/analyzer-views/test/elements.test.ts +++ b/analyzer-views/test/elements.test.ts @@ -80,6 +80,30 @@ describe("", () => { document.body.append(el); expect(el.hidden).toBe(true); }); + + // A -DEV run keeps a tree per pass; the pass it belongs to offers it, and what its + // rules matched, as the extension's sequence view does. + it("puts a tree and a rule-matches button on every pass a debug run wrote a tree for", () => { + const el = make(); + expect(el.querySelectorAll(".nlp-act").length).toBe(0); + el.trees = [ + { name: "final.tree", pass: null }, + { name: "ana002.tree", pass: 2, passName: "funcs" }, + ]; + const rows = [...el.querySelectorAll("li")].map((li) => + [...li.querySelectorAll(".nlp-act")].map((b) => b.className)); + expect(rows).toEqual([[], [], ["nlp-act tree", "nlp-act matches"], []]); + }); + it("says which pass's tree or matches were asked for", () => { + const el = make(); + el.trees = [{ name: "ana002.tree", pass: 2, passName: "funcs" }]; + const asked: [string, string, number | undefined][] = []; + for (const type of ["nlp-open-tree", "nlp-open-matches"] as const) { + document.body.addEventListener(type, (e) => asked.push([type, e.detail.path, e.detail.pass])); + } + for (const b of el.querySelectorAll(".nlp-act")) b.click(); + expect(asked).toEqual([["nlp-open-tree", "ana002.tree", 2], ["nlp-open-matches", "ana002.tree", 2]]); + }); }); describe("", () => { diff --git a/analyzer-views/test/rules.test.ts b/analyzer-views/test/rules.test.ts index 8063673..4fc1529 100644 --- a/analyzer-views/test/rules.test.ts +++ b/analyzer-views/test/rules.test.ts @@ -3,7 +3,8 @@ import { describe, expect, it } from "vitest"; import { fileIcon, fileSize, outputOrder, passComment, passIcon, passLabel, passTooltip, passes, shownInKnowledgeBase, - filledFields, langFor, logLines, problemWhere, problemsInLog, readOutput, treeLabel, treeNote, treeOrder, treeTitle, + filledFields, langFor, logLines, matchCount, matchesIn, problemWhere, problemsInLog, readOutput, ruleMatches, + treeLabel, treeNote, treeOrder, treeTitle, } from "../src/rules.js"; describe("analyzer.seq", () => { @@ -172,3 +173,45 @@ describe("the values a run found", () => { expect("error" in readOutput("{oops")).toBe(true); }); }); + +describe("what a pass matched", () => { + // A pass tree as the engine writes it with -DEV: the sentence, then the nodes a rule + // matched in it. "fired" marks a match, "blt" one that built a node. + const text = "I live in San Diego, in California."; + const tree = [ + "_ROOT [0,34,0,34,0,0,node]", + " _city [10,18,10,18,3,12,node,fired,blt]", + " San [10,12,10,12,0,0,alpha]", + " Diego [14,18,14,18,0,0,alpha]", + " _state [24,33,24,33,3,20,node,fired]", + " . [34,34,34,34,0,0,punct]", + ].join("\n"); + + it("is the input with every match marked, built ones apart from the rest", () => { + expect(ruleMatches(tree, text)).toBe("I live in <<>>, in (((California)))."); + }); + it("keeps only what built a node when asked for that", () => { + expect(ruleMatches(tree, text, { builtOnly: true })).toBe("I live in <<>>, in California."); + }); + it("marks the outermost match, not the ones inside it", () => { + const nested = [ + "_ROOT [0,34,0,34,0,0,node]", + " _where [10,33,10,33,4,3,node,fired,blt]", + " _city [10,18,10,18,3,12,node,fired,blt]", + ].join("\n"); + expect(ruleMatches(nested, text)).toBe("I live in <<>>."); + }); + it("counts code points, so a match after an emoji covers the words it matched", () => { + const emoji = "🙂 San Diego"; + // The tree's ustart/uend count code points: the emoji is one, and two UTF-16 units. + expect(ruleMatches(" _city [0,0,2,10,3,12,node,fired,blt]", emoji)).toBe("🙂 <<>>"); + }); + it("is the text itself when the pass matched nothing, or wrote no tree", () => { + expect(ruleMatches("_ROOT [0,34,0,34,0,0,node]", text)).toBe(text); + expect(ruleMatches(null, text)).toBe(text); + }); + it("says how many matches a pass made", () => { + expect(matchCount(matchesIn(tree, text))).toBe("2 matches, 1 built"); + expect(matchCount(matchesIn(" _x [0,0,0,0,3,12,node,fired]", text))).toBe("1 match"); + }); +}); diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index db9911c..fa29d46 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -114,9 +114,18 @@ files the analyzer wrote, problems, and the engine's log. Its parse trees open i the result. The server moves a run's trees aside (`trees.py`: for the person who ran it, for 30 minutes, the newest 20 runs and 256 MB) and the page lists them; opening one fetches it (`GET /api/run/tree`) into a read-only editor document in the `tree` language. **Debug** - runs the engine in its develop mode, which also writes `output/ana###.tree` after every - pass. Each tree node carries the pass and rule line that built it, so hover shows a node's - text and go to definition opens its rule (`run/treeview.ts`). + runs the engine in its develop mode (`-DEV`), which also writes `output/ana###.tree` after + every pass. Each tree node carries the pass and rule line that built it, so hover shows a + node's text and go to definition opens its rule (`run/treeview.ts`). +- **A debug run puts two buttons on every pass.** The sequence shows, on each pass the run + kept a tree for, that tree and what the pass's rules matched — the two inline commands the + extension's sequence view offers when a pass has a log. The matches are not a file the + engine writes: the extension derives them from the pass's tree, and so does + `ruleMatches()` in `analyzer-views/src/matches.ts` — every node the tree marks `fired`, put + back over the input as `<<>>` and `((( matched )))`, which is the `.txxt` grammar's + own markup and so colours like one. Deriving it in the browser is why no page and no + server had to learn a new artifact: a page that can fetch the pass's tree can show its + matches. - **The file list follows the extension's sequence view.** The section is "Analyzer Sequence", each pass carries the icon `sequenceView.ts` would give it (the DNA helix for a rule pass, grey when it is switched off, a pink-headed helix for a recursive one, a folder, a dot for diff --git a/studio/index.html b/studio/index.html index a4ee0bf..c67b54b 100644 --- a/studio/index.html +++ b/studio/index.html @@ -16,7 +16,7 @@ - diff --git a/studio/src/main.ts b/studio/src/main.ts index 2fbc1b1..aeb0c2a 100644 --- a/studio/src/main.ts +++ b/studio/src/main.ts @@ -22,7 +22,7 @@ import { languageFor } from "./lsp/convert"; import { type AnalyzerEntry, fileUri, loadFiles, loadIndex, pathOf } from "./analyzers"; import { type IconName, type NlpKnowledgeBase, type NlpSequence, type Pass, type TreeFile as ListedTree, - fileSize, iconElement, passes, treeTitle, + fileSize, iconElement, passes, ruleMatches, treeTitle, } from "@visualtext/analyzer-views"; import { type Account, type CommitResult, type FoundAnalyzer, type RecentAnalyzer, type RepoAnalyzers, type Repository, @@ -92,8 +92,10 @@ export class Studio { analyzers: AnalyzerEntry[] = []; current: AnalyzerEntry | undefined; currentPath: string | undefined; - // A parse tree or an output file in the editor, when one is: currentPath is then undefined. + // A parse tree, what a pass's rules matched, or an output file in the editor, when one + // is: currentPath is then undefined. currentTree: string | undefined; + currentMatches: string | undefined; currentOutput: string | undefined; // The input a run reads: the input file opened last, or the analyzer's first. inputPath: string | undefined; @@ -113,6 +115,8 @@ export class Studio { // Kept apart from the analyzer's files: never a draft, never run, committed or zipped. private lastRun: { id: string; trees: RunTrees | null; output: Record; context: TreeContext } | undefined; private readonly treeModels = new Map(); + // What each pass's rules matched, worked out from its tree the first time it is asked for. + private readonly matchModels = new Map(); private readonly outputModels = new Map(); private lastPath: string | undefined; private treeRequest = 0; @@ -242,6 +246,7 @@ export class Studio { this.currentPath = path; this.lastPath = path; this.currentTree = undefined; + this.currentMatches = undefined; this.currentOutput = undefined; if (path.startsWith("input/")) { this.inputPath = path; @@ -285,6 +290,7 @@ export class Studio { this.editor.updateOptions({ readOnly: true }); this.currentPath = undefined; this.currentTree = undefined; + this.currentMatches = undefined; this.currentOutput = name; byId("path").textContent = `${this.current.title} / output / ${name}`; this.markOpen(); @@ -302,17 +308,8 @@ export class Studio { const request = ++this.treeRequest; let model = this.treeModels.get(name); if (!model) { - const before = byId("status").textContent ?? ""; - this.say(`Loading the ${treeTitle(file)} (${fileSize(file.size)})…`); - let text: string; - try { - text = await fetchTree(run.trees.run, name); - } catch (err) { - if (request === this.treeRequest) this.say(`Could not open the ${treeTitle(file)}: ${messageOf(err)}`); - return false; - } - if (this.lastRun !== run) return false; // another run or analyzer meanwhile - this.say(before); + const text = await this.treeText(run, file, request); + if (text === null) return false; model = this.treeModels.get(name); if (!model) { model = monaco.editor.createModel(text, "tree", monaco.Uri.parse(`nlp-tree:/${run.trees.run}/${name}`)); @@ -321,28 +318,88 @@ export class Studio { } } if (request !== this.treeRequest) return false; // another was opened meanwhile + this.showRunDocument(model, name, `${this.current.title} / ${treeTitle(file)} · ${name}`); + this.currentTree = name; + this.markOpen(); + return true; + } + + // Open what one pass's rules matched: the input the run read with every match of that + // pass marked, as the VS Code extension's Display Matched Rules shows it. It is worked + // out here from the pass's tree -- the engine writes no such file -- so it needs the + // same debug run the tree came from, and nothing more from the server. + async openMatches(treeName: string): Promise { + const run = this.lastRun; + const file = run?.trees?.files.find((f) => f.name === treeName); + if (!run?.trees || !file || !this.current) return false; + const name = treeName.replace(/\.tree$/, ".txxt"); + const request = ++this.treeRequest; + let model = this.matchModels.get(name); + if (!model) { + const text = await this.treeText(run, file, request); + if (text === null) return false; + model = this.matchModels.get(name); + if (!model) { + model = monaco.editor.createModel(ruleMatches(text, run.context.text), "txxt", + monaco.Uri.parse(`nlp-matches:/${run.trees.run}/${name}`)); + this.matchModels.set(name, model); + } + } + if (request !== this.treeRequest) return false; + const what = file.pass === null ? "the whole run" : `pass ${file.pass}${file.passName ? ` (${file.passName})` : ""}`; + this.showRunDocument(model, name, `${this.current.title} / what ${what} matched · ${name}`); + this.currentMatches = name; + this.markOpen(); + return true; + } + + // One of the run's trees as text: the model's, when it is already open, else the + // server's. Null when it could not be had, with the reason in the header. + private async treeText(run: NonNullable, file: TreeFile, request: number): Promise { + const open = this.treeModels.get(file.name); + if (open) return open.getValue(); + const before = byId("status").textContent ?? ""; + this.say(`Loading the ${treeTitle(file)} (${fileSize(file.size)})…`); + let text: string; + try { + text = await fetchTree(run.trees!.run, file.name); + } catch (err) { + if (request === this.treeRequest) this.say(`Could not open the ${treeTitle(file)}: ${messageOf(err)}`); + return null; + } + if (this.lastRun !== run) return null; // another run or analyzer meanwhile + this.say(before); + return text; + } + + // A document the run produced, read-only, in the editor. + private showRunDocument(model: monaco.editor.ITextModel, name: string, header: string): void { this.editor.setModel(model); this.editor.updateOptions({ readOnly: true }); this.currentPath = undefined; + this.currentTree = undefined; + this.currentMatches = undefined; this.currentOutput = undefined; - this.currentTree = name; - byId("path").textContent = `${this.current.title} / ${treeTitle(file)} · ${name}`; - this.markOpen(); + byId("path").textContent = header; this.editor.focus(); this.showProblems(); - return true; } // Let go of the last run's files; the editor goes back to a file if it was showing one. private forgetRunFiles(): void { this.treeRequest++; - const showing = this.currentTree !== undefined || this.currentOutput !== undefined; + const showing = this.currentTree !== undefined || this.currentMatches !== undefined + || this.currentOutput !== undefined; this.lastRun = undefined; this.currentTree = undefined; + this.currentMatches = undefined; this.currentOutput = undefined; if (showing) this.editor.setModel(null); - for (const model of [...this.treeModels.values(), ...this.outputModels.values()]) model.dispose(); + for (const model of [...this.treeModels.values(), ...this.matchModels.values(), ...this.outputModels.values()]) { + model.dispose(); + } this.treeModels.clear(); + this.matchModels.clear(); this.outputModels.clear(); } @@ -755,6 +812,7 @@ export class Studio { // goes back to the analyzer file it had. private showRunFiles(result: RunResult, context: TreeContext): void { const tree = this.currentTree; + const matches = this.currentMatches; const output = this.currentOutput; this.forgetRunFiles(); const files = result.output ?? {}; @@ -763,9 +821,12 @@ export class Studio { } this.renderFiles(); this.showChanges(); + const matchTree = matches?.replace(/\.txxt$/, ".tree"); if (tree !== undefined && this.lastRun?.trees?.files.some((f) => f.name === tree)) void this.openTree(tree); - else if (output !== undefined && this.lastRun?.output[output] !== undefined) this.openOutput(output); - else if (tree === undefined && output === undefined) this.markOpen(); + else if (matchTree !== undefined && this.lastRun?.trees?.files.some((f) => f.name === matchTree)) { + void this.openMatches(matchTree); + } else if (output !== undefined && this.lastRun?.output[output] !== undefined) this.openOutput(output); + else if (tree === undefined && matches === undefined && output === undefined) this.markOpen(); else this.openPath(this.lastPath ?? "spec/analyzer.seq"); } @@ -819,6 +880,11 @@ export class Studio { const sequence = document.createElement("nlp-sequence"); sequence.files = entry.files; sequence.sequence = this.models.get("spec/analyzer.seq")?.getValue() ?? ""; + // A Debug run keeps the tree after every pass, so each pass offers two more things, + // as the extension's sequence view does: that tree, and what its rules matched. + sequence.trees = this.lastRun?.trees?.files.map(listedTree); + sequence.addEventListener("nlp-open-tree", (e) => void this.openTree(e.detail.path)); + sequence.addEventListener("nlp-open-matches", (e) => void this.openMatches(e.detail.path)); listen(sequence); const kb = document.createElement("nlp-knowledge-base"); kb.files = entry.files; diff --git a/studio/src/selftest.ts b/studio/src/selftest.ts index bbda545..f7dbca6 100644 --- a/studio/src/selftest.ts +++ b/studio/src/selftest.ts @@ -391,6 +391,22 @@ async function runChecks(studio: Studio, check: (name: string, ok: boolean, got: !!afterOne?.includes("world [") && !afterOne.includes("_greeting") && !!afterThree?.includes("_greeting"), { afterOne: afterOne?.slice(0, 200), afterThree: afterThree?.length, path: document.getElementById("path")?.textContent }); + // The sequence's own buttons, which a Debug run puts on every pass it kept a tree for: + // that tree, and what the pass's rules matched in the input. + progress("opening what a pass matched"); + const acts = (kind: string) => + [...document.querySelectorAll(`#files nlp-sequence .nlp-act.${kind}`)]; + check("with Debug, each pass in the sequence offers its tree and its rule matches", + acts("tree").length === 4 && acts("matches").length === 4, + { trees: acts("tree").length, matches: acts("matches").length }); + acts("matches").find((b) => b.title.includes("pass 3"))?.click(); + const matched = await until(() => studio.editor.getModel(), (m) => m?.getLanguageId() === "txxt"); + const marked = matched?.getValue() ?? ""; + // greeting builds _greeting over each greeting, and builds nothing around "there". + check("...and the rule matches open as the input with what the pass built marked", + studio.currentMatches === "ana003.txxt" && marked.startsWith("<<>>") && marked.includes("there"), + { matches: studio.currentMatches, language: matched?.getLanguageId(), marked: marked.slice(0, 120) }); + studio.openPath("spec/output.nlp"); const output = studio.editor.getModel()!; output.setValue("@CODE\nNoSuchFunction(1);\n@@CODE\n");