ui-highlight.test.ts 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458
  1. /**
  2. * The viewer's server-side syntax classification (CG-43, rebuilt on the
  3. * engine's own tree-sitter parse in CG-57).
  4. *
  5. * Two things are worth pinning here and they are not the colours. The first is
  6. * that a call-site link lands on the callee's own name — the accent underline
  7. * is the only colour in the code block, and putting it on the receiver or on a
  8. * word inside a comment is worse than not drawing it. The second is that
  9. * highlighting never becomes a way for a source request to fail: a language
  10. * with no grammar, an oversized slice, a minified line all have to answer with
  11. * the source and an honest `engine: 'plain'`.
  12. *
  13. * The end-to-end shape is deliberate: the server's tokens are fed straight
  14. * through the viewer's own `decodeLine` and `assignRefs`, because the seam
  15. * between "how a grammar chose to cut a line" and "which token the overlay
  16. * claims" is exactly where this breaks.
  17. *
  18. * These run against the real grammars, which live in `src/extraction/wasm/`
  19. * and `tree-sitter-wasms` — the same ones indexing uses — so unlike the Shiki
  20. * era there is nothing to build first and nothing to skip.
  21. */
  22. import { describe, it, expect, beforeAll } from 'vitest';
  23. import * as fs from 'fs';
  24. import * as path from 'path';
  25. import {
  26. clearHighlightCache,
  27. grammarFor,
  28. highlightCacheStats,
  29. highlightLines,
  30. isHighlightable,
  31. MAX_HIGHLIGHT_CHARS,
  32. SLICE_CACHE_LINES,
  33. TOKEN_CLASSES,
  34. type HighlightResult,
  35. } from '../src/ui-server/highlight';
  36. import { classifyTree, syntaxRegionsFor } from '../src/extraction/syntax-tokens';
  37. import { getParser, initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars';
  38. import { LANGUAGES } from '../src/types';
  39. import { decodeLine, type Token } from '../ui/src/lib/highlight';
  40. import { assignRefs, type LineRef } from '../ui/src/lib/symbol-model';
  41. function tokensOf(result: HighlightResult, line: number): Token[] {
  42. return decodeLine(result.lines[line] ?? [], result.classes);
  43. }
  44. /** What the code block would render for one line: `class:text` per token. */
  45. function shape(result: HighlightResult, line: number): string[] {
  46. return tokensOf(result, line).map((t) => `${t.cls}:${t.text}`);
  47. }
  48. function lineRef(over: Partial<LineRef>): LineRef {
  49. return {
  50. ident: 'x',
  51. col: null,
  52. targetId: 'method:x',
  53. uncertain: false,
  54. outside: false,
  55. title: '',
  56. ...over,
  57. };
  58. }
  59. /** Which token an overlay ref claims — the whole point of the atomisation. */
  60. function claimedText(result: HighlightResult, line: number, ref: LineRef): string | undefined {
  61. const tokens = tokensOf(result, line);
  62. const claimed = assignRefs(tokens, [ref]);
  63. const [index] = [...claimed.keys()];
  64. return index === undefined ? undefined : tokens[index]?.text;
  65. }
  66. describe('which languages classify', () => {
  67. it('answers for every language the engine indexes, without throwing', () => {
  68. for (const language of LANGUAGES) {
  69. expect(() => grammarFor(language)).not.toThrow();
  70. }
  71. // The ones the classification is measured on all have a grammar.
  72. for (const language of ['typescript', 'go', 'python', 'rust', 'swift', 'csharp', 'ruby', 'php']) {
  73. expect(isHighlightable(language)).toBe(true);
  74. }
  75. });
  76. it('answers null rather than throwing for a language this build never heard of', () => {
  77. expect(grammarFor('some-future-language')).toBeNull();
  78. expect(grammarFor(undefined)).toBeNull();
  79. expect(grammarFor('')).toBeNull();
  80. });
  81. it('reads a single-file component through its script block', () => {
  82. // A .svelte file has no grammar of its own; its symbols live in <script>
  83. // and the extractor hands those to TypeScript. The classifier follows.
  84. expect(grammarFor('svelte')).toBe('typescript');
  85. const regions = syntaxRegionsFor('<p>{x}</p>\n<script lang="ts">\nlet x = 1;\n</script>\n', 'svelte');
  86. expect(regions).toHaveLength(1);
  87. expect(regions?.[0]?.language).toBe('typescript');
  88. });
  89. it('has no grammar for the formats that only have file-level extraction', () => {
  90. for (const language of ['yaml', 'xml', 'properties', 'twig', 'unknown']) {
  91. expect(grammarFor(language)).toBeNull();
  92. }
  93. });
  94. });
  95. describe('classification', () => {
  96. beforeAll(() => clearHighlightCache());
  97. it('reads TypeScript with the classes the theme paints', async () => {
  98. const result = await highlightLines(['const answer = 42; // note'], {
  99. language: 'typescript',
  100. });
  101. expect(result.engine).toBe('tree-sitter');
  102. expect(result.grammar).toBe('typescript');
  103. expect(result.classes).toEqual([...TOKEN_CLASSES]);
  104. const rendered = shape(result, 0);
  105. expect(rendered).toContain('keyword:const');
  106. expect(rendered).toContain('ident:answer');
  107. expect(rendered).toContain('number:42');
  108. expect(rendered).toContain('comment:// note');
  109. });
  110. it('reads a # comment as a comment in Python and as code in TypeScript', async () => {
  111. const python = await highlightLines(['x = 1 # note'], { language: 'python' });
  112. expect(shape(python, 0).at(-1)).toBe('comment:# note');
  113. const ts = await highlightLines(['x = 1 # note'], { language: 'typescript' });
  114. expect(shape(ts, 0).at(-1)).not.toBe('comment:# note');
  115. });
  116. it('carries a block comment across lines within one slice', async () => {
  117. const result = await highlightLines(['/* open', 'still comment', 'done */ const x = 1;'], {
  118. language: 'typescript',
  119. });
  120. expect(shape(result, 1)).toEqual(['comment:still comment']);
  121. expect(shape(result, 2)[0]).toBe('comment:done */');
  122. expect(shape(result, 2)).toContain('keyword:const');
  123. });
  124. it('reads Go, which has its own idea of what a keyword is', async () => {
  125. const result = await highlightLines(['func Greet(name string) string {'], { language: 'go' });
  126. expect(shape(result, 0)).toContain('keyword:func');
  127. expect(shape(result, 0)).toContain('def:Greet');
  128. });
  129. it('reads ArkTS with its own grammar, not TypeScript’s', async () => {
  130. const result = await highlightLines(['@Entry struct Index { build() {} }'], {
  131. language: 'arkts',
  132. });
  133. expect(result.engine).toBe('tree-sitter');
  134. expect(result.grammar).toBe('arkts');
  135. });
  136. it('does not read a type annotation’s `string` as a string literal', async () => {
  137. // An anonymous tree-sitter node's type IS its text, so `string` in a
  138. // signature arrives as a node literally typed `string`. Reading that as a
  139. // string literal greys out half of every signature in TypeScript and PHP.
  140. for (const [language, line] of [
  141. ['typescript', 'function put(key: string): void {}'],
  142. ['php', '<?php function put(string $key): void {}'],
  143. ] as const) {
  144. const result = await highlightLines([line], { language });
  145. expect(shape(result, 0)).toContain('type:string');
  146. expect(shape(result, 0)).not.toContain('string:string');
  147. }
  148. });
  149. it('paints a built-in type the same way in every language', async () => {
  150. // The grammars disagree: `string` is a `type_identifier` in Go and an
  151. // anonymous token inside a `predefined_type` in TypeScript. Left alone that
  152. // is one word painting two ways on the same screen.
  153. for (const [language, line] of [
  154. ['typescript', 'let a: string;'],
  155. ['go', 'var a string'],
  156. ['csharp', 'string a;'],
  157. ['rust', 'let a: u32 = 1;'],
  158. ] as const) {
  159. const rendered = shape(await highlightLines([line], { language }), 0);
  160. expect(rendered.some((t) => t.startsWith('type:'))).toBe(true);
  161. expect(rendered.some((t) => t === 'keyword:string' || t === 'keyword:u32')).toBe(false);
  162. }
  163. });
  164. it('keeps a template literal’s interpolated call as code, so it can link', async () => {
  165. const line = 'const s = `n=${store.size()} done`;';
  166. const result = await highlightLines([line], { language: 'typescript' });
  167. expect(shape(result, 0)).toContain('ident:size');
  168. expect(claimedText(result, 0, lineRef({ ident: 'size' }))).toBe('size');
  169. });
  170. it('marks a definition’s own name, from the extractor’s tables', async () => {
  171. const cases: [string, string, string][] = [
  172. ['typescript', 'export class Store {}', 'Store'],
  173. ['python', 'def put(self):', 'put'],
  174. ['rust', 'pub fn put(&self) {}', 'put'],
  175. ['ruby', 'class Store', 'Store'],
  176. ['csharp', 'public class Store {}', 'Store'],
  177. ['swift', 'final class Store {}', 'Store'],
  178. ];
  179. for (const [language, line, name] of cases) {
  180. const result = await highlightLines([line], { language });
  181. expect(shape(result, 0)).toContain(`def:${name}`);
  182. }
  183. });
  184. it('emits one entry per source line, always', async () => {
  185. const lines = ['a();', '', 'b();', ''];
  186. const result = await highlightLines(lines, { language: 'typescript' });
  187. // The code block indexes rows positionally: one short answer and every
  188. // line below it renders the wrong source.
  189. expect(result.lines).toHaveLength(lines.length);
  190. expect(result.lines[1]).toEqual([]);
  191. });
  192. it('reproduces every line of a real file exactly', async () => {
  193. // The code block renders these tokens and nothing else, so a dropped or
  194. // duplicated character is a corrupted file on screen — silently.
  195. const file = path.join(__dirname, '..', 'src', 'ui-server', 'api', 'source.ts');
  196. const lines = fs.readFileSync(file, 'utf-8').split('\n');
  197. const result = await highlightLines(lines, { language: 'typescript' });
  198. expect(result.engine).toBe('tree-sitter');
  199. result.lines.forEach((row, i) => {
  200. expect(row.map(([, text]) => text).join('')).toBe(lines[i]);
  201. });
  202. });
  203. it('classifies a component’s script and leaves its markup plain', async () => {
  204. const lines = [
  205. '<script lang="ts">',
  206. ' let count = 0;',
  207. '</script>',
  208. '',
  209. '<button onclick={bump}>{count}</button>',
  210. ];
  211. const result = await highlightLines(lines, { language: 'svelte' });
  212. expect(result.engine).toBe('tree-sitter');
  213. expect(shape(result, 1)).toContain('keyword:let');
  214. // The markup still splits into identifiers, so a call site in it links.
  215. expect(claimedText(result, 4, lineRef({ ident: 'bump' }))).toBe('bump');
  216. expect(result.lines.map((row) => row.map(([, t]) => t).join(''))).toEqual(lines);
  217. });
  218. });
  219. describe('the plain fallback', () => {
  220. beforeAll(() => clearHighlightCache());
  221. it('answers plain, with a reason, for a language no grammar covers', async () => {
  222. const result = await highlightLines(['whatever this is'], { language: 'unknown' });
  223. expect(result.engine).toBe('plain');
  224. expect(result.grammar).toBeNull();
  225. expect(result.reason).toBeTruthy();
  226. expect(result.lines).toHaveLength(1);
  227. });
  228. it('still splits identifiers when it cannot highlight, so the links land', async () => {
  229. const result = await highlightLines([' return this.mutex.withLock();'], {
  230. language: 'unknown',
  231. });
  232. expect(claimedText(result, 0, lineRef({ ident: 'withLock', col: 9 }))).toBe('withLock');
  233. });
  234. it('refuses to classify a minified line rather than wedging on it', async () => {
  235. const enormous = 'a'.repeat(MAX_HIGHLIGHT_CHARS + 1);
  236. const result = await highlightLines([enormous], { language: 'javascript' });
  237. expect(result.engine).toBe('plain');
  238. expect(result.reason).toMatch(/minified/);
  239. // The source still comes back whole — that is the part that matters.
  240. expect(result.lines[0]?.map(([, text]) => text).join('')).toHaveLength(enormous.length);
  241. });
  242. it('answers plain for a component whose script block is empty', async () => {
  243. const result = await highlightLines(['<p>hello</p>'], { language: 'svelte' });
  244. expect(result.engine).toBe('plain');
  245. expect(result.lines[0]?.map(([, text]) => text).join('')).toBe('<p>hello</p>');
  246. });
  247. });
  248. describe('graph links land on the right token', () => {
  249. beforeAll(() => clearHighlightCache());
  250. it('marks the callee, not the receiver the recorded column points at', async () => {
  251. // The recorded column is the start of the calling EXPRESSION — `this` —
  252. // and the underline has to end up on `withLock`.
  253. const line = ' return this.indexMutex.withLock(async () => {';
  254. const result = await highlightLines([line], { language: 'typescript' });
  255. expect(claimedText(result, 0, lineRef({ ident: 'withLock', col: line.indexOf('this') }))).toBe(
  256. 'withLock'
  257. );
  258. });
  259. it('lands on a real call site in the engine’s own src/index.ts', async () => {
  260. const file = path.join(__dirname, '..', 'src', 'index.ts');
  261. const source = fs.readFileSync(file, 'utf-8').split('\n');
  262. // A line the engine actually contains, found rather than hard-coded, so a
  263. // refactor of index.ts retires this test instead of silently passing.
  264. const index = source.findIndex((l) => /^\s*(?:return |const \w+ = )?this\.\w+\.\w+\(/.test(l));
  265. expect(index).toBeGreaterThanOrEqual(0);
  266. const line = source[index] as string;
  267. const match = /this\.(\w+)\.(\w+)\(/.exec(line) as RegExpExecArray;
  268. const callee = match[2] as string;
  269. const result = await highlightLines([line], { language: 'typescript' });
  270. expect(claimedText(result, 0, lineRef({ ident: callee, col: line.indexOf('this') }))).toBe(
  271. callee
  272. );
  273. });
  274. it('lands on a Go method call', async () => {
  275. const line = '\tresult := s.repo.FindByID(ctx, id)';
  276. const result = await highlightLines([line], { language: 'go' });
  277. expect(claimedText(result, 0, lineRef({ ident: 'FindByID', col: line.indexOf('s.repo') }))).toBe(
  278. 'FindByID'
  279. );
  280. });
  281. it('lands on a Python method call, not on the receiver of the same name', async () => {
  282. const line = ' return self.store.join(self.store.path)';
  283. const result = await highlightLines([line], { language: 'python' });
  284. expect(claimedText(result, 0, lineRef({ ident: 'join', col: line.indexOf('self') }))).toBe(
  285. 'join'
  286. );
  287. });
  288. it('leaves a word inside a comment or a string alone', async () => {
  289. const result = await highlightLines(
  290. [' // call render here', ' const s = "render";'],
  291. { language: 'typescript' }
  292. );
  293. expect(claimedText(result, 0, lineRef({ ident: 'render' }))).toBeUndefined();
  294. expect(claimedText(result, 1, lineRef({ ident: 'render' }))).toBeUndefined();
  295. });
  296. it('keeps every identifier separately claimable', async () => {
  297. const result = await highlightLines(['render(); render();'], { language: 'typescript' });
  298. const tokens = tokensOf(result, 0);
  299. const claimed = assignRefs(tokens, [
  300. lineRef({ ident: 'render', targetId: 'a' }),
  301. lineRef({ ident: 'render', targetId: 'b' }),
  302. ]);
  303. expect(claimed.size).toBe(2);
  304. });
  305. it('keeps a type name claimable — it is a distinct class, not an excluded one', async () => {
  306. const result = await highlightLines(['let store: Store = make();'], { language: 'typescript' });
  307. expect(shape(result, 0)).toContain('type:Store');
  308. expect(claimedText(result, 0, lineRef({ ident: 'Store' }))).toBe('Store');
  309. });
  310. it('reproduces the line exactly — the code block renders these tokens', async () => {
  311. const line = ' const s = `a ${b.c()} d`; // 1 + 2';
  312. const result = await highlightLines([line], { language: 'typescript' });
  313. expect(
  314. tokensOf(result, 0)
  315. .map((t) => t.text)
  316. .join('')
  317. ).toBe(line);
  318. });
  319. });
  320. describe('cost', () => {
  321. it('classifies three thousand lines of TypeScript well inside the budget', async () => {
  322. clearHighlightCache();
  323. const lines = fs
  324. .readFileSync(path.join(__dirname, '..', 'src', 'extraction', 'tree-sitter.ts'), 'utf-8')
  325. .split('\n')
  326. .slice(0, 3000);
  327. // Warm the grammar load, which is a one-off per language per process.
  328. await highlightLines(lines.slice(0, 5), { language: 'typescript' });
  329. clearHighlightCache();
  330. const started = Date.now();
  331. const result = await highlightLines(lines, { language: 'typescript' });
  332. const elapsed = Date.now() - started;
  333. expect(result.engine).toBe('tree-sitter');
  334. // The whole point of CG-57's swap: the TextMate grammar took ~700 ms here.
  335. // Generous against a loaded CI box; the dev Mac measures 24–41 ms.
  336. expect(elapsed).toBeLessThan(400);
  337. });
  338. it('answers a cached slice without re-classifying it', async () => {
  339. clearHighlightCache();
  340. const lines = fs
  341. .readFileSync(path.join(__dirname, '..', 'src', 'ui-server', 'api', 'source.ts'), 'utf-8')
  342. .split('\n');
  343. const cold = Date.now();
  344. await highlightLines(lines, { language: 'typescript', cacheKey: 'a:1:9999' });
  345. const coldMs = Date.now() - cold;
  346. const warm = Date.now();
  347. const second = await highlightLines(lines, { language: 'typescript', cacheKey: 'a:1:9999' });
  348. const warmMs = Date.now() - warm;
  349. expect(second.engine).toBe('tree-sitter');
  350. // The cache is what makes a re-render free: every resize, theme flip and
  351. // step back through the trail re-asks for the same slice.
  352. expect(warmMs).toBeLessThan(Math.max(20, coldMs / 4));
  353. });
  354. it('bounds the cache by total lines, not just by entry count', async () => {
  355. clearHighlightCache();
  356. const big = new Array(Math.ceil(SLICE_CACHE_LINES / 2) + 10).fill('x');
  357. // The entry count alone would let a reader left open on a big repo grow
  358. // without limit: three of these is well inside SLICE_CACHE_LIMIT and well
  359. // over the line budget.
  360. for (const key of ['one', 'two', 'three']) {
  361. await highlightLines(big, { language: 'unknown', cacheKey: key });
  362. }
  363. const stats = highlightCacheStats();
  364. expect(stats.entries).toBeLessThan(3);
  365. expect(stats.lines).toBeLessThanOrEqual(SLICE_CACHE_LINES);
  366. });
  367. it('keys the cache on the content, so an edited file re-classifies', async () => {
  368. clearHighlightCache();
  369. const first = await highlightLines(['const a = 1;'], {
  370. language: 'typescript',
  371. cacheKey: 'hash-one:1:1',
  372. });
  373. const second = await highlightLines(['const bbb = 2;'], {
  374. language: 'typescript',
  375. cacheKey: 'hash-two:1:1',
  376. });
  377. expect(first.lines[0]?.map(([, t]) => t).join('')).toBe('const a = 1;');
  378. expect(second.lines[0]?.map(([, t]) => t).join('')).toBe('const bbb = 2;');
  379. });
  380. });
  381. describe('the classifier itself', () => {
  382. it('covers the source with ordered, non-overlapping spans', async () => {
  383. const source = fs
  384. .readFileSync(path.join(__dirname, '..', 'src', 'ui-server', 'api', 'flow.ts'), 'utf-8')
  385. .slice(0, 40_000);
  386. await initGrammars();
  387. await loadGrammarsForLanguages(['typescript']);
  388. const parser = getParser('typescript');
  389. expect(parser).not.toBeNull();
  390. const tree = (parser as NonNullable<typeof parser>).parse(source);
  391. const spans = classifyTree((tree as NonNullable<typeof tree>).rootNode, source, 'typescript');
  392. expect(spans.length).toBeGreaterThan(1000);
  393. let previous = 0;
  394. for (const span of spans) {
  395. expect(span.start).toBeGreaterThanOrEqual(previous);
  396. expect(span.end).toBeGreaterThan(span.start);
  397. previous = span.end;
  398. }
  399. expect(previous).toBeLessThanOrEqual(source.length);
  400. // Everything the walk did not claim is whitespace the caller fills in.
  401. const uncovered: string[] = [];
  402. let at = 0;
  403. for (const span of spans) {
  404. if (span.start > at) uncovered.push(source.slice(at, span.start));
  405. at = span.end;
  406. }
  407. expect(uncovered.every((gap) => gap.trim() === '')).toBe(true);
  408. });
  409. });