diff --git a/packages/coding-agent/docs/extensions.md b/packages/coding-agent/docs/extensions.md index 00e92a1d6..fea3ab0e9 100644 --- a/packages/coding-agent/docs/extensions.md +++ b/packages/coding-agent/docs/extensions.md @@ -2877,11 +2877,17 @@ For syntax highlighting in custom tool renderers: import { highlightCode, getLanguageFromPath } from "@earendil-works/pi-coding-agent"; // Highlight code with explicit language -const highlighted = highlightCode("const x = 1;", "typescript", theme); +const typescriptLines = highlightCode("const x = 1;", "typescript"); -// Auto-detect language from file path +// Detect language from a file path const lang = getLanguageFromPath("/path/to/file.rs"); // "rust" -const highlighted = highlightCode(code, lang, theme); +const fileLines = highlightCode(code, lang); + +// Uncommon grammars load lazily. In a tool renderer, request another render +// after the grammar and its dependencies are ready. +const lazyLines = highlightCode(code, lang, { + onLanguageReady: context.invalidate, +}); ``` ## Error Handling diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 3d05c2af4..7b656c0c9 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -393,6 +393,7 @@ export { getMarkdownTheme, getSelectListTheme, getSettingsListTheme, + type HighlightCodeOptions, highlightCode, initTheme, Theme, diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 720f9b0f9..a1f090b1b 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -6379,7 +6379,7 @@ export class InteractiveMode { this.ui.terminal.setProgress(false); } this.clearStatusIndicator(); - this.themeController.disableAutoSync(); + this.themeController.dispose(); this.clearExtensionTerminalInputListeners(); this.footer.dispose(); this.footerDataProvider.dispose(); diff --git a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts index 6d99b0ded..a5354c1ae 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme-controller.ts @@ -1,5 +1,6 @@ import type { TUI } from "@earendil-works/pi-tui"; import type { SettingsManager } from "../../../core/settings-manager.ts"; +import { onHighlightLanguageLoad } from "../../../utils/syntax-highlight.ts"; import { detectTerminalBackgroundFromEnv, detectTerminalBackgroundTheme, @@ -24,6 +25,9 @@ export class InteractiveThemeController { private activeThemeName: string | undefined; private autoSyncEnabled = false; private terminalColorSchemeUnsubscribe: (() => void) | undefined; + private highlightLanguageLoadUnsubscribe: (() => void) | undefined; + private highlightLanguageRefreshTimer: ReturnType | undefined; + private highlightLanguageGlobalRefreshPending = false; constructor(ui: TUI, settingsManager: SettingsManager, showError: (message: string) => void, onChanged: () => void) { this.ui = ui; @@ -32,6 +36,21 @@ export class InteractiveThemeController { this.onChanged = onChanged; this.activeThemeName = resolveThemeSetting(this.settingsManager.getThemeSetting(), this.terminalTheme); initTheme(this.activeThemeName, true); + this.highlightLanguageLoadUnsubscribe = onHighlightLanguageLoad((event) => { + if ("error" in event) { + const message = event.error instanceof Error ? event.error.message : String(event.error); + this.showError(`Failed to load syntax highlighting language "${event.language}": ${message}`); + return; + } + this.highlightLanguageGlobalRefreshPending ||= event.requiresGlobalRefresh; + if (this.highlightLanguageRefreshTimer !== undefined) return; + this.highlightLanguageRefreshTimer = setTimeout(() => { + this.highlightLanguageRefreshTimer = undefined; + if (this.highlightLanguageGlobalRefreshPending) this.ui.invalidate(); + this.highlightLanguageGlobalRefreshPending = false; + this.ui.requestRender(); + }, 0); + }); this.bindTerminalColorSchemeListener(); } @@ -92,6 +111,17 @@ export class InteractiveThemeController { this.setAutoSync(false); } + dispose(): void { + this.disableAutoSync(); + this.terminalColorSchemeUnsubscribe?.(); + this.terminalColorSchemeUnsubscribe = undefined; + this.highlightLanguageLoadUnsubscribe?.(); + this.highlightLanguageLoadUnsubscribe = undefined; + if (this.highlightLanguageRefreshTimer !== undefined) clearTimeout(this.highlightLanguageRefreshTimer); + this.highlightLanguageRefreshTimer = undefined; + this.highlightLanguageGlobalRefreshPending = false; + } + getTerminalTheme(): TerminalTheme { return this.terminalTheme; } diff --git a/packages/coding-agent/src/modes/interactive/theme/theme.ts b/packages/coding-agent/src/modes/interactive/theme/theme.ts index c6bb899d4..9e19f4758 100644 --- a/packages/coding-agent/src/modes/interactive/theme/theme.ts +++ b/packages/coding-agent/src/modes/interactive/theme/theme.ts @@ -15,7 +15,9 @@ import { Compile } from "typebox/compile"; import { getCustomThemesDir, getThemesDir } from "../../../config.ts"; import type { SourceInfo } from "../../../core/source-info.ts"; import { closeWatcher, watchWithErrorHandler } from "../../../utils/fs-watch.ts"; -import { highlight, supportsLanguage } from "../../../utils/syntax-highlight.ts"; +import { highlight, requestHighlightLanguage } from "../../../utils/syntax-highlight.ts"; + +export { getLanguageFromPath } from "../../../utils/syntax-highlight.ts"; // ============================================================================ // Types & Schema @@ -1153,14 +1155,19 @@ function getCliHighlightTheme(t: Theme): CliHighlightTheme { return cachedCliHighlightTheme; } +export interface HighlightCodeOptions { + onLanguageReady?: () => void; +} + /** * Highlight code with syntax coloring based on file extension or language. * Returns array of highlighted lines. */ -export function highlightCode(code: string, lang?: string): string[] { - // Validate language before highlighting to avoid stderr spam from cli-highlight - const validLang = lang && supportsLanguage(lang) ? lang : undefined; - // Skip highlighting when no valid language is specified. cli-highlight's +export function highlightCode(code: string, lang?: string, options: HighlightCodeOptions = {}): string[] { + // Validate language before highlighting to avoid stderr spam from highlight.js. + // Uncommon languages start loading in the background and render plainly once. + const validLang = lang ? requestHighlightLanguage(lang, options.onLanguageReady) : undefined; + // Skip highlighting when no valid language is specified. highlight.js's // auto-detection is unreliable and can misidentify prose as AppleScript, // LiveCodeServer, etc., coloring random English words as keywords. if (!validLang) { @@ -1174,81 +1181,10 @@ export function highlightCode(code: string, lang?: string): string[] { try { return highlight(code, opts).split("\n"); } catch { - return code.split("\n"); + return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); } } -/** - * Get language identifier from file path extension. - */ -export function getLanguageFromPath(filePath: string): string | undefined { - const ext = filePath.split(".").pop()?.toLowerCase(); - if (!ext) return undefined; - - const extToLang: Record = { - ts: "typescript", - tsx: "typescript", - js: "javascript", - jsx: "javascript", - mjs: "javascript", - cjs: "javascript", - py: "python", - rb: "ruby", - rs: "rust", - go: "go", - java: "java", - kt: "kotlin", - swift: "swift", - c: "c", - h: "c", - cpp: "cpp", - cc: "cpp", - cxx: "cpp", - hpp: "cpp", - cs: "csharp", - php: "php", - sh: "bash", - bash: "bash", - zsh: "bash", - fish: "fish", - ps1: "powershell", - sql: "sql", - html: "html", - htm: "html", - css: "css", - scss: "scss", - sass: "sass", - less: "less", - json: "json", - yaml: "yaml", - yml: "yaml", - toml: "toml", - xml: "xml", - md: "markdown", - markdown: "markdown", - dockerfile: "dockerfile", - makefile: "makefile", - cmake: "cmake", - lua: "lua", - perl: "perl", - r: "r", - scala: "scala", - clj: "clojure", - ex: "elixir", - exs: "elixir", - erl: "erlang", - hs: "haskell", - ml: "ocaml", - vim: "vim", - graphql: "graphql", - proto: "protobuf", - tf: "hcl", - hcl: "hcl", - }; - - return extToLang[ext]; -} - export function getMarkdownTheme(): MarkdownTheme { return { heading: (text: string) => theme.fg("mdHeading", text), @@ -1265,26 +1201,7 @@ export function getMarkdownTheme(): MarkdownTheme { italic: (text: string) => theme.italic(text), underline: (text: string) => theme.underline(text), strikethrough: (text: string) => chalk.strikethrough(text), - highlightCode: (code: string, lang?: string): string[] => { - // Validate language before highlighting to avoid stderr spam from cli-highlight - const validLang = lang && supportsLanguage(lang) ? lang : undefined; - // Skip highlighting when no valid language is specified. cli-highlight's - // auto-detection is unreliable and can misidentify prose as AppleScript, - // LiveCodeServer, etc., coloring random English words as keywords. - if (!validLang) { - return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); - } - const opts = { - language: validLang, - ignoreIllegals: true, - theme: getCliHighlightTheme(theme), - }; - try { - return highlight(code, opts).split("\n"); - } catch { - return code.split("\n").map((line) => theme.fg("mdCodeBlock", line)); - } - }, + highlightCode: (code, lang, onLanguageReady) => highlightCode(code, lang, { onLanguageReady }), }; } diff --git a/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts b/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts index 75e31da28..941ca356f 100644 --- a/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts +++ b/packages/coding-agent/src/utils/highlight-js-lib-index.d.ts @@ -1,19 +1,19 @@ -declare module "highlight.js/lib/index.js" { - interface HighlightResult { - value: string; - } - - interface HighlightOptions { - language: string; - ignoreIllegals?: boolean; - } +declare module "highlight.js/lib/core.js" { + export type LanguageDefinition = (hljs: HighlightJs) => unknown; interface HighlightJs { - highlight(code: string, options: HighlightOptions): HighlightResult; - highlightAuto(code: string, languageSubset?: string[]): HighlightResult; + highlight(code: string, options: { language: string; ignoreIllegals?: boolean }): { value: string }; + highlightAuto(code: string, languageSubset?: string[]): { value: string }; getLanguage(name: string): unknown; + registerLanguage(name: string, definition: LanguageDefinition): void; } const hljs: HighlightJs; export default hljs; } + +declare module "highlight.js/lib/languages/*.js" { + import type { LanguageDefinition } from "highlight.js/lib/core.js"; + const definition: LanguageDefinition; + export default definition; +} diff --git a/packages/coding-agent/src/utils/highlight-languages.ts b/packages/coding-agent/src/utils/highlight-languages.ts new file mode 100644 index 000000000..ffc3db5a8 --- /dev/null +++ b/packages/coding-agent/src/utils/highlight-languages.ts @@ -0,0 +1,556 @@ +import type { LanguageDefinition } from "highlight.js/lib/core.js"; +import bash from "highlight.js/lib/languages/bash.js"; +import css from "highlight.js/lib/languages/css.js"; +import diff from "highlight.js/lib/languages/diff.js"; +import javascript from "highlight.js/lib/languages/javascript.js"; +import json from "highlight.js/lib/languages/json.js"; +import markdown from "highlight.js/lib/languages/markdown.js"; +import python from "highlight.js/lib/languages/python.js"; +import ruby from "highlight.js/lib/languages/ruby.js"; +import typescript from "highlight.js/lib/languages/typescript.js"; +import xml from "highlight.js/lib/languages/xml.js"; +import yaml from "highlight.js/lib/languages/yaml.js"; + +type LanguageModule = { default: LanguageDefinition }; + +export interface HighlightLanguage { + name: string; + extensions?: readonly string[]; + aliases?: readonly string[]; + dependencies?: readonly string[]; + definition?: LanguageDefinition; + load?: () => Promise; +} + +// Registration, aliases, dependencies, lazy importers, and file-extension +// detection live in one registry. +// +// Intentional exception to the no-dynamic-import rule: literal dynamic imports +// let Bun embed every grammar in the standalone binary while keeping evaluation +// lazy, and let Node load only the requested grammar. +export const HIGHLIGHT_LANGUAGES: readonly HighlightLanguage[] = [ + { name: "1c", load: () => import("highlight.js/lib/languages/1c.js") }, + { name: "abnf", load: () => import("highlight.js/lib/languages/abnf.js") }, + { name: "accesslog", load: () => import("highlight.js/lib/languages/accesslog.js") }, + { + name: "actionscript", + aliases: ["as"], + load: () => import("highlight.js/lib/languages/actionscript.js"), + }, + { name: "ada", load: () => import("highlight.js/lib/languages/ada.js") }, + { + name: "angelscript", + aliases: ["asc"], + load: () => import("highlight.js/lib/languages/angelscript.js"), + }, + { + name: "apache", + aliases: ["apacheconf"], + load: () => import("highlight.js/lib/languages/apache.js"), + }, + { + name: "applescript", + aliases: ["osascript"], + load: () => import("highlight.js/lib/languages/applescript.js"), + }, + { name: "arcade", load: () => import("highlight.js/lib/languages/arcade.js") }, + { name: "arduino", aliases: ["ino"], load: () => import("highlight.js/lib/languages/arduino.js") }, + { name: "armasm", aliases: ["arm"], load: () => import("highlight.js/lib/languages/armasm.js") }, + { + name: "xml", + extensions: ["xml"], + aliases: ["html", "xhtml", "rss", "atom", "xjb", "xsd", "xsl", "plist", "wsf", "svg"], + dependencies: ["css", "javascript"], + definition: xml, + }, + { name: "html", extensions: ["html", "htm"], dependencies: ["xml"] }, + { + name: "asciidoc", + aliases: ["adoc"], + dependencies: ["xml"], + load: () => import("highlight.js/lib/languages/asciidoc.js"), + }, + { name: "aspectj", load: () => import("highlight.js/lib/languages/aspectj.js") }, + { + name: "autohotkey", + aliases: ["ahk"], + load: () => import("highlight.js/lib/languages/autohotkey.js"), + }, + { name: "autoit", load: () => import("highlight.js/lib/languages/autoit.js") }, + { name: "avrasm", load: () => import("highlight.js/lib/languages/avrasm.js") }, + { name: "awk", load: () => import("highlight.js/lib/languages/awk.js") }, + { name: "axapta", aliases: ["x++"], load: () => import("highlight.js/lib/languages/axapta.js") }, + { name: "bash", extensions: ["sh", "bash", "zsh"], aliases: ["sh", "zsh"], definition: bash }, + { name: "basic", load: () => import("highlight.js/lib/languages/basic.js") }, + { name: "bnf", load: () => import("highlight.js/lib/languages/bnf.js") }, + { + name: "brainfuck", + aliases: ["bf"], + load: () => import("highlight.js/lib/languages/brainfuck.js"), + }, + { + name: "c-like", + aliases: ["c", "h", "cc", "c++", "h++", "hpp", "hh", "hxx", "cxx"], + load: () => import("highlight.js/lib/languages/c-like.js"), + }, + { name: "c", extensions: ["c", "h"], aliases: ["h"], load: () => import("highlight.js/lib/languages/c.js") }, + { name: "cal", load: () => import("highlight.js/lib/languages/cal.js") }, + { + name: "capnproto", + aliases: ["capnp"], + load: () => import("highlight.js/lib/languages/capnproto.js"), + }, + { name: "ceylon", load: () => import("highlight.js/lib/languages/ceylon.js") }, + { + name: "clean", + aliases: ["icl", "dcl"], + load: () => import("highlight.js/lib/languages/clean.js"), + }, + { + name: "clojure", + extensions: ["clj"], + aliases: ["clj"], + load: () => import("highlight.js/lib/languages/clojure.js"), + }, + { + name: "clojure-repl", + dependencies: ["clojure"], + load: () => import("highlight.js/lib/languages/clojure-repl.js"), + }, + { + name: "cmake", + extensions: ["cmake"], + aliases: ["cmake.in"], + load: () => import("highlight.js/lib/languages/cmake.js"), + }, + { + name: "coffeescript", + aliases: ["coffee", "cson", "iced"], + dependencies: ["javascript"], + load: () => import("highlight.js/lib/languages/coffeescript.js"), + }, + { name: "coq", load: () => import("highlight.js/lib/languages/coq.js") }, + { + name: "cos", + aliases: ["cls"], + dependencies: ["javascript", "sql", "xml"], + load: () => import("highlight.js/lib/languages/cos.js"), + }, + { + name: "cpp", + extensions: ["cpp", "cc", "cxx", "hpp"], + aliases: ["cc", "c++", "h++", "hpp", "hh", "hxx", "cxx"], + load: () => import("highlight.js/lib/languages/cpp.js"), + }, + { + name: "crmsh", + aliases: ["crm", "pcmk"], + load: () => import("highlight.js/lib/languages/crmsh.js"), + }, + { name: "crystal", aliases: ["cr"], load: () => import("highlight.js/lib/languages/crystal.js") }, + { + name: "csharp", + extensions: ["cs"], + aliases: ["cs", "c#"], + load: () => import("highlight.js/lib/languages/csharp.js"), + }, + { name: "csp", load: () => import("highlight.js/lib/languages/csp.js") }, + { name: "css", extensions: ["css"], definition: css }, + { name: "d", load: () => import("highlight.js/lib/languages/d.js") }, + { + name: "markdown", + extensions: ["md", "markdown"], + aliases: ["md", "mkdown", "mkd"], + dependencies: ["xml"], + definition: markdown, + }, + { + name: "dart", + dependencies: ["markdown"], + load: () => import("highlight.js/lib/languages/dart.js"), + }, + { + name: "delphi", + aliases: ["dpr", "dfm", "pas", "pascal", "freepascal", "lazarus", "lpr", "lfm"], + load: () => import("highlight.js/lib/languages/delphi.js"), + }, + { name: "diff", aliases: ["patch"], definition: diff }, + { + name: "django", + aliases: ["jinja"], + dependencies: ["xml"], + load: () => import("highlight.js/lib/languages/django.js"), + }, + { name: "dns", aliases: ["bind", "zone"], load: () => import("highlight.js/lib/languages/dns.js") }, + { + name: "dockerfile", + extensions: ["dockerfile"], + aliases: ["docker"], + dependencies: ["bash"], + load: () => import("highlight.js/lib/languages/dockerfile.js"), + }, + { name: "dos", aliases: ["bat", "cmd"], load: () => import("highlight.js/lib/languages/dos.js") }, + { name: "dsconfig", load: () => import("highlight.js/lib/languages/dsconfig.js") }, + { name: "dts", load: () => import("highlight.js/lib/languages/dts.js") }, + { + name: "dust", + aliases: ["dst"], + dependencies: ["xml"], + load: () => import("highlight.js/lib/languages/dust.js"), + }, + { name: "ebnf", load: () => import("highlight.js/lib/languages/ebnf.js") }, + { name: "elixir", extensions: ["ex", "exs"], load: () => import("highlight.js/lib/languages/elixir.js") }, + { name: "elm", load: () => import("highlight.js/lib/languages/elm.js") }, + { + name: "ruby", + extensions: ["rb"], + aliases: ["rb", "gemspec", "podspec", "thor", "irb"], + definition: ruby, + }, + { + name: "erb", + dependencies: ["ruby", "xml"], + load: () => import("highlight.js/lib/languages/erb.js"), + }, + { name: "erlang-repl", load: () => import("highlight.js/lib/languages/erlang-repl.js") }, + { + name: "erlang", + extensions: ["erl"], + aliases: ["erl"], + load: () => import("highlight.js/lib/languages/erlang.js"), + }, + { + name: "excel", + aliases: ["xlsx", "xls"], + load: () => import("highlight.js/lib/languages/excel.js"), + }, + { name: "fix", load: () => import("highlight.js/lib/languages/fix.js") }, + { name: "fish", extensions: ["fish"] }, + { name: "flix", load: () => import("highlight.js/lib/languages/flix.js") }, + { + name: "fortran", + aliases: ["f90", "f95"], + load: () => import("highlight.js/lib/languages/fortran.js"), + }, + { name: "fsharp", aliases: ["fs"], load: () => import("highlight.js/lib/languages/fsharp.js") }, + { name: "gams", aliases: ["gms"], load: () => import("highlight.js/lib/languages/gams.js") }, + { name: "gauss", aliases: ["gss"], load: () => import("highlight.js/lib/languages/gauss.js") }, + { name: "gcode", aliases: ["nc"], load: () => import("highlight.js/lib/languages/gcode.js") }, + { + name: "gherkin", + aliases: ["feature"], + load: () => import("highlight.js/lib/languages/gherkin.js"), + }, + { name: "glsl", load: () => import("highlight.js/lib/languages/glsl.js") }, + { name: "gml", load: () => import("highlight.js/lib/languages/gml.js") }, + { name: "go", extensions: ["go"], aliases: ["golang"], load: () => import("highlight.js/lib/languages/go.js") }, + { name: "golo", load: () => import("highlight.js/lib/languages/golo.js") }, + { name: "gradle", load: () => import("highlight.js/lib/languages/gradle.js") }, + { name: "graphql", extensions: ["graphql"] }, + { name: "groovy", load: () => import("highlight.js/lib/languages/groovy.js") }, + { + name: "haml", + dependencies: ["ruby"], + load: () => import("highlight.js/lib/languages/haml.js"), + }, + { + name: "handlebars", + aliases: ["hbs", "html.hbs", "html.handlebars", "htmlbars"], + dependencies: ["xml"], + load: () => import("highlight.js/lib/languages/handlebars.js"), + }, + { + name: "haskell", + extensions: ["hs"], + aliases: ["hs"], + load: () => import("highlight.js/lib/languages/haskell.js"), + }, + { name: "hcl", extensions: ["tf", "hcl"] }, + { name: "haxe", aliases: ["hx"], load: () => import("highlight.js/lib/languages/haxe.js") }, + { name: "hsp", load: () => import("highlight.js/lib/languages/hsp.js") }, + { + name: "htmlbars", + aliases: ["hbs", "html.hbs", "html.handlebars", "htmlbars"], + dependencies: ["xml"], + load: () => import("highlight.js/lib/languages/htmlbars.js"), + }, + { name: "http", aliases: ["https"], load: () => import("highlight.js/lib/languages/http.js") }, + { name: "hy", aliases: ["hylang"], load: () => import("highlight.js/lib/languages/hy.js") }, + { name: "inform7", aliases: ["i7"], load: () => import("highlight.js/lib/languages/inform7.js") }, + { name: "ini", aliases: ["toml"], load: () => import("highlight.js/lib/languages/ini.js") }, + { name: "irpf90", load: () => import("highlight.js/lib/languages/irpf90.js") }, + { name: "isbl", load: () => import("highlight.js/lib/languages/isbl.js") }, + { name: "java", extensions: ["java"], aliases: ["jsp"], load: () => import("highlight.js/lib/languages/java.js") }, + { + name: "javascript", + extensions: ["js", "jsx", "mjs", "cjs"], + aliases: ["js", "jsx", "mjs", "cjs"], + dependencies: ["css", "xml"], + definition: javascript, + }, + { + name: "jboss-cli", + aliases: ["wildfly-cli"], + load: () => import("highlight.js/lib/languages/jboss-cli.js"), + }, + { name: "json", extensions: ["json"], definition: json }, + { name: "julia", load: () => import("highlight.js/lib/languages/julia.js") }, + { + name: "julia-repl", + dependencies: ["julia"], + load: () => import("highlight.js/lib/languages/julia-repl.js"), + }, + { + name: "kotlin", + extensions: ["kt"], + aliases: ["kt", "kts"], + load: () => import("highlight.js/lib/languages/kotlin.js"), + }, + { + name: "lasso", + aliases: ["ls", "lassoscript"], + load: () => import("highlight.js/lib/languages/lasso.js"), + }, + { name: "latex", aliases: ["tex"], load: () => import("highlight.js/lib/languages/latex.js") }, + { name: "ldif", load: () => import("highlight.js/lib/languages/ldif.js") }, + { name: "leaf", load: () => import("highlight.js/lib/languages/leaf.js") }, + { name: "less", extensions: ["less"], load: () => import("highlight.js/lib/languages/less.js") }, + { name: "lisp", load: () => import("highlight.js/lib/languages/lisp.js") }, + { name: "livecodeserver", load: () => import("highlight.js/lib/languages/livecodeserver.js") }, + { + name: "livescript", + aliases: ["ls"], + dependencies: ["javascript"], + load: () => import("highlight.js/lib/languages/livescript.js"), + }, + { name: "llvm", load: () => import("highlight.js/lib/languages/llvm.js") }, + { name: "lsl", load: () => import("highlight.js/lib/languages/lsl.js") }, + { name: "lua", extensions: ["lua"], load: () => import("highlight.js/lib/languages/lua.js") }, + { + name: "makefile", + extensions: ["makefile"], + aliases: ["mk", "mak", "make"], + load: () => import("highlight.js/lib/languages/makefile.js"), + }, + { + name: "mathematica", + aliases: ["mma", "wl"], + load: () => import("highlight.js/lib/languages/mathematica.js"), + }, + { name: "matlab", load: () => import("highlight.js/lib/languages/matlab.js") }, + { name: "maxima", load: () => import("highlight.js/lib/languages/maxima.js") }, + { name: "mel", load: () => import("highlight.js/lib/languages/mel.js") }, + { + name: "mercury", + aliases: ["m", "moo"], + load: () => import("highlight.js/lib/languages/mercury.js"), + }, + { name: "mipsasm", aliases: ["mips"], load: () => import("highlight.js/lib/languages/mipsasm.js") }, + { name: "mizar", load: () => import("highlight.js/lib/languages/mizar.js") }, + { + name: "perl", + extensions: ["perl"], + aliases: ["pl", "pm"], + dependencies: ["mojolicious"], + load: () => import("highlight.js/lib/languages/perl.js"), + }, + { + name: "mojolicious", + dependencies: ["perl", "xml"], + load: () => import("highlight.js/lib/languages/mojolicious.js"), + }, + { name: "monkey", load: () => import("highlight.js/lib/languages/monkey.js") }, + { + name: "moonscript", + aliases: ["moon"], + load: () => import("highlight.js/lib/languages/moonscript.js"), + }, + { name: "n1ql", load: () => import("highlight.js/lib/languages/n1ql.js") }, + { name: "nginx", aliases: ["nginxconf"], load: () => import("highlight.js/lib/languages/nginx.js") }, + { name: "nim", load: () => import("highlight.js/lib/languages/nim.js") }, + { name: "nix", aliases: ["nixos"], load: () => import("highlight.js/lib/languages/nix.js") }, + { + name: "node-repl", + dependencies: ["javascript"], + load: () => import("highlight.js/lib/languages/node-repl.js"), + }, + { name: "nsis", load: () => import("highlight.js/lib/languages/nsis.js") }, + { + name: "objectivec", + aliases: ["mm", "objc", "obj-c", "obj-c++", "objective-c++"], + load: () => import("highlight.js/lib/languages/objectivec.js"), + }, + { name: "ocaml", extensions: ["ml"], aliases: ["ml"], load: () => import("highlight.js/lib/languages/ocaml.js") }, + { + name: "openscad", + aliases: ["scad"], + load: () => import("highlight.js/lib/languages/openscad.js"), + }, + { name: "oxygene", load: () => import("highlight.js/lib/languages/oxygene.js") }, + { + name: "parser3", + dependencies: ["xml"], + load: () => import("highlight.js/lib/languages/parser3.js"), + }, + { name: "pf", aliases: ["pf.conf"], load: () => import("highlight.js/lib/languages/pf.js") }, + { + name: "pgsql", + aliases: ["postgres", "postgresql"], + dependencies: ["bash", "java", "json", "lua", "perl", "php", "python", "r", "ruby", "scheme", "tcl", "xml"], + load: () => import("highlight.js/lib/languages/pgsql.js"), + }, + { + name: "php", + extensions: ["php"], + aliases: ["php3", "php4", "php5", "php6", "php7", "php8"], + load: () => import("highlight.js/lib/languages/php.js"), + }, + { + name: "php-template", + dependencies: ["php", "xml"], + load: () => import("highlight.js/lib/languages/php-template.js"), + }, + { + name: "plaintext", + aliases: ["text", "txt"], + load: () => import("highlight.js/lib/languages/plaintext.js"), + }, + { name: "pony", load: () => import("highlight.js/lib/languages/pony.js") }, + { + name: "powershell", + extensions: ["ps1"], + aliases: ["ps", "ps1"], + load: () => import("highlight.js/lib/languages/powershell.js"), + }, + { name: "processing", load: () => import("highlight.js/lib/languages/processing.js") }, + { name: "profile", load: () => import("highlight.js/lib/languages/profile.js") }, + { name: "prolog", load: () => import("highlight.js/lib/languages/prolog.js") }, + { name: "properties", load: () => import("highlight.js/lib/languages/properties.js") }, + { name: "protobuf", extensions: ["proto"], load: () => import("highlight.js/lib/languages/protobuf.js") }, + { name: "puppet", aliases: ["pp"], load: () => import("highlight.js/lib/languages/puppet.js") }, + { + name: "purebasic", + aliases: ["pb", "pbi"], + load: () => import("highlight.js/lib/languages/purebasic.js"), + }, + { name: "python", extensions: ["py"], aliases: ["py", "gyp", "ipython"], definition: python }, + { + name: "python-repl", + aliases: ["pycon"], + dependencies: ["python"], + load: () => import("highlight.js/lib/languages/python-repl.js"), + }, + { name: "q", aliases: ["k", "kdb"], load: () => import("highlight.js/lib/languages/q.js") }, + { + name: "qml", + aliases: ["qt"], + dependencies: ["xml"], + load: () => import("highlight.js/lib/languages/qml.js"), + }, + { name: "r", extensions: ["r"], load: () => import("highlight.js/lib/languages/r.js") }, + { name: "reasonml", aliases: ["re"], load: () => import("highlight.js/lib/languages/reasonml.js") }, + { name: "rib", load: () => import("highlight.js/lib/languages/rib.js") }, + { + name: "roboconf", + aliases: ["graph", "instances"], + load: () => import("highlight.js/lib/languages/roboconf.js"), + }, + { + name: "routeros", + aliases: ["mikrotik"], + load: () => import("highlight.js/lib/languages/routeros.js"), + }, + { name: "rsl", load: () => import("highlight.js/lib/languages/rsl.js") }, + { name: "ruleslanguage", load: () => import("highlight.js/lib/languages/ruleslanguage.js") }, + { name: "rust", extensions: ["rs"], aliases: ["rs"], load: () => import("highlight.js/lib/languages/rust.js") }, + { name: "sas", load: () => import("highlight.js/lib/languages/sas.js") }, + { name: "sass", extensions: ["sass"] }, + { name: "scala", extensions: ["scala"], load: () => import("highlight.js/lib/languages/scala.js") }, + { name: "scheme", load: () => import("highlight.js/lib/languages/scheme.js") }, + { name: "scilab", aliases: ["sci"], load: () => import("highlight.js/lib/languages/scilab.js") }, + { name: "scss", extensions: ["scss"], load: () => import("highlight.js/lib/languages/scss.js") }, + { + name: "shell", + aliases: ["console"], + dependencies: ["bash"], + load: () => import("highlight.js/lib/languages/shell.js"), + }, + { name: "smali", load: () => import("highlight.js/lib/languages/smali.js") }, + { + name: "smalltalk", + aliases: ["st"], + load: () => import("highlight.js/lib/languages/smalltalk.js"), + }, + { name: "sml", aliases: ["ml"], load: () => import("highlight.js/lib/languages/sml.js") }, + { name: "sqf", load: () => import("highlight.js/lib/languages/sqf.js") }, + { + name: "sql_more", + aliases: ["mysql", "oracle"], + load: () => import("highlight.js/lib/languages/sql_more.js"), + }, + { name: "sql", extensions: ["sql"], load: () => import("highlight.js/lib/languages/sql.js") }, + { name: "stan", aliases: ["stanfuncs"], load: () => import("highlight.js/lib/languages/stan.js") }, + { name: "stata", aliases: ["do", "ado"], load: () => import("highlight.js/lib/languages/stata.js") }, + { + name: "step21", + aliases: ["p21", "step", "stp"], + load: () => import("highlight.js/lib/languages/step21.js"), + }, + { name: "stylus", aliases: ["styl"], load: () => import("highlight.js/lib/languages/stylus.js") }, + { name: "subunit", load: () => import("highlight.js/lib/languages/subunit.js") }, + { name: "swift", extensions: ["swift"], load: () => import("highlight.js/lib/languages/swift.js") }, + { name: "taggerscript", load: () => import("highlight.js/lib/languages/taggerscript.js") }, + { + name: "yaml", + extensions: ["yaml", "yml"], + aliases: ["yml"], + dependencies: ["ruby"], + definition: yaml, + }, + { + name: "tap", + dependencies: ["yaml"], + load: () => import("highlight.js/lib/languages/tap.js"), + }, + { name: "tcl", aliases: ["tk"], load: () => import("highlight.js/lib/languages/tcl.js") }, + { name: "thrift", load: () => import("highlight.js/lib/languages/thrift.js") }, + { name: "toml", extensions: ["toml"], dependencies: ["ini"] }, + { name: "tp", load: () => import("highlight.js/lib/languages/tp.js") }, + { + name: "twig", + aliases: ["craftcms"], + dependencies: ["xml"], + load: () => import("highlight.js/lib/languages/twig.js"), + }, + { + name: "typescript", + extensions: ["ts", "tsx"], + aliases: ["ts", "tsx"], + dependencies: ["css", "xml"], + definition: typescript, + }, + { name: "vala", load: () => import("highlight.js/lib/languages/vala.js") }, + { name: "vbnet", aliases: ["vb"], load: () => import("highlight.js/lib/languages/vbnet.js") }, + { name: "vbscript", aliases: ["vbs"], load: () => import("highlight.js/lib/languages/vbscript.js") }, + { + name: "vbscript-html", + dependencies: ["vbscript", "xml"], + load: () => import("highlight.js/lib/languages/vbscript-html.js"), + }, + { + name: "verilog", + aliases: ["v", "sv", "svh"], + load: () => import("highlight.js/lib/languages/verilog.js"), + }, + { name: "vhdl", load: () => import("highlight.js/lib/languages/vhdl.js") }, + { name: "vim", extensions: ["vim"], load: () => import("highlight.js/lib/languages/vim.js") }, + { name: "x86asm", load: () => import("highlight.js/lib/languages/x86asm.js") }, + { name: "xl", aliases: ["tao"], load: () => import("highlight.js/lib/languages/xl.js") }, + { + name: "xquery", + aliases: ["xpath", "xq"], + dependencies: ["xml"], + load: () => import("highlight.js/lib/languages/xquery.js"), + }, + { name: "zephir", aliases: ["zep"], load: () => import("highlight.js/lib/languages/zephir.js") }, +]; diff --git a/packages/coding-agent/src/utils/syntax-highlight.ts b/packages/coding-agent/src/utils/syntax-highlight.ts index bcc4add1c..293084cc0 100644 --- a/packages/coding-agent/src/utils/syntax-highlight.ts +++ b/packages/coding-agent/src/utils/syntax-highlight.ts @@ -1,4 +1,5 @@ -import hljs from "highlight.js/lib/index.js"; +import hljs, { type LanguageDefinition } from "highlight.js/lib/core.js"; +import { HIGHLIGHT_LANGUAGES, type HighlightLanguage } from "./highlight-languages.ts"; import { decodeHtmlEntityAt } from "./html.ts"; export type HighlightFormatter = (text: string) => string; @@ -11,6 +12,199 @@ export interface HighlightOptions { theme?: HighlightTheme; } +const LANGUAGE_BY_NAME = new Map(); +for (const language of HIGHLIGHT_LANGUAGES) { + LANGUAGE_BY_NAME.set(language.name, language); + for (const alias of language.aliases ?? []) LANGUAGE_BY_NAME.set(alias.toLowerCase(), language); +} + +const LANGUAGE_BY_EXTENSION = new Map( + HIGHLIGHT_LANGUAGES.flatMap(({ name, extensions = [] }) => + extensions.map((extension) => [extension, name] as const), + ), +); + +const registeredLanguages = new Set(); + +function registerLanguage(language: HighlightLanguage, definition: LanguageDefinition): void { + if (registeredLanguages.has(language.name)) return; + hljs.registerLanguage(language.name, definition); + registeredLanguages.add(language.name); +} + +for (const language of HIGHLIGHT_LANGUAGES) { + if (language.definition) registerLanguage(language, language.definition); +} + +type HighlightLanguageLoadEvent = + | { language: string; requiresGlobalRefresh: boolean } + | { language: string; error: unknown }; +type HighlightLanguageLoadListener = (event: HighlightLanguageLoadEvent) => void; + +interface LanguageLoadState { + definition?: LanguageDefinition; + promise: Promise; +} + +interface LanguageRequestState { + promise: Promise; + callbacks: Set<() => void>; + requiresGlobalRefresh: boolean; + status: "pending" | "ready" | "failed"; +} + +interface LanguageRegistryState { + loads: Map; + listeners: Set; + requests?: Map; +} + +// jiti can evaluate this module more than once. Sharing imported definitions +// and listeners lets extension-triggered loads refresh the host TUI. +const LANGUAGE_REGISTRY_KEY = Symbol.for("@earendil-works/pi-coding-agent:highlight-language-loaders-v3"); +const globalRegistry = globalThis as Record; +const languageRegistry: LanguageRegistryState = globalRegistry[LANGUAGE_REGISTRY_KEY] ?? { + loads: new Map(), + listeners: new Set(), +}; +globalRegistry[LANGUAGE_REGISTRY_KEY] = languageRegistry; +if (!languageRegistry.requests) languageRegistry.requests = new Map(); +const languageRequests = languageRegistry.requests; + +function notifyLanguageLoad(event: HighlightLanguageLoadEvent): void { + if (languageRegistry.listeners.size > 0) { + for (const listener of languageRegistry.listeners) listener(event); + } else if ("error" in event) { + const message = event.error instanceof Error ? event.error.message : String(event.error); + console.error(`Failed to load syntax highlighting language "${event.language}": ${message}`); + } +} + +function getLanguageClosure(language: HighlightLanguage): HighlightLanguage[] { + const result: HighlightLanguage[] = []; + const visited = new Set(); + const visit = (current: HighlightLanguage) => { + if (visited.has(current.name)) return; + visited.add(current.name); + for (const dependencyName of current.dependencies ?? []) { + const dependency = LANGUAGE_BY_NAME.get(dependencyName); + if (dependency) visit(dependency); + } + result.push(current); + }; + visit(language); + return result; +} + +function ensureLanguagesRegistered(languages: HighlightLanguage[], requestedName: string): boolean { + for (const language of languages) { + const definition = language.definition ?? languageRegistry.loads.get(language.name)?.definition; + if (definition) registerLanguage(language, definition); + } + return ( + languages.every( + (language) => + registeredLanguages.has(language.name) || + (!language.definition && !language.load && supportsLanguage(language.name)), + ) && supportsLanguage(requestedName) + ); +} + +function loadLanguageDefinition(language: HighlightLanguage): Promise | undefined { + if (language.definition) return Promise.resolve(language.definition); + if (!language.load) return undefined; + + let state = languageRegistry.loads.get(language.name); + if (!state) { + const promise = Promise.resolve() + .then(language.load) + .then( + ({ default: definition }) => { + const currentState = languageRegistry.loads.get(language.name); + if (currentState) currentState.definition = definition; + return definition; + }, + (error: unknown) => { + notifyLanguageLoad({ language: language.name, error }); + throw error; + }, + ); + state = { promise }; + languageRegistry.loads.set(language.name, state); + } + return state.promise; +} + +function getLanguageRequest(language: HighlightLanguage, languages: HighlightLanguage[]): LanguageRequestState { + let request = languageRequests.get(language.name); + if (request) return request; + + const callbacks = new Set<() => void>(); + const promises: Promise[] = []; + for (const current of languages) { + const promise = loadLanguageDefinition(current); + if (promise) promises.push(promise); + } + const promise = Promise.all(promises).then(() => ensureLanguagesRegistered(languages, language.name)); + request = { promise, callbacks, requiresGlobalRefresh: false, status: "pending" }; + languageRequests.set(language.name, request); + void promise.then( + (ready) => { + request.status = ready ? "ready" : "failed"; + if (ready) { + for (const callback of callbacks) callback(); + notifyLanguageLoad({ language: language.name, requiresGlobalRefresh: request.requiresGlobalRefresh }); + } + callbacks.clear(); + }, + () => { + request.status = "failed"; + callbacks.clear(); + }, + ); + return request; +} + +export function onHighlightLanguageLoad(listener: HighlightLanguageLoadListener): () => void { + languageRegistry.listeners.add(listener); + return () => languageRegistry.listeners.delete(listener); +} + +export async function loadHighlightLanguage(name: string): Promise { + const normalizedName = name.toLowerCase(); + const language = LANGUAGE_BY_NAME.get(normalizedName); + if (!language) return supportsLanguage(normalizedName); + + const languages = getLanguageClosure(language); + if (ensureLanguagesRegistered(languages, language.name)) return true; + const request = getLanguageRequest(language, languages); + request.requiresGlobalRefresh = true; + return request.promise; +} + +/** Return the canonical grammar when ready, starting a background load when needed. */ +export function requestHighlightLanguage(name: string, onReady?: () => void): string | undefined { + const normalizedName = name.toLowerCase(); + const language = LANGUAGE_BY_NAME.get(normalizedName); + if (!language) return supportsLanguage(normalizedName) ? normalizedName : undefined; + + const languages = getLanguageClosure(language); + if (ensureLanguagesRegistered(languages, language.name)) return language.name; + if (!languages.some((current) => current.load)) return undefined; + + const request = getLanguageRequest(language, languages); + if (request.status === "pending") { + if (onReady) request.callbacks.add(onReady); + else request.requiresGlobalRefresh = true; + } + return undefined; +} + +export function getLanguageFromPath(filePath: string): string | undefined { + const extension = filePath.split(".").pop()?.toLowerCase(); + return extension ? LANGUAGE_BY_EXTENSION.get(extension) : undefined; +} + const SPAN_CLOSE = ""; const HIGHLIGHT_CLASS_PREFIX = "hljs-"; @@ -142,5 +336,5 @@ export function highlight(code: string, options: HighlightOptions = {}): string } export function supportsLanguage(name: string): boolean { - return hljs.getLanguage(name) !== undefined; + return hljs.getLanguage(name.toLowerCase()) !== undefined; } diff --git a/packages/coding-agent/test/syntax-highlight.test.ts b/packages/coding-agent/test/syntax-highlight.test.ts index 6f311e494..e8a8e2b59 100644 --- a/packages/coding-agent/test/syntax-highlight.test.ts +++ b/packages/coding-agent/test/syntax-highlight.test.ts @@ -1,7 +1,18 @@ -import { resetCapabilitiesCache, setCapabilities } from "@earendil-works/pi-tui"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { resetCapabilitiesCache, setCapabilities, type TUI } from "@earendil-works/pi-tui"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SettingsManager } from "../src/core/settings-manager.ts"; import { highlightCode, initTheme } from "../src/modes/interactive/theme/theme.ts"; -import { highlight, renderHighlightedHtml, supportsLanguage } from "../src/utils/syntax-highlight.ts"; +import { InteractiveThemeController } from "../src/modes/interactive/theme/theme-controller.ts"; +import { HIGHLIGHT_LANGUAGES } from "../src/utils/highlight-languages.ts"; +import { + getLanguageFromPath, + highlight, + loadHighlightLanguage, + onHighlightLanguageLoad, + renderHighlightedHtml, + requestHighlightLanguage, + supportsLanguage, +} from "../src/utils/syntax-highlight.ts"; describe("syntax highlight renderer", () => { it("renders highlighted spans with the provided theme", () => { @@ -47,6 +58,208 @@ describe("syntax highlight renderer", () => { expect(rendered).toContain("[keyword:const]"); expect(rendered).toContain("[number:1]"); }); + + it("maps file extensions and preloads only common languages", () => { + expect(getLanguageFromPath("src/main.ts")).toBe("typescript"); + expect(getLanguageFromPath("public/index.html")).toBe("html"); + expect(getLanguageFromPath("Dockerfile")).toBe("dockerfile"); + expect(getLanguageFromPath("build.ps1")).toBe("powershell"); + expect(getLanguageFromPath("config.toml")).toBe("toml"); + expect(getLanguageFromPath("shell.fish")).toBe("fish"); + expect(getLanguageFromPath("style.sass")).toBe("sass"); + expect(getLanguageFromPath("schema.graphql")).toBe("graphql"); + expect(getLanguageFromPath("main.tf")).toBe("hcl"); + expect(getLanguageFromPath("config.hcl")).toBe("hcl"); + expect(supportsLanguage(getLanguageFromPath("src/main.ts")!)).toBe(true); + expect(supportsLanguage(getLanguageFromPath("public/index.html")!)).toBe(true); + expect(supportsLanguage(getLanguageFromPath("Dockerfile")!)).toBe(false); + expect(supportsLanguage(getLanguageFromPath("build.ps1")!)).toBe(false); + const handlebars = HIGHLIGHT_LANGUAGES.find(({ name }) => name === "handlebars"); + expect(handlebars?.definition).toBeUndefined(); + expect(handlebars?.load).toBeTypeOf("function"); + for (const unsupported of ["fish", "graphql", "hcl", "sass"]) { + expect(requestHighlightLanguage(unsupported)).toBeUndefined(); + } + }); + + it("normalizes names and loads aliases through their canonical grammar", async () => { + expect(requestHighlightLanguage("ToMl")).toBeUndefined(); + expect(await loadHighlightLanguage("TOML")).toBe(true); + expect(supportsLanguage("ini")).toBe(true); + expect(supportsLanguage("toml")).toBe(true); + expect(requestHighlightLanguage("ToMl")).toBe("toml"); + + expect(requestHighlightLanguage("Rust")).toBeUndefined(); + expect(await loadHighlightLanguage("RUST")).toBe(true); + expect(supportsLanguage("rust")).toBe(true); + expect(requestHighlightLanguage("Rust")).toBe("rust"); + + expect(await loadHighlightLanguage("PowerShell")).toBe(true); + expect(supportsLanguage("POWERSHELL")).toBe(true); + }); + + it("keeps duplicate aliases bound to their canonical grammar", async () => { + expect(await loadHighlightLanguage("ls")).toBe(true); + expect(requestHighlightLanguage("ls")).toBe("livescript"); + expect(await loadHighlightLanguage("lasso")).toBe(true); + expect(requestHighlightLanguage("ls")).toBe("livescript"); + + expect(await loadHighlightLanguage("ml")).toBe(true); + expect(requestHighlightLanguage("ml")).toBe("sml"); + expect(await loadHighlightLanguage("ocaml")).toBe(true); + expect(requestHighlightLanguage("ml")).toBe("sml"); + + expect(await loadHighlightLanguage("hbs")).toBe(true); + expect(requestHighlightLanguage("hbs")).toBe("htmlbars"); + expect(await loadHighlightLanguage("handlebars")).toBe(true); + expect(requestHighlightLanguage("hbs")).toBe("htmlbars"); + }); + + it("loads required sublanguage grammars", async () => { + expect(supportsLanguage("ruby")).toBe(true); + expect(await loadHighlightLanguage("ERB")).toBe(true); + expect( + highlight("<%= User.find(1) %>", { + language: "erb", + theme: { number: (text) => `[number:${text}]` }, + }), + ).toContain("[number:1]"); + + expect(supportsLanguage("php")).toBe(false); + expect(await loadHighlightLanguage("php-template")).toBe(true); + expect(supportsLanguage("php")).toBe(true); + expect( + highlight("", { + language: "php-template", + theme: { keyword: (text) => `[keyword:${text}]` }, + }), + ).toContain("[keyword:echo]"); + + expect(await loadHighlightLanguage("mojolicious")).toBe(true); + expect(supportsLanguage("perl")).toBe(true); + }); + + it("loads only the requested uncommon language", async () => { + expect(supportsLanguage("brainfuck")).toBe(false); + expect(supportsLanguage("abnf")).toBe(false); + let notifications = 0; + const unsubscribe = onHighlightLanguageLoad(() => notifications++); + + expect(requestHighlightLanguage("bf")).toBeUndefined(); + expect(await loadHighlightLanguage("bf")).toBe(true); + expect(supportsLanguage("brainfuck")).toBe(true); + expect(supportsLanguage("bf")).toBe(true); + expect(supportsLanguage("abnf")).toBe(false); + expect(notifications).toBe(1); + + expect(await loadHighlightLanguage("abnf")).toBe(true); + expect(supportsLanguage("abnf")).toBe(true); + expect(notifications).toBe(2); + unsubscribe(); + }); + + it("reports background load failures once without retrying", async () => { + const language = HIGHLIGHT_LANGUAGES.find(({ name }) => name === "accesslog"); + if (!language?.load) throw new Error("accesslog test grammar is missing"); + const originalLoad = language.load; + const loadError = new Error("test grammar load failed"); + let attempts = 0; + language.load = () => { + attempts++; + return Promise.reject(loadError); + }; + let resolveFailure: (failure: { language: string; error: unknown }) => void = () => {}; + const failure = new Promise<{ language: string; error: unknown }>((resolve) => { + resolveFailure = resolve; + }); + const unsubscribe = onHighlightLanguageLoad((event) => { + if ("error" in event) resolveFailure({ language: event.language, error: event.error }); + }); + + try { + expect(requestHighlightLanguage("AccessLog")).toBeUndefined(); + await expect(failure).resolves.toEqual({ language: "accesslog", error: loadError }); + expect(requestHighlightLanguage("accesslog")).toBeUndefined(); + await Promise.resolve(); + await Promise.resolve(); + expect(attempts).toBe(1); + await expect(loadHighlightLanguage("accesslog")).rejects.toThrow("test grammar load failed"); + } finally { + language.load = originalLoad; + unsubscribe(); + } + }); +}); + +describe("highlight language load lifecycle", () => { + it("invalidates only affected targets while retaining a global fallback", async () => { + const invalidate = vi.fn(); + const requestRender = vi.fn(); + const unsubscribeTerminalTheme = vi.fn(); + const ui = { + invalidate, + requestRender, + onTerminalColorSchemeChange: () => unsubscribeTerminalTheme, + } as unknown as TUI; + const settingsManager = { getThemeSetting: () => "dark" } as unknown as SettingsManager; + const controller = new InteractiveThemeController(ui, settingsManager, vi.fn(), vi.fn()); + const arcade = HIGHLIGHT_LANGUAGES.find(({ name }) => name === "arcade"); + const oneC = HIGHLIGHT_LANGUAGES.find(({ name }) => name === "1c"); + const eager = HIGHLIGHT_LANGUAGES.find(({ name }) => name === "bash"); + if (!arcade?.load || !oneC?.load || !eager?.definition) throw new Error("highlight test grammars are missing"); + const originalArcadeLoad = arcade.load; + const originalOneCLoad = oneC.load; + arcade.load = () => Promise.resolve({ default: eager.definition! }); + oneC.load = () => Promise.resolve({ default: eager.definition! }); + + try { + const loaded = new Set(); + let resolveLoads = (): void => {}; + const loadsFinished = new Promise((resolve) => { + resolveLoads = resolve; + }); + const unsubscribeLoads = onHighlightLanguageLoad((event) => { + if ("error" in event) return; + loaded.add(event.language); + if (loaded.has("arcade") && loaded.has("1c")) resolveLoads(); + }); + const invalidateArcade = vi.fn(); + const invalidateOneC = vi.fn(); + + highlightCode("let value = 1", "arcade", { onLanguageReady: invalidateArcade }); + highlightCode("let value = 1", "1c", { onLanguageReady: invalidateOneC }); + await loadsFinished; + unsubscribeLoads(); + + expect(invalidateArcade).toHaveBeenCalledOnce(); + expect(invalidateOneC).toHaveBeenCalledOnce(); + expect(invalidate).not.toHaveBeenCalled(); + expect(requestRender).not.toHaveBeenCalled(); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(invalidate).not.toHaveBeenCalled(); + expect(requestRender).toHaveBeenCalledOnce(); + requestRender.mockClear(); + + highlightCode("package main", "ada"); + await loadHighlightLanguage("ada"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(invalidate).toHaveBeenCalledOnce(); + expect(requestRender).toHaveBeenCalledOnce(); + invalidate.mockClear(); + requestRender.mockClear(); + + controller.dispose(); + await loadHighlightLanguage("apache"); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(unsubscribeTerminalTheme).toHaveBeenCalledOnce(); + expect(invalidate).not.toHaveBeenCalled(); + expect(requestRender).not.toHaveBeenCalled(); + } finally { + arcade.load = originalArcadeLoad; + oneC.load = originalOneCLoad; + controller.dispose(); + } + }); }); describe("theme syntax highlighting", () => { diff --git a/packages/tui/src/components/markdown.ts b/packages/tui/src/components/markdown.ts index 608936c7b..9f16c6716 100644 --- a/packages/tui/src/components/markdown.ts +++ b/packages/tui/src/components/markdown.ts @@ -212,7 +212,7 @@ export interface MarkdownTheme { italic: (text: string) => string; strikethrough: (text: string) => string; underline: (text: string) => string; - highlightCode?: (code: string, lang?: string) => string[]; + highlightCode?: (code: string, lang?: string, onLanguageReady?: () => void) => string[]; /** Prefix applied to each rendered code block line (default: " ") */ codeBlockIndent?: string; } @@ -241,6 +241,7 @@ export class Markdown implements Component { private theme: MarkdownTheme; private options: MarkdownOptions; private defaultStylePrefix?: string; + private readonly handleHighlightLanguageReady = (): void => this.invalidate(); // Cache for rendered output private cachedText?: string; @@ -521,7 +522,11 @@ export class Markdown implements Component { const indent = this.theme.codeBlockIndent ?? " "; lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); if (this.theme.highlightCode) { - const highlightedLines = this.theme.highlightCode(token.text, token.lang); + const highlightedLines = this.theme.highlightCode( + token.text, + token.lang, + this.handleHighlightLanguageReady, + ); for (const hlLine of highlightedLines) { lines.push(`${indent}${hlLine}`); } diff --git a/packages/tui/test/markdown.test.ts b/packages/tui/test/markdown.test.ts index d45b7716d..c2513670e 100644 --- a/packages/tui/test/markdown.test.ts +++ b/packages/tui/test/markdown.test.ts @@ -38,6 +38,28 @@ function stripAnsi(line: string): string { describe("Markdown component", () => { describe("Transforms", () => { + it("invalidates its cache when a code language finishes loading", () => { + const callbacks: Array<() => void> = []; + let highlightCalls = 0; + const markdown = new Markdown("```example\ncode\n```", 0, 0, { + ...defaultMarkdownTheme, + highlightCode: (_code, _lang, onLanguageReady) => { + highlightCalls++; + if (onLanguageReady) callbacks.push(onLanguageReady); + return ["code"]; + }, + }); + + markdown.render(80); + markdown.render(80); + assert.strictEqual(highlightCalls, 1); + + callbacks[0]?.(); + markdown.render(80); + assert.strictEqual(highlightCalls, 2); + assert.strictEqual(callbacks[0], callbacks[1]); + }); + it("caches transformed Markdown by source and available width", () => { const calls: Array<{ source: string; availableWidth: number }> = []; const markdown = new Markdown("source", 2, 0, defaultMarkdownTheme, undefined, {