diff --git a/ui-tui/src/__tests__/syntax.test.ts b/ui-tui/src/__tests__/syntax.test.ts new file mode 100644 index 00000000000..505988b2abf --- /dev/null +++ b/ui-tui/src/__tests__/syntax.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from 'vitest' + +import { highlightLine, isHighlightable } from '../lib/syntax.js' +import { DEFAULT_THEME } from '../theme.js' + +const t = DEFAULT_THEME + +describe('syntax highlighter', () => { + it('recognizes supported langs and aliases', () => { + expect(isHighlightable('ts')).toBe(true) + expect(isHighlightable('js')).toBe(true) + expect(isHighlightable('python')).toBe(true) + expect(isHighlightable('rs')).toBe(true) + expect(isHighlightable('bash')).toBe(true) + expect(isHighlightable('whatever')).toBe(false) + expect(isHighlightable('')).toBe(false) + }) + + it('paints a whole-line comment dim', () => { + const tokens = highlightLine('// hello', 'ts', t) + + expect(tokens).toEqual([[t.color.dim, '// hello']]) + }) + + it('paints keywords, strings, and numbers in a ts line', () => { + const tokens = highlightLine(`const x = 'hi' + 42`, 'ts', t) + const colors = tokens.map(tok => tok[0]) + + expect(colors).toContain(t.color.bronze) // const + expect(colors).toContain(t.color.amber) // 'hi' + expect(colors).toContain(t.color.cornsilk) // 42 + }) + + it('falls through unchanged for unknown langs', () => { + const tokens = highlightLine(`const x = 1`, 'zzz', t) + + expect(tokens).toEqual([['', 'const x = 1']]) + }) + + it('treats `#` as a python comment, not a selector', () => { + const tokens = highlightLine('# comment', 'py', t) + + expect(tokens).toEqual([[t.color.dim, '# comment']]) + }) +}) diff --git a/ui-tui/src/components/markdown.tsx b/ui-tui/src/components/markdown.tsx index 865ab857960..d43357b6918 100644 --- a/ui-tui/src/components/markdown.tsx +++ b/ui-tui/src/components/markdown.tsx @@ -1,6 +1,7 @@ import { Box, Text } from '@hermes/ink' import { memo, type ReactNode, useMemo } from 'react' +import { highlightLine, isHighlightable } from '../lib/syntax.js' import type { Theme } from '../theme.js' const FENCE_RE = /^\s*(`{3,}|~{3,})(.*)$/ @@ -282,11 +283,28 @@ function MdImpl({ compact, t, text }: MdProps) { start('code') const isDiff = lang === 'diff' + const highlighted = !isDiff && isHighlightable(lang) nodes.push( {lang && !isDiff && {'─ ' + lang}} {block.map((l, j) => { + if (highlighted) { + return ( + + {highlightLine(l, lang, t).map(([color, text], k) => + color ? ( + + {text} + + ) : ( + {text} + ) + )} + + ) + } + const add = isDiff && l.startsWith('+') const del = isDiff && l.startsWith('-') const hunk = isDiff && l.startsWith('@@') diff --git a/ui-tui/src/lib/syntax.ts b/ui-tui/src/lib/syntax.ts new file mode 100644 index 00000000000..06173b63e9f --- /dev/null +++ b/ui-tui/src/lib/syntax.ts @@ -0,0 +1,117 @@ +import type { Theme } from '../theme.js' + +export type Token = [string, string] + +interface LangSpec { + comment: null | string + keywords: Set +} + +const KW = (s: string) => new Set(s.split(/\s+/).filter(Boolean)) + +const TS = KW(` + abstract as async await break case catch class const continue debugger default delete do else enum export extends + false finally for from function get if implements import in instanceof interface is let new null of package private + protected public readonly return set static super switch this throw true try type typeof undefined var void while + with yield +`) + +const PY = KW(` + 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 +`) + +const SH = KW(` + if then else elif fi for in do done while until case esac function return break continue local export readonly + declare typeset +`) + +const GO = KW(` + 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 +`) + +const RUST = KW(` + 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 yield +`) + +const SQL = KW(` + select from where and or not in is null as by group order limit offset insert into values update set delete create + table drop alter add column primary key foreign references join left right inner outer on +`) + +const LANGS: Record = { + go: { comment: '//', keywords: GO }, + json: { comment: null, keywords: KW('true false null') }, + py: { comment: '#', keywords: PY }, + rust: { comment: '//', keywords: RUST }, + sh: { comment: '#', keywords: SH }, + sql: { comment: '--', keywords: SQL }, + ts: { comment: '//', keywords: TS }, + yaml: { comment: '#', keywords: KW('true false null yes no on off') } +} + +const ALIAS: Record = { + bash: 'sh', + javascript: 'ts', + js: 'ts', + jsx: 'ts', + python: 'py', + rs: 'rust', + shell: 'sh', + tsx: 'ts', + typescript: 'ts', + yml: 'yaml', + zsh: 'sh' +} + +const resolve = (lang: string): LangSpec | null => LANGS[ALIAS[lang] ?? lang] ?? null + +export const isHighlightable = (lang: string): boolean => resolve(lang) !== null + +const TOKEN_RE = /'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"|`(?:[^`\\]|\\.)*`|\b\d+(?:\.\d+)?\b|[A-Za-z_$][\w$]*/g + +export function highlightLine(line: string, lang: string, t: Theme): Token[] { + const spec = resolve(lang) + + if (!spec) { + return [['', line]] + } + + if (spec.comment && line.trimStart().startsWith(spec.comment)) { + return [[t.color.dim, line]] + } + + const tokens: Token[] = [] + let last = 0 + + for (const m of line.matchAll(TOKEN_RE)) { + const start = m.index ?? 0 + + if (start > last) { + tokens.push(['', line.slice(last, start)]) + } + + const tok = m[0] + const ch = tok[0]! + + if (ch === '"' || ch === "'" || ch === '`') { + tokens.push([t.color.amber, tok]) + } else if (ch >= '0' && ch <= '9') { + tokens.push([t.color.cornsilk, tok]) + } else if (spec.keywords.has(tok)) { + tokens.push([t.color.bronze, tok]) + } else { + tokens.push(['', tok]) + } + + last = start + tok.length + } + + if (last < line.length) { + tokens.push(['', line.slice(last)]) + } + + return tokens +}