spb/khaelor Public
KHAELOR — a terminal-native autonomous engineering agent powered by Anthropic.
TypeScript 82.9%
HTML 14.9%
CSS 1.1%
JavaScript 0.7%
1/**2 * KHAELOR3 * File: src/tui/markdown/highlight.ts4 * Description: Hand-rolled per-line syntax tokenizer for common languages — no dependencies, theme-driven colors.5 *6 * Author: Simon-Pierre Boucher7 * Contact: contact@spboucher.ai8 */910export type SynRole =11 | "synKeyword"12 | "synString"13 | "synComment"14 | "synNumber"15 | "synFunction"16 | "synType"17 | "text";1819export interface SynSpan {20 text: string;21 role: SynRole;22}2324interface LangRules {25 keywords: ReadonlySet<string>;26 lineComment: string | null;27 hashComment: boolean;28}2930const TS_KEYWORDS = new Set(31 (32 "abstract any as async await boolean break case catch class const continue debugger declare " +33 "default delete do else enum export extends false finally for from function if implements " +34 "import in instanceof interface keyof let namespace never new null number object of override " +35 "private protected public readonly return satisfies static string super switch this throw true " +36 "try type typeof undefined unknown var void while yield"37 ).split(" "),38);3940const PY_KEYWORDS = new Set(41 (42 "False None True and as assert async await break class continue def del elif else except " +43 "finally for from global if import in is lambda nonlocal not or pass raise return try while " +44 "with yield match case self"45 ).split(" "),46);4748const SH_KEYWORDS = new Set(49 (50 "if then else elif fi for while until do done case esac function in select time export local " +51 "return exit echo cd set unset readonly declare source"52 ).split(" "),53);5455const RUST_KEYWORDS = new Set(56 (57 "as async await break const continue crate dyn else enum extern false fn for if impl in let " +58 "loop match mod move mut pub ref return self Self static struct super trait true type unsafe " +59 "use where while"60 ).split(" "),61);6263const GO_KEYWORDS = new Set(64 (65 "break case chan const continue default defer else fallthrough for func go goto if import " +66 "interface map package range return select struct switch type var nil true false"67 ).split(" "),68);6970const JSON_KEYWORDS = new Set(["true", "false", "null"]);7172const LANGS: Record<string, LangRules> = {73 ts: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false },74 tsx: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false },75 js: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false },76 jsx: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false },77 javascript: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false },78 typescript: { keywords: TS_KEYWORDS, lineComment: "//", hashComment: false },79 json: { keywords: JSON_KEYWORDS, lineComment: null, hashComment: false },80 jsonc: { keywords: JSON_KEYWORDS, lineComment: "//", hashComment: false },81 py: { keywords: PY_KEYWORDS, lineComment: null, hashComment: true },82 python: { keywords: PY_KEYWORDS, lineComment: null, hashComment: true },83 sh: { keywords: SH_KEYWORDS, lineComment: null, hashComment: true },84 bash: { keywords: SH_KEYWORDS, lineComment: null, hashComment: true },85 zsh: { keywords: SH_KEYWORDS, lineComment: null, hashComment: true },86 shell: { keywords: SH_KEYWORDS, lineComment: null, hashComment: true },87 yaml: { keywords: new Set(["true", "false", "null"]), lineComment: null, hashComment: true },88 yml: { keywords: new Set(["true", "false", "null"]), lineComment: null, hashComment: true },89 toml: { keywords: new Set(["true", "false"]), lineComment: null, hashComment: true },90 rust: { keywords: RUST_KEYWORDS, lineComment: "//", hashComment: false },91 rs: { keywords: RUST_KEYWORDS, lineComment: "//", hashComment: false },92 go: { keywords: GO_KEYWORDS, lineComment: "//", hashComment: false },93};9495const WORD_RE = /^[A-Za-z_$][A-Za-z0-9_$]*/;96const NUMBER_RE = /^(?:0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?:\.\d+)?(?:[eE][+-]?\d+)?)/;9798/**99 * Tokenize one line of source into styled spans. Line-scoped by design100 * (multi-line strings/comments degrade to plain text — acceptable for V1101 * terminal display; never wrong output, only less color).102 */103export function highlightLine(line: string, lang: string | null): SynSpan[] {104 const rules = lang ? LANGS[lang.toLowerCase()] : undefined;105 if (!rules) {106 return line === "" ? [] : [{ text: line, role: "text" }];107 }108109 const spans: SynSpan[] = [];110 let plain = "";111 const flush = (): void => {112 if (plain !== "") {113 spans.push({ text: plain, role: "text" });114 plain = "";115 }116 };117118 let i = 0;119 while (i < line.length) {120 const rest = line.slice(i);121 const ch = line[i] as string;122123 // Comments to end of line.124 if (rules.lineComment && rest.startsWith(rules.lineComment)) {125 flush();126 spans.push({ text: rest, role: "synComment" });127 break;128 }129 if (rules.hashComment && ch === "#") {130 flush();131 spans.push({ text: rest, role: "synComment" });132 break;133 }134135 // Strings (single-line portion).136 if (ch === '"' || ch === "'" || ch === "`") {137 let j = i + 1;138 while (j < line.length) {139 if (line[j] === "\\") {140 j += 2;141 continue;142 }143 if (line[j] === ch) {144 j += 1;145 break;146 }147 j += 1;148 }149 flush();150 spans.push({ text: line.slice(i, Math.min(j, line.length)), role: "synString" });151 i = Math.min(j, line.length);152 continue;153 }154155 // Numbers.156 const num = NUMBER_RE.exec(rest);157 if (num && !/[A-Za-z0-9_$]/.test(line[i - 1] ?? " ")) {158 flush();159 spans.push({ text: num[0], role: "synNumber" });160 i += num[0].length;161 continue;162 }163164 // Words → keyword, call site, type name, or plain.165 const word = WORD_RE.exec(rest);166 if (word) {167 if (rules.keywords.has(word[0])) {168 flush();169 spans.push({ text: word[0], role: "synKeyword" });170 } else if (rest[word[0].length] === "(") {171 flush();172 spans.push({ text: word[0], role: "synFunction" });173 } else if (/^[A-Z]/.test(word[0])) {174 flush();175 spans.push({ text: word[0], role: "synType" });176 } else {177 plain += word[0];178 }179 i += word[0].length;180 continue;181 }182183 plain += ch;184 i += 1;185 }186 flush();187 return spans;188}189