SPB Git

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%
6.1 KB · 189 lines typescript
Raw Blame History
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