diff --git a/analyzer-views/README.md b/analyzer-views/README.md index a18163d..5572573 100644 --- a/analyzer-views/README.md +++ b/analyzer-views/README.md @@ -22,7 +22,8 @@ sequence.sequence = analyzerSeqText; // the text of spec/analyzer.seq sequence.addEventListener("nlp-open", (e) => openFile(e.detail.path)); const kb = document.createElement("nlp-knowledge-base"); -kb.files = [{ path: "kb/user/colors.dict", bytes: 3000 }]; // sizes are optional +kb.files = [{ path: "kb/user/colors.dict", bytes: 3000, // sizes are optional, + description: kbDescription(colorsDictText) }]; // and so is the mouse-over kb.addEventListener("nlp-open", (e) => openFile(e.detail.path)); sidebar.append(sequence, kb); @@ -42,7 +43,7 @@ Every list element takes: | | | |---|---| -| `files` (property) | The paths to list: strings, or `{ path, bytes }` objects to show sizes. `` takes `trees` instead. | +| `files` (property) | The paths to list: strings, or `{ path, bytes }` objects to show sizes. On ``, a `description` too: the file's mouse-over, above its path. `` takes `trees` instead. | | `selected` (property) | The path that's open now, drawn as selected. | | `changed` (property) | Paths with unsaved edits. Each gets a mark, plus a revert button when the element has `revertable`. | | `heading` (attribute) | The list's heading. `heading=""` shows no heading. | @@ -144,7 +145,11 @@ follow the same light and dark choice. - `passes(seqText, files)` reads `analyzer.seq` into passes, numbered the way the engine numbers them. A switched-off pass keeps its number, and a folder or stub gets none. -- `shownInKnowledgeBase(path)` says whether a file is listed in the knowledge base. +- `shownInKnowledgeBase(path)` says whether a file is listed in the knowledge base, and + `kbLabel(path)` names it there: its path under `kb/user/`. +- `kbDescription(text)` is what a `.dict` or `.kbb` file holds, from the comment + paragraph at its top, as the extension's knowledge base view shows on mouse-over. Only + the first 4 KB are read, so a head of the file is enough. - `passTooltip`, `passLabel`, `passIcon`, `fileIcon` and `fileSize` give each row's mouse-over, label, icon and size. - `outputOrder`, `treeOrder`, `treeLabel`, `treeTitle` and `treeNote` order and describe diff --git a/analyzer-views/package-lock.json b/analyzer-views/package-lock.json index 6b877b9..5db7d97 100644 --- a/analyzer-views/package-lock.json +++ b/analyzer-views/package-lock.json @@ -1,12 +1,12 @@ { "name": "@visualtext/analyzer-views", - "version": "0.3.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@visualtext/analyzer-views", - "version": "0.3.0", + "version": "0.6.0", "dependencies": { "@shikijs/core": "4.4.3", "@shikijs/engine-javascript": "4.4.3", diff --git a/analyzer-views/package.json b/analyzer-views/package.json index 1d19b79..bc1d82a 100644 --- a/analyzer-views/package.json +++ b/analyzer-views/package.json @@ -1,7 +1,7 @@ { "name": "@visualtext/analyzer-views", "private": true, - "version": "0.5.0", + "version": "0.6.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 3659f79..c604d71 100644 --- a/analyzer-views/src/elements.ts +++ b/analyzer-views/src/elements.ts @@ -16,7 +16,9 @@ // seq.sequence = seqText; // seq.addEventListener("nlp-open", (e) => open(e.detail.path)); // -// Properties: files paths, or { path, bytes } to show sizes (all but ) +// Properties: files paths, or { path, bytes } to show sizes (all but ); +// on , { description } too, the mouse-over +// (kbDescription of the file's text) // sequence the text of spec/analyzer.seq () // trees { name, pass, passName, bytes } and skipped, names too large // to keep (); on , the trees a -DEV run @@ -39,7 +41,7 @@ // LIGHT DOM, not a shadow root: the rows are ordinary buttons a page can style, test and // query (button[data-path]). style.css draws them; its --nlp-* properties theme them. -import { type AnalyzerFile, fileSize, shownInKnowledgeBase, toFile } from "./files.js"; +import { type AnalyzerFile, fileSize, kbLabel, shownInKnowledgeBase, toFile } from "./files.js"; import { nlpTokens } from "./highlight.js"; import { type IconName, fileIcon, iconElement, passIcon } from "./icons.js"; import { type NlpLanguage, NLP_LANGUAGES, langFor } from "./languages.js"; @@ -310,10 +312,11 @@ export class NlpKnowledgeBase extends FileList { protected rows(): Row[] { return this.files.filter((f) => shownInKnowledgeBase(f.path)).map((f) => ({ - label: f.path.slice("kb/".length), + label: kbLabel(f.path), path: f.path, icon: fileIcon(f.path), - title: f.path, + // What the file holds, over where it lives, as the extension's mouse-over. + title: f.description ? `${f.description}\n\n${f.path}` : f.path, size: fileSize(f.bytes), })); } diff --git a/analyzer-views/src/files.ts b/analyzer-views/src/files.ts index 66f25d6..7dc5b2c 100644 --- a/analyzer-views/src/files.ts +++ b/analyzer-views/src/files.ts @@ -3,10 +3,12 @@ // PURE: no DOM. // A file of the analyzer: its path inside the analyzer (spec/..., kb/..., input/...), -// and its size when the host knows it. +// its size when the host knows it, and, for a knowledge-base file, what it holds +// (kbDescription of its text) to show when the mouse is over it. export interface AnalyzerFile { path: string; bytes?: number; + description?: string; } export function toFile(file: string | AnalyzerFile): AnalyzerFile { @@ -20,6 +22,57 @@ export function shownInKnowledgeBase(path: string): boolean { return path.startsWith("kb/") && /\.(dict|kbb)$/i.test(path); } +// A knowledge-base file's name in the list: its path under kb/user/, where an analyzer's +// own files live -- "colors.dict" rather than "kb/user/colors.dict". +export function kbLabel(path: string): string { + return path.replace(/^kb\/(user\/)?/, ""); +} + +// What a .dict or .kbb file holds, as its author says in the comment at the top of it: the +// opening paragraph of '#' or /* */ comment lines, as the extension's KNOWLEDGE BASE view +// shows it on mouse-over (kbView.ts fileDescription). A bare '#' ends the paragraph; a '/*' +// alone on the line opening a block comment is stepped over. "" when there is none. +// +// Only the head is read: en-full.kbb is 10 MB, and the description is at the top. +const DESCRIPTION_HEAD = 4096; +const DESCRIPTION_LINES = 8; + +export function kbDescription(text: string | null | undefined): string { + const all = String(text ?? ""); + const lines = all.slice(0, DESCRIPTION_HEAD).split(/\r?\n/); + if (all.length > DESCRIPTION_HEAD) lines.pop(); // cut mid-line: not whole + const out: string[] = []; + let open = false; // inside a /* */ comment begun on an earlier line + for (const line of lines) { + if (out.length >= DESCRIPTION_LINES) break; + const trimmed = line.trim(); + if (open || trimmed.startsWith("/*")) { + // A block-comment line counts only when nothing follows its close. + const close = trimmed.indexOf("*/", open ? 0 : 2); + if (close >= 0 && trimmed.slice(close + 2).trim()) break; + open = close < 0; + } else if (!trimmed.startsWith("#")) { + break; + } + const words = stripCommentMarkers(trimmed); + if (!words) { + if (!out.length) continue; // '/*' on a line of its own opens the block + break; // a marker-only line closes the paragraph + } + out.push(words); + } + return out.join("\n"); +} + +// One comment line without its markers: '#', '/*', '*/', and the '*' block comments' +// continuation lines are often drawn with. +function stripCommentMarkers(line: string): string { + let text = line; + if (text.startsWith("/*")) text = text.slice(2); + if (text.endsWith("*/")) text = text.slice(0, -2); + return text.replace(/^[#*\s]+/, "").trim(); +} + // A size for a list: "3 KB", "1.2 MB". Unknown or empty prints nothing rather than // claiming "0 KB". export function fileSize(bytes: number | null | undefined): string { diff --git a/analyzer-views/test/elements.test.ts b/analyzer-views/test/elements.test.ts index 792035d..5a90521 100644 --- a/analyzer-views/test/elements.test.ts +++ b/analyzer-views/test/elements.test.ts @@ -113,9 +113,16 @@ describe("", () => { document.body.append(el); expect(el.querySelector("h3")?.textContent).toBe("Knowledge base"); expect(paths(el)).toEqual(["kb/user/colors.dict", "kb/user/facts.kbb"]); - expect([...el.querySelectorAll(".nlp-name")].map((n) => n.textContent)).toEqual(["user/colors.dict", "user/facts.kbb"]); + expect([...el.querySelectorAll(".nlp-name")].map((n) => n.textContent)).toEqual(["colors.dict", "facts.kbb"]); expect([...el.querySelectorAll(".nlp-size")].map((n) => n.textContent)).toEqual(["3 KB"]); }); + it("says on mouse-over what a file holds, over its path", () => { + const el = document.createElement("nlp-knowledge-base") as NlpKnowledgeBase; + el.files = [{ path: "kb/user/colors.dict", description: "Colour words" }, "kb/user/facts.kbb"]; + document.body.append(el); + expect([...el.querySelectorAll(".nlp-name")].map((n) => n.getAttribute("title"))) + .toEqual(["Colour words\n\nkb/user/colors.dict", "kb/user/facts.kbb"]); + }); it("hides itself when the analyzer has only .kb files", () => { const el = document.createElement("nlp-knowledge-base"); el.files = ["kb/user/hier.kb", "kb/user/word.kb"]; diff --git a/analyzer-views/test/rules.test.ts b/analyzer-views/test/rules.test.ts index 4fc1529..9d51936 100644 --- a/analyzer-views/test/rules.test.ts +++ b/analyzer-views/test/rules.test.ts @@ -2,7 +2,7 @@ // The rules with no DOM at all, as a server or another page would use them. import { describe, expect, it } from "vitest"; import { - fileIcon, fileSize, outputOrder, passComment, passIcon, passLabel, passTooltip, passes, shownInKnowledgeBase, + fileIcon, fileSize, outputOrder, passComment, passIcon, passLabel, passTooltip, passes, shownInKnowledgeBase, kbDescription, kbLabel, filledFields, langFor, logLines, matchCount, matchesIn, problemWhere, problemsInLog, readOutput, ruleMatches, treeLabel, treeNote, treeOrder, treeTitle, } from "../src/rules.js"; @@ -89,6 +89,22 @@ describe("the knowledge base", () => { ]; expect(files.filter(shownInKnowledgeBase)).toEqual(["kb/user/colors.dict", "kb/user/en-full.DICT", "kb/user/facts.kbb"]); }); + it("names a file by its path under kb/user/", () => { + expect(["kb/user/colors.dict", "kb/general/en.kbb", "kb/facts.kbb"].map(kbLabel)).toEqual(["colors.dict", "general/en.kbb", "facts.kbb"]); + }); + it("describes a file by the comment paragraph at its top, as the extension does", () => { + expect(kbDescription("# Colour words\n# for the paint analyzer\n#\n# Maintained by hand\nred\tcolor=red\n")) + .toBe("Colour words\nfor the paint analyzer"); + expect(kbDescription("/*\n * Facts about firms\n */\nfirm\n")).toBe("Facts about firms"); + expect(kbDescription("/* One line */\r\nfirm\r\n")).toBe("One line"); + expect(kbDescription("/* then */ red\n")).toBe(""); + expect(kbDescription("red\tcolor=red\n# not at the top\n")).toBe(""); + expect(kbDescription(undefined)).toBe(""); + }); + it("reads only the head of a large file", () => { + expect(kbDescription(`# Every English word\n${"word\n".repeat(1_000_000)}`)).toBe("Every English word"); + expect(kbDescription(Array.from({ length: 20 }, (_, i) => `# line ${i}`).join("\n")).split("\n")).toHaveLength(8); + }); it("prints a size, and nothing for one it does not know", () => { expect([fileSize(3000), fileSize(100), fileSize(2.5 * 1024 * 1024), fileSize(0), fileSize(undefined)]) .toEqual(["3 KB", "1 KB", "2.5 MB", "", ""]); diff --git a/studio/src/main.ts b/studio/src/main.ts index aeb0c2a..869c58e 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, ruleMatches, treeTitle, + fileSize, iconElement, kbDescription, passes, ruleMatches, shownInKnowledgeBase, treeTitle, } from "@visualtext/analyzer-views"; import { type Account, type CommitResult, type FoundAnalyzer, type RecentAnalyzer, type RepoAnalyzers, type Repository, @@ -887,7 +887,12 @@ export class Studio { 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; + // Each file's opening comment is its mouse-over, as in the extension. Only its top + // lines are taken: a .kbb can run to megabytes. + const head = (model: monaco.editor.ITextModel | undefined) => + model?.getValueInRange(new monaco.Range(1, 1, Math.min(model.getLineCount(), 20) + 1, 1)); + kb.files = entry.files.filter(shownInKnowledgeBase) + .map((path) => ({ path, description: kbDescription(head(this.models.get(path))) })); listen(kb); // The studio's own lists, drawn with the same rows until they move there too.