/** * KHAELOR * File: src/tui/markdown/highlight.ts * Description: Hand-rolled per-line syntax tokenizer for common languages — no dependencies, theme-driven colors. * * Author: Simon-Pierre Boucher * Contact: contact@spboucher.ai */ export type SynRole = | "synKeyword" | "synString" | "synComment" | "synNumber" | "synFunction" | "synType" | "text"; export interface SynSpan { text: string; role: SynRole; } interface LangRules { keywords: ReadonlySet; lineComment: string | null; hashComment: boolean; } const TS_KEYWORDS = new Set( ( "abstract any as async await boolean break case catch class const continue debugger declare " + "default delete do else enum export extends false finally for from function if implements " + "import in instanceof interface keyof let namespace never new null number object of override " + "private protected public readonly return satisfies static string super switch this throw true " + "try type typeof undefined unknown var void while yield" ).split(" "), ); const PY_KEYWORDS = new Set( ( "False None True and as assert async await break class continue def del elif else except " + "finally for from global if import in is lambda nonlocal not or pass raise return try while " + "with yield match case self" ).split(" "), ); const SH_KEYWORDS = new Set( ( "if then else elif fi for while until do done case esac function in select time export local " + "return exit echo cd set unset readonly declare source" ).split(" "), ); const RUST_KEYWORDS = new Set( ( "as async await break const continue crate dyn else enum extern false fn for if impl in let " + "loop match mod move mut pub ref return self Self static struct super trait true type unsafe " + "use where while" ).split(" "), ); const GO_KEYWORDS = new Set( ( "break case chan const continue default defer else fallthrough for func go goto if import " + "interface map package range return select struct switch type var nil true false" ).split(" "), ); const JSON_KEYWORDS = new Set(["true", "false", "null"]); const LANGS: Record = { ts: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false }, tsx: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false }, js: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false }, jsx: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false }, javascript: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false }, typescript: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false }, json: { keywords: JSON_KEYWORDS, lineComment: null, hashComment: false }, jsonc: { keywords: JSON_KEYWORDS, lineComment: "//", hashComment: false }, py: { keywords: PY_KEYWORDS, lineComment: null, hashComment: true }, python: { keywords: PY_KEYWORDS, lineComment: null, hashComment: true }, sh: { keywords: SH_KEYWORDS, lineComment: null, hashComment: true }, bash: { keywords: SH_KEYWORDS, lineComment: null, hashComment: true }, zsh: { keywords: SH_KEYWORDS, lineComment: null, hashComment: true }, shell: { keywords: SH_KEYWORDS, lineComment: null, hashComment: true }, yaml: { keywords: new Set(["true", "false", "null"]), lineComment: null, hashComment: true }, yml: { keywords: new Set(["true", "false", "null"]), lineComment: null, hashComment: true }, toml: { keywords: new Set(["true", "false"]), lineComment: null, hashComment: true }, rust: { keywords: RUST_KEYWORDS, lineComment: "//", hashComment: false }, rs: { keywords: RUST_KEYWORDS, lineComment: "//", hashComment: false }, go: { keywords: GO_KEYWORDS, lineComment: "//", hashComment: false }, }; const WORD_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/; const NUMBER_RE = /^(?:0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?)/; /** * Tokenize one line of source into styled spans. Line-scoped by design * (multi-line strings/comments degrade to plain text — acceptable for V1 * terminal display; never wrong output, only less color). */ export function highlightLine(line: string, lang: string | null): SynSpan[] { const rules = lang ? LANGS[lang.toLowerCase()] : undefined; if (!rules) { return line === "" ? [] : [{ text: line, role: "text" }]; } const spans: SynSpan[] = []; let plain = ""; const flush = (): void => { if (plain !== "") { spans.push({ text: plain, role: "text" }); plain = ""; } }; let i = 0; while (i < line.length) { const rest = line.slice(i); const ch = line[i] as string; // Comments to end of line. if (rules.lineComment && rest.startsWith(rules.lineComment)) { flush(); spans.push({ text: rest, role: "synComment" }); break; } if (rules.hashComment && ch === "#") { flush(); spans.push({ text: rest, role: "synComment" }); break; } // Strings (single-line portion). if (ch === '"' || ch === "'" || ch === "`") { let j = i + 1; while (j < line.length) { if (line[j] === "\\") { j += 2; continue; } if (line[j] === ch) { j += 1; break; } j += 1; } flush(); spans.push({ text: line.slice(i, Math.min(j, line.length)), role: "synString" }); i = Math.min(j, line.length); continue; } // Numbers. const num = NUMBER_RE.exec(rest); if (num && !/[A-Za-z0-9_$]/.test(line[i - 1] ?? " ")) { flush(); spans.push({ text: num[0], role: "synNumber" }); i += num[0].length; continue; } // Words → keyword, call site, type name, or plain. const word = WORD_RE.exec(rest); if (word) { if (rules.keywords.has(word[0])) { flush(); spans.push({ text: word[0], role: "synKeyword" }); } else if (rest[word[0].length] === "(") { flush(); spans.push({ text: word[0], role: "synFunction" }); } else if (/^[A-Z]/.test(word[0])) { flush(); spans.push({ text: word[0], role: "synType" }); } else { plain += word[0]; } i += word[0].length; continue; } plain += ch; i += 1; } flush(); return spans; }