feat(ui): highlight source server-side with a near-monochrome Shiki theme (CG-43)

The viewer's code block stops lexing with a hand-rolled dialect table and
reads real TextMate grammars instead, run once in `/api/source`.

Three things make that safe to depend on:

* Highlighting never fails a request. A missing grammar, an oversized
  slice, an ESM import that did not resolve — every one of them answers
  `engine: 'plain'` with a reason and the source still goes out.
* Identifiers survive whatever token boundaries a grammar chose. Every
  code token is split into identifier runs before it goes on the wire, so
  the graph's call-site overlay claims a token the highlighter produced
  rather than re-cutting the line. `assignRefs` now matches on a token's
  text rather than on the class a grammar gave it, so a language that
  scopes type names as `storage.type` still links.
* The theme classifies rather than colours: its foregrounds are sentinels
  the server maps back to class names, and the viewer paints them from
  CSS custom properties — one token stream serves light and dark with no
  refetch, and the ramp lives only in app.css.

Comments move from --ink-3 to a new --code-comment. --ink-3 measures
3.46:1 on paper and 3.00:1 on the hot-line tint, both under AA for 12.5px
text; --code-comment is the smallest step along the same ramp that clears
4.5:1 on every background a code line can have, and stays quieter than
the strings and numbers above it.

Shipping: @shikijs/core and @shikijs/engine-javascript are runtime
dependencies (no wasm, no native module); @shikijs/langs stays a
devDependency and `npm run build:textmate` writes only the closure the
engine's 40-odd languages reach — 56 grammars, 2.6 MB, against 11 MB for
all 722. check-ui-build.mjs asserts the tree after every build and inside
every release archive.
This commit is contained in:
Colby McHenry
2026-08-27 01:23:10 -05:00
parent 87afc50e76
commit 2ad836d935
22 changed files with 2117 additions and 405 deletions
+6 -1
View File
@@ -11,7 +11,7 @@ Distributed as `@colbymchenry/codegraph` on npm; same binary serves as installer
## Build, Test, Run
```bash
npm run build # tsc + copy schema.sql and *.wasm into dist/; chmods dist/bin/codegraph.js
npm run build # tsc + copy schema.sql and *.wasm + prune TextMate grammars + build the viewer into dist/; chmods dist/bin/codegraph.js
npm run dev # tsc --watch
npm run clean # rm -rf dist
@@ -29,6 +29,11 @@ npx vitest run __tests__/extraction.test.ts -t "TypeScript"
`copy-assets` (called from `build`) copies `src/db/schema.sql` and all `src/extraction/wasm/*.wasm` files into `dist/`. **Any new SQL or grammar wasm must be copied or it won't ship.**
Two other build steps write into `dist/` and are subject to the same rule:
`build:textmate` (`scripts/prune-grammars.mjs`) writes the viewer's syntax grammars to `dist/textmate/`, and
`build:ui` builds the browser viewer into `dist/viewer/` (never `dist/ui/` — that's the terminal ui).
`scripts/check-ui-build.mjs` asserts both trees after every build and inside every release archive.
Node engines: `>=20.0.0 <25.0.0`. There is a hard exit on Node 25.x and below 20 (see `src/bin/node-version-check.ts`).
## Architecture
+349
View File
@@ -0,0 +1,349 @@
/**
* The viewer's server-side syntax classification (CG-43).
*
* Two things are worth pinning here and they are not the colours. The first is
* that a call-site link lands on the callee's own name — the accent underline
* is the only colour in the code block, and putting it on the receiver or on a
* word inside a comment is worse than not drawing it. The second is that
* highlighting never becomes a way for a source request to fail: a missing
* grammar, an oversized slice, a language nobody wrote a grammar for all have
* to answer with the source and an honest `engine: 'plain'`.
*
* The end-to-end shape is deliberate: the server's tokens are fed straight
* through the viewer's own `decodeLine` and `assignRefs`, because the seam
* between "how a grammar chose to cut a line" and "which token the overlay
* claims" is exactly where this breaks.
*/
import { describe, it, expect, beforeAll, vi } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import {
clearHighlightCache,
grammarFor,
highlightCacheStats,
highlightLines,
LANGUAGE_GRAMMAR,
MAX_HIGHLIGHT_CHARS,
REQUIRED_GRAMMARS,
SLICE_CACHE_LINES,
TOKEN_CLASSES,
type HighlightResult,
} from '../src/ui-server/highlight';
import { loadManifest } from '../src/ui-server/highlight/grammars';
import { LANGUAGES } from '../src/types';
import { decodeLine, type Token } from '../ui/src/lib/highlight';
import { assignRefs, type LineRef } from '../ui/src/lib/symbol-model';
/**
* The pruned grammars live in `dist/textmate`, written by `npm run build`. A
* source tree that has only ever been type-checked has none, and the right
* behaviour there is plain text — which is itself asserted below, so the
* grammar-dependent cases skip rather than fail.
*/
const HAS_GRAMMARS = loadManifest() !== null;
const withGrammars = HAS_GRAMMARS ? it : it.skip;
function tokensOf(result: HighlightResult, line: number): Token[] {
return decodeLine(result.lines[line] ?? [], result.classes);
}
/** What the code block would render for one line: `class:text` per token. */
function shape(result: HighlightResult, line: number): string[] {
return tokensOf(result, line).map((t) => `${t.cls}:${t.text}`);
}
function lineRef(over: Partial<LineRef>): LineRef {
return {
ident: 'x',
col: null,
targetId: 'method:x',
uncertain: false,
outside: false,
title: '',
...over,
};
}
/** Which token an overlay ref claims — the whole point of the atomisation. */
function claimedText(result: HighlightResult, line: number, ref: LineRef): string | undefined {
const tokens = tokensOf(result, line);
const claimed = assignRefs(tokens, [ref]);
const [index] = [...claimed.keys()];
return index === undefined ? undefined : tokens[index]?.text;
}
describe('the language table', () => {
it('has an entry for every language the engine indexes', () => {
for (const language of LANGUAGES) {
expect(LANGUAGE_GRAMMAR).toHaveProperty(language);
}
});
it('answers null rather than throwing for a language this build never heard of', () => {
expect(grammarFor('some-future-language')).toBeNull();
expect(grammarFor(undefined)).toBeNull();
expect(grammarFor('')).toBeNull();
});
withGrammars('ships a grammar for every id the table names', () => {
const found = loadManifest();
expect(found).not.toBeNull();
for (const id of REQUIRED_GRAMMARS) {
expect(Object.keys((found as NonNullable<typeof found>).manifest.languages)).toContain(id);
}
});
withGrammars('loads a grammar chain dependencies-first, so embedded blocks highlight', () => {
const found = loadManifest();
const vue = (found as NonNullable<typeof found>).manifest.languages['vue'] ?? [];
// The single-file component's own grammar is last; everything it embeds
// has to be registered before Shiki resolves `embeddedLangs`.
expect(vue[vue.length - 1]).toBe('vue');
expect(vue).toContain('typescript');
expect(vue.indexOf('typescript')).toBeLessThan(vue.length - 1);
});
});
describe('classification', () => {
beforeAll(() => clearHighlightCache());
withGrammars('reads TypeScript with the four classes the theme paints', async () => {
const result = await highlightLines(['const answer = 42; // note'], {
language: 'typescript',
});
expect(result.engine).toBe('shiki');
expect(result.grammar).toBe('typescript');
expect(result.classes).toEqual([...TOKEN_CLASSES]);
const rendered = shape(result, 0);
expect(rendered).toContain('keyword:const');
expect(rendered).toContain('ident:answer');
expect(rendered).toContain('number:42');
expect(rendered).toContain('comment:// note');
});
withGrammars('reads a # comment as a comment in Python and as code in TypeScript', async () => {
const python = await highlightLines(['x = 1 # note'], { language: 'python' });
expect(shape(python, 0).at(-1)).toBe('comment:# note');
const ts = await highlightLines(['x = 1 # note'], { language: 'typescript' });
expect(shape(ts, 0).at(-1)).not.toBe('comment:# note');
});
withGrammars('carries a block comment across lines within one slice', async () => {
const result = await highlightLines(['/* open', 'still comment', 'done */ const x = 1;'], {
language: 'typescript',
});
expect(shape(result, 1)).toEqual(['comment:still comment']);
expect(shape(result, 2)[0]).toBe('comment:done */');
expect(shape(result, 2)).toContain('keyword:const');
});
withGrammars('reads Go, which has its own idea of what a keyword is', async () => {
const result = await highlightLines(['func Greet(name string) string {'], { language: 'go' });
expect(shape(result, 0)).toContain('keyword:func');
expect(shape(result, 0)).toContain('ident:Greet');
});
withGrammars('reads ArkTS with the TypeScript grammar', async () => {
const result = await highlightLines(['@Entry struct Index { build() {} }'], {
language: 'arkts',
});
expect(result.engine).toBe('shiki');
expect(result.grammar).toBe('typescript');
});
withGrammars('emits one entry per source line, always', async () => {
const lines = ['a();', '', 'b();', ''];
const result = await highlightLines(lines, { language: 'typescript' });
// The code block indexes rows positionally: one short answer and every
// line below it renders the wrong source.
expect(result.lines).toHaveLength(lines.length);
expect(result.lines[1]).toEqual([]);
});
});
describe('the plain fallback', () => {
beforeAll(() => clearHighlightCache());
it('answers plain, with a reason, for a language no grammar covers', async () => {
const result = await highlightLines(['whatever this is'], { language: 'unknown' });
expect(result.engine).toBe('plain');
expect(result.grammar).toBeNull();
expect(result.reason).toBeTruthy();
expect(result.lines).toHaveLength(1);
});
it('still splits identifiers when it cannot highlight, so the links land', async () => {
const result = await highlightLines([' return this.mutex.withLock();'], {
language: 'unknown',
});
expect(claimedText(result, 0, lineRef({ ident: 'withLock', col: 9 }))).toBe('withLock');
});
it('refuses to tokenise a minified line rather than wedging on it', async () => {
const enormous = 'a'.repeat(MAX_HIGHLIGHT_CHARS + 1);
const result = await highlightLines([enormous], { language: 'javascript' });
expect(result.engine).toBe('plain');
expect(result.reason).toMatch(/minified/);
// The source still comes back whole — that is the part that matters.
expect(result.lines[0]?.map(([, text]) => text).join('')).toHaveLength(enormous.length);
});
it('answers plain when a shipped grammar file is missing or unreadable', async () => {
// The install is half there: a manifest that names a grammar whose file
// never made it. The viewer must still get its source.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-textmate-'));
fs.writeFileSync(
path.join(dir, 'manifest.json'),
JSON.stringify({ shikiVersion: 'test', languages: { typescript: ['typescript'] } })
);
const previous = process.env.CODEGRAPH_TEXTMATE_PATH;
process.env.CODEGRAPH_TEXTMATE_PATH = dir;
// A fresh module registry: the highlighter and its grammar bookkeeping are
// created once per process, and this case is about that first attempt.
vi.resetModules();
try {
const mod = await import('../src/ui-server/highlight');
const result: HighlightResult = await mod.highlightLines(['const x = 1;'], {
language: 'typescript',
});
expect(result.engine).toBe('plain');
expect(result.reason).toBeTruthy();
expect(result.lines[0]?.map(([, text]) => text).join('')).toBe('const x = 1;');
} finally {
if (previous === undefined) delete process.env.CODEGRAPH_TEXTMATE_PATH;
else process.env.CODEGRAPH_TEXTMATE_PATH = previous;
fs.rmSync(dir, { recursive: true, force: true });
vi.resetModules();
}
});
});
describe('graph links land on the right token', () => {
beforeAll(() => clearHighlightCache());
withGrammars('marks the callee, not the receiver the recorded column points at', async () => {
// The recorded column is the start of the calling EXPRESSION — `this` —
// and the underline has to end up on `withLock`.
const line = ' return this.indexMutex.withLock(async () => {';
const result = await highlightLines([line], { language: 'typescript' });
expect(claimedText(result, 0, lineRef({ ident: 'withLock', col: line.indexOf('this') }))).toBe(
'withLock'
);
});
withGrammars('lands on a real call site in the engines own src/index.ts', async () => {
const file = path.join(__dirname, '..', 'src', 'index.ts');
const source = fs.readFileSync(file, 'utf-8').split('\n');
// A line the engine actually contains, found rather than hard-coded, so a
// refactor of index.ts retires this test instead of silently passing.
const index = source.findIndex((l) => /^\s*(?:return |const \w+ = )?this\.\w+\.\w+\(/.test(l));
expect(index).toBeGreaterThanOrEqual(0);
const line = source[index] as string;
const match = /this\.(\w+)\.(\w+)\(/.exec(line) as RegExpExecArray;
const callee = match[2] as string;
const result = await highlightLines([line], { language: 'typescript' });
expect(claimedText(result, 0, lineRef({ ident: callee, col: line.indexOf('this') }))).toBe(
callee
);
});
withGrammars('lands on a Go method call', async () => {
const line = '\tresult := s.repo.FindByID(ctx, id)';
const result = await highlightLines([line], { language: 'go' });
expect(claimedText(result, 0, lineRef({ ident: 'FindByID', col: line.indexOf('s.repo') }))).toBe(
'FindByID'
);
});
withGrammars('lands on a Python method call, not on the receiver of the same name', async () => {
const line = ' return self.store.join(self.store.path)';
const result = await highlightLines([line], { language: 'python' });
expect(claimedText(result, 0, lineRef({ ident: 'join', col: line.indexOf('self') }))).toBe(
'join'
);
});
withGrammars('leaves a word inside a comment or a string alone', async () => {
const result = await highlightLines(
[' // call render here', ' const s = "render";'],
{ language: 'typescript' }
);
expect(claimedText(result, 0, lineRef({ ident: 'render' }))).toBeUndefined();
expect(claimedText(result, 1, lineRef({ ident: 'render' }))).toBeUndefined();
});
withGrammars('keeps every identifier separately claimable', async () => {
const result = await highlightLines(['render(); render();'], { language: 'typescript' });
const tokens = tokensOf(result, 0);
const claimed = assignRefs(tokens, [
lineRef({ ident: 'render', targetId: 'a' }),
lineRef({ ident: 'render', targetId: 'b' }),
]);
expect(claimed.size).toBe(2);
});
withGrammars('reproduces the line exactly — the code block renders these tokens', async () => {
const line = ' const s = `a ${b.c()} d`; // 1 + 2';
const result = await highlightLines([line], { language: 'typescript' });
expect(
tokensOf(result, 0)
.map((t) => t.text)
.join('')
).toBe(line);
});
});
describe('cost', () => {
withGrammars('answers a cached slice without re-tokenising it', async () => {
clearHighlightCache();
const lines = fs
.readFileSync(path.join(__dirname, '..', 'src', 'ui-server', 'api', 'source.ts'), 'utf-8')
.split('\n');
const cold = Date.now();
await highlightLines(lines, { language: 'typescript', cacheKey: 'a:1:9999' });
const coldMs = Date.now() - cold;
const warm = Date.now();
const second = await highlightLines(lines, { language: 'typescript', cacheKey: 'a:1:9999' });
const warmMs = Date.now() - warm;
expect(second.engine).toBe('shiki');
// The cache is what makes a re-render free: every resize, theme flip and
// step back through the trail re-asks for the same slice.
expect(warmMs).toBeLessThan(Math.max(20, coldMs / 4));
});
it('bounds the cache by total lines, not just by entry count', async () => {
clearHighlightCache();
const big = new Array(Math.ceil(SLICE_CACHE_LINES / 2) + 10).fill('x');
// The entry count alone would let a reader left open on a big repo grow
// without limit: three of these is well inside SLICE_CACHE_LIMIT and well
// over the line budget.
for (const key of ['one', 'two', 'three']) {
await highlightLines(big, { language: 'unknown', cacheKey: key });
}
const stats = highlightCacheStats();
expect(stats.entries).toBeLessThan(3);
expect(stats.lines).toBeLessThanOrEqual(SLICE_CACHE_LINES);
});
withGrammars('keys the cache on the content, so an edited file re-highlights', async () => {
clearHighlightCache();
const first = await highlightLines(['const a = 1;'], {
language: 'typescript',
cacheKey: 'hash-one:1:1',
});
const second = await highlightLines(['const bbb = 2;'], {
language: 'typescript',
cacheKey: 'hash-two:1:1',
});
expect(first.lines[0]?.map(([, t]) => t).join('')).toBe('const a = 1;');
expect(second.lines[0]?.map(([, t]) => t).join('')).toBe('const bbb = 2;');
});
});
+26
View File
@@ -658,6 +658,29 @@ describe('GET /api/source', () => {
expect(body.lines).toHaveLength(node.node.lines);
});
it('carries the classified source beside the lines, one entry per line', async () => {
const body = await getJson('/api/source?file=src/cache.ts&from=1&to=3');
// Highlighting rides with the slice rather than behind its own endpoint:
// the two are only ever wanted together, and a second round-trip would let
// the code block paint unhighlighted source and then reflow it.
expect(body.highlight).toBeTruthy();
expect(body.highlight.classes).toEqual([
'other',
'ident',
'comment',
'string',
'keyword',
'number',
]);
expect(body.highlight.lines).toHaveLength(body.lines.length);
// Every line's tokens reproduce that line exactly — the code block renders
// these, not the raw string.
for (let i = 0; i < body.lines.length; i++) {
const rebuilt = body.highlight.lines[i].map(([, text]: [number, string]) => text).join('');
expect(rebuilt).toBe(body.lines[i]);
}
});
it('refuses to slice a file that changed on disk after the last sync', async () => {
const target = path.join(projectRoot, 'src', 'handler.ts');
const original = fs.readFileSync(target);
@@ -668,6 +691,9 @@ describe('GET /api/source', () => {
expect(body.drift).toBe(true);
// The whole point: no slice, rather than a slice of the wrong lines.
expect(body.lines).toBeUndefined();
// And nothing to render it with either — a highlight with no source is
// just a second way to draw the wrong lines.
expect(body.highlight).toBeUndefined();
expect(body.reason).toContain('changed on disk after the last index sync');
// And every screen that renders indexed line ranges is told.
+106 -74
View File
@@ -26,7 +26,7 @@ import {
HEAD_LINES,
type LineRef,
} from '../ui/src/lib/symbol-model';
import { newLexState, tokenize } from '../ui/src/lib/highlight';
import { decodeLine, plainLine, tokensByLine } from '../ui/src/lib/highlight';
import type { WireRelation, WireSymbolPayload } from '../ui/src/lib/api';
/* ------------------------------------------------------------- fixtures -- */
@@ -204,7 +204,7 @@ describe('buildCodeBlock', () => {
/* ----------------------------------------------------------------- refs -- */
describe('assignRefs', () => {
const toks = (line: string) => tokenize(line, newLexState(), 'typescript');
const toks = (line: string) => plainLine(line);
const ref = (over: Partial<LineRef>): LineRef => ({
ident: 'withLock',
col: null,
@@ -249,10 +249,36 @@ describe('assignRefs', () => {
expect(assignRefs(toks('return 1;'), [ref({ ident: 'nowhere' })]).size).toBe(0);
});
it('never marks a keyword, a string or a comment as a call site', () => {
const tokens = toks('// call render here');
expect(assignRefs(tokens, [ref({ ident: 'render' })]).size).toBe(0);
expect(assignRefs(toks('const s = "render";'), [ref({ ident: 'render' })]).size).toBe(0);
it('never marks a word inside a comment or a string as a call site', () => {
// The classification comes from the server's grammar; what this pins is
// that the overlay respects it. Anything else — a keyword, a type name a
// grammar happened to scope as `storage.type` — stays claimable, because a
// grammar's opinion about a scope name must not decide what navigates.
const comment = [
{ cls: 'comment', text: '// call render here', col: 0 },
];
expect(assignRefs(comment, [ref({ ident: 'render' })]).size).toBe(0);
const string = [
{ cls: 'keyword', text: 'const', col: 0 },
{ cls: 'other', text: ' s = ', col: 5 },
{ cls: 'string', text: '"render"', col: 10 },
{ cls: 'other', text: ';', col: 18 },
];
expect(assignRefs(string, [ref({ ident: 'render' })]).size).toBe(0);
});
it('still claims an identifier a grammar classified as something else', () => {
// Go scopes `string` as storage.type; Java does the same to a declared
// type name. A link that disappeared over that would be a highlighting
// change silently breaking navigation.
const tokens = [
{ cls: 'keyword', text: 'Duration', col: 0 },
{ cls: 'other', text: '.Since(t)', col: 8 },
];
const claimed = assignRefs(tokens, [ref({ ident: 'Duration', col: 0 })]);
expect(claimed.size).toBe(1);
expect(tokens[[...claimed.keys()][0] as number]?.text).toBe('Duration');
});
});
@@ -456,90 +482,96 @@ describe('showsBody', () => {
/* ---------------------------------------------------------------- lexer -- */
describe('tokenize', () => {
const kinds = (line: string, state = newLexState(), language = 'typescript') =>
tokenize(line, state, language).map((t) => `${t.cls}:${t.text}`);
describe('client-side token decoding', () => {
// The classification itself is the server's job (`src/ui-server/highlight/`,
// real TextMate grammars); what is worth pinning here is the decoding — the
// columns the call-site overlay matches against, and the plain fallback that
// has to keep links working when no grammar covers a file.
const CLASSES = ['other', 'ident', 'comment', 'string', 'keyword', 'number'];
it('separates the four things the near-monochrome theme colours', () => {
expect(kinds('const x = 1; // note')).toEqual([
it('resolves class ids through the payload table', () => {
const tokens = decodeLine(
[
[4, 'const'],
[0, ' '],
[1, 'x'],
[0, ' = '],
[5, '1'],
[0, '; '],
[2, '// note'],
],
CLASSES
);
expect(tokens.map((t) => `${t.cls}:${t.text}`)).toEqual([
'keyword:const',
'space: ',
'other: ',
'ident:x',
'space: ',
'punct:=',
'space: ',
'other: = ',
'number:1',
'punct:;',
'space: ',
'other:; ',
'comment:// note',
]);
});
it('carries a block comment across lines so the next line is not read as code', () => {
const state = newLexState();
expect(kinds('/* open', state)).toEqual(['comment:/* open']);
expect(state.block).toBe(true);
expect(kinds('still comment', state)).toEqual(['comment:still comment']);
expect(kinds('done */ const x = 1;', state)).toEqual([
'comment:done */',
'space: ',
'keyword:const',
'space: ',
'ident:x',
'space: ',
'punct:=',
'space: ',
'number:1',
'punct:;',
]);
expect(state.block).toBe(false);
it('derives each column from the running text, which is how a ref finds its identifier', () => {
const tokens = decodeLine(
[
[0, ' '],
[4, 'return'],
[0, ' '],
[1, 'render'],
[0, '();'],
],
CLASSES
);
expect(tokens.find((t) => t.text === 'render')?.col).toBe(' return '.length);
expect(tokens.at(-1)?.col).toBe(' return render'.length);
});
it('carries a template literal across lines, and closes it on the right backtick', () => {
const state = newLexState();
expect(kinds('const s = `open', state)).toContain('string:`open');
expect(state.stringEnd).toBe('`');
expect(kinds('closed` + x', state)).toEqual([
'string:closed`',
'space: ',
'punct:+',
'space: ',
'ident:x',
it('treats an unknown class id as unstyled rather than throwing', () => {
expect(decodeLine([[99, 'x']], CLASSES)[0]?.cls).toBe('other');
});
it('splits identifiers even with no grammar, so the links still land', () => {
expect(plainLine(' return this.mutex.withLock();').map((t) => `${t.cls}:${t.text}`)).toEqual([
'other: ',
'ident:return',
'other: ',
'ident:this',
'other:.',
'ident:mutex',
'other:.',
'ident:withLock',
'other:();',
]);
});
it('does not eat the rest of a window on an apostrophe in prose', () => {
// An unterminated single-line quote is punctuation in English far more
// often than a real string, so it stops at the line.
const state = newLexState();
kinds("// it's fine", state);
expect(state.stringEnd).toBeNull();
const after = kinds('const x = 1;', state);
expect(after[0]).toBe('keyword:const');
it('splits non-ASCII identifiers, because a symbol name can be one', () => {
expect(plainLine('取得データ()').map((t) => t.cls)).toEqual(['ident', 'other']);
});
it('reads a # comment as a comment in Python and as code in TypeScript', () => {
expect(kinds('# note', newLexState(), 'python')).toEqual(['comment:# note']);
expect(kinds('x = 1 # note', newLexState(), 'python').at(-1)).toBe('comment:# note');
expect(kinds('# note', newLexState(), 'typescript')[0]).not.toBe('comment:# note');
it('keys a slice by real file line, not by offset into the slice', () => {
const byLine = tokensByLine(['a();', 'b();'], 120, {
engine: 'shiki',
grammar: 'typescript',
classes: CLASSES,
lines: [
[
[1, 'a'],
[0, '();'],
],
[
[1, 'b'],
[0, '();'],
],
],
});
expect([...byLine.keys()]).toEqual([120, 121]);
expect(byLine.get(121)?.[0]?.text).toBe('b');
});
it('closes a Python triple-quoted string on the triple, not on the first quote', () => {
const state = newLexState();
expect(kinds('"""docstring', state, 'python')).toEqual(['string:"""docstring']);
expect(state.stringEnd).toBe('"""');
expect(kinds('more"""', state, 'python')).toEqual(['string:more"""']);
});
it("reports each token's column, which is how a ref finds its identifier", () => {
const tokens = tokenize(' return render();', newLexState(), 'typescript');
const render = tokens.find((t) => t.text === 'render');
expect(render?.col).toBe(' return '.length);
});
it('falls back to a C-family reading for a language it has no table for', () => {
// Silence beats a wrong claim, but a plain `//` comment is not a claim
// worth getting wrong in a language we have not enumerated.
expect(kinds('// note', newLexState(), 'some-new-language')).toEqual(['comment:// note']);
it('falls back per line when the payload carries no highlight block at all', () => {
const byLine = tokensByLine(['render();'], 5, undefined);
expect(byLine.get(5)?.map((t) => t.cls)).toEqual(['ident', 'other']);
});
});
+23 -1
View File
@@ -61,10 +61,18 @@ media/`[data-theme]` block. `body { background: var(--paper); color: var(--ink)
section labels (`Called by`, `Calls`, `Blast radius`) `600 13px` sans; rail rows `12.5px` mono name + `11px` sans meta;
chips `11px` mono; line numbers `11px` mono in `--ink-4`; badges `11.5px`; map node label `13px` mono, count `11px`;
flow card name `600 13px` mono, window `12px/19px` mono; trail `12px` mono. Headings sentence case, `text-wrap: balance`.
- Code token classes: comment `--ink-3`; string `--ink-2`; keyword weight 500 (same ink); number `--ink-2`; definition
- Code token classes: comment `--code-comment`; string `--ink-2`; keyword weight 500 (same ink); number `--ink-2`; definition
name on its own line weight 600; **call-site link** = `--accent`, underline `--accent-line`, offset 3px, hover/hot fill
`--accent-soft`; uncertain link = `--ink-2`, dotted underline `--ink-4`; link to a symbol outside the index = `--ink-2`,
underline `--rule-soft`, not clickable.
- *As built (CG-43) — comments are `--code-comment`, not `--ink-3`.* `--ink-3` measures 3.46:1 on `--paper` and 3.00:1 on
the hot-line tint `--accent-soft`, both under the 4.5:1 that 12.5px body text needs. `--code-comment` is the smallest
step along the same warm-grey ramp that clears 4.5:1 on every background a code line can have (`#6a675d` light —
paper 5.23, paper-2 4.92, accent-soft 4.53; `#8e8b81` dark — 5.36 / 5.10 / 4.51) while staying quieter than the
`--ink-2` strings and numbers use, so the recession order above is unchanged. Everything else in this list passes as
specified: ink 16.9/16.2, ink-2 7.03/8.89, accent 9.25/6.91 (8.02/5.80 on `--accent-soft`).
- *Line numbers remain `--ink-4` (1.99:1 light, 2.69:1 dark) — a known contrast gap, left as specified rather than
changed inside a rendering task. Worth a design call before phase 2.*
### 2.3 Kind glyphs
@@ -188,6 +196,20 @@ point.
fallback if crossing quality demands it (never ELK). Symbol view = DOM + one SVG overlay (`ResizeObserver` re-layout).
- Shiki (JavaScript regex engine, lazy grammars, custom near-monochrome theme as in §2.2) server-side in `/api/source`; tree-sitter-derived
tokens replace it in phase 3.
- *As built (CG-43).* `@shikijs/core` + `@shikijs/engine-javascript` are runtime dependencies (~5 MB installed, no wasm, no native
module); `@shikijs/langs` is a **devDependency** and `npm run build:textmate` (`scripts/prune-grammars.mjs`) writes only the
closure the engine's 40-odd languages reach — 56 grammars, 2.6 MB — into **`dist/textmate/`**, checked by `scripts/check-ui-build.mjs`.
Shipping all 722 grammars would have been 11 MB.
- The theme classifies rather than colours: its foregrounds are sentinels the server maps to class names (`comment`, `string`,
`keyword`, `number`, `ident`, `other`), and the viewer paints them from the CSS custom properties above — so **one token stream
serves light and dark** with no refetch when `prefers-color-scheme` flips, and the ramp lives only in `ui/src/app.css`.
- Every code token is split into identifier runs before it goes on the wire, so the graph's call-site overlay claims a token the
highlighter produced rather than re-cutting a line — which is what keeps a link landing on the callee's own name whatever
boundaries a grammar chose, and keeps links working in the plain-text fallback.
- Measured on this machine (Shiki 4.4.3, JS regex engine, 3 000 lines cold): Python 3547 ms, Go 4357 ms, **TypeScript ~700 ms**
the TS TextMate grammar is 57× the cost of any other and the oniguruma-wasm engine would run it in ~120 ms. Slices are therefore
cached by content hash + range, so a re-render (resize, theme flip, stepping back through the trail) is a map lookup (< 10 ms);
a symbol-sized slice (~280 lines of TS) is ~50 ms cold. Phase 1 only ever requests one symbol's range.
- No native modules; no runtime dependency for the UI itself; the CLI serves **`dist/viewer/`** over `node:http`, loopback only.
(Not `dist/ui/``src/ui/` is the engine's *terminal* ui and tsc already compiles it there; see `ui/README.md`.)
+518
View File
@@ -13,6 +13,8 @@
],
"dependencies": {
"@clack/prompts": "^1.3.0",
"@shikijs/core": "^4.4.3",
"@shikijs/engine-javascript": "^4.4.3",
"commander": "^14.0.2",
"fast-string-width": "^3.0.2",
"fast-wrap-ansi": "^0.2.0",
@@ -27,6 +29,7 @@
"codegraph": "dist/bin/codegraph.js"
},
"devDependencies": {
"@shikijs/langs": "^4.4.3",
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^20.19.30",
"@types/picomatch": "^4.0.2",
@@ -927,6 +930,82 @@
"win32"
]
},
"node_modules/@shikijs/core": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/core/-/core-4.4.3.tgz",
"integrity": "sha512-QCR4q2ZO/ILJEuwiBMel4wdcTDb1JGwfjKTxPDF6x8ixOaluPrVqIn06C99AcRPhmYlBR56d/Fb+GN58GzExpg==",
"license": "MIT",
"dependencies": {
"@shikijs/primitive": "4.4.3",
"@shikijs/types": "4.4.3",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.5",
"hast-util-to-html": "^9.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/engine-javascript": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/engine-javascript/-/engine-javascript-4.4.3.tgz",
"integrity": "sha512-FbOjFJp9VLdo1Wevs10BBtVxiTWwNLqZh5Gkhjgda/ioL15YOgeSl9n+6XMa3qRlPQzfhFNe641SrynFHYG0nQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.4.3",
"@shikijs/vscode-textmate": "^10.0.2",
"oniguruma-to-es": "^4.3.6"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/langs": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/langs/-/langs-4.4.3.tgz",
"integrity": "sha512-ePic0yfAJGOF83D5wBHK/00EjK65oahBYxFk5epgq33WRv7X9UuxLEV8PtR0szC0z8dl7INIpIodB99JRFlR+A==",
"dev": true,
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.4.3"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/primitive": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/primitive/-/primitive-4.4.3.tgz",
"integrity": "sha512-m0wBeLDQDeIxRdUmrCPdQqfuUamDwRL5isCfYbguKD6NiaKpVbsv+3J81DyIKgNW5h4WAIIr8T4EkgQrBBxvaQ==",
"license": "MIT",
"dependencies": {
"@shikijs/types": "4.4.3",
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/types": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/@shikijs/types/-/types-4.4.3.tgz",
"integrity": "sha512-UEJxmRR++MAGR6hugn0vgVS2W/6lWAts84FFSrnlH9sP0LNol7E5+NQ792pH8liWUhyMyjhTgSUH3k7iD7tc5g==",
"license": "MIT",
"dependencies": {
"@shikijs/vscode-textmate": "^10.0.2",
"@types/hast": "^3.0.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@shikijs/vscode-textmate": {
"version": "10.0.2",
"resolved": "https://registry.npmjs.org/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz",
"integrity": "sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==",
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.13",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.13.tgz",
@@ -964,6 +1043,24 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/hast": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.5.tgz",
"integrity": "sha512-rp/ezSWaD1m44dPKICGhiskI13nVr7qTloFwDa/IYkhhf5nzwP+zIQcIJh3WIFSBOy/H1PzB40jPjMDksN4F+g==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/mdast": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz",
"integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==",
"license": "MIT",
"dependencies": {
"@types/unist": "*"
}
},
"node_modules/@types/node": {
"version": "20.19.33",
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.33.tgz",
@@ -989,6 +1086,18 @@
"dev": true,
"license": "MIT"
},
"node_modules/@types/unist": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz",
"integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==",
"license": "MIT"
},
"node_modules/@ungap/structured-clone": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.4.tgz",
"integrity": "sha512-JL+CF0GeLHyPWI0rXu7UnxgiuOm9UQWzadi0OYOJNhNO2q6EZElpwlgXkNkfU1PzANDHq3YcwKVZprdvS+BrbQ==",
"license": "ISC"
},
"node_modules/@vitest/expect": {
"version": "2.1.9",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz",
@@ -1156,6 +1265,16 @@
"node": ">=8"
}
},
"node_modules/ccount": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz",
"integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/chai": {
"version": "5.3.3",
"resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz",
@@ -1173,6 +1292,26 @@
"node": ">=18"
}
},
"node_modules/character-entities-html4": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz",
"integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/character-entities-legacy": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz",
"integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/check-error": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz",
@@ -1213,6 +1352,16 @@
"resolved": "ui",
"link": true
},
"node_modules/comma-separated-tokens": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz",
"integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/commander": {
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
@@ -1260,6 +1409,15 @@
"node": ">=0.10.0"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/devalue": {
"version": "5.9.1",
"resolved": "https://registry.npmjs.org/devalue/-/devalue-5.9.1.tgz",
@@ -1267,6 +1425,19 @@
"dev": true,
"license": "MIT"
},
"node_modules/devlop": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz",
"integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==",
"license": "MIT",
"dependencies": {
"dequal": "^2.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/es-module-lexer": {
"version": "1.7.0",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
@@ -1415,6 +1586,52 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/hast-util-to-html": {
"version": "9.0.5",
"resolved": "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz",
"integrity": "sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/unist": "^3.0.0",
"ccount": "^2.0.0",
"comma-separated-tokens": "^2.0.0",
"hast-util-whitespace": "^3.0.0",
"html-void-elements": "^3.0.0",
"mdast-util-to-hast": "^13.0.0",
"property-information": "^7.0.0",
"space-separated-tokens": "^2.0.0",
"stringify-entities": "^4.0.0",
"zwitch": "^2.0.4"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/hast-util-whitespace": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz",
"integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/html-void-elements": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz",
"integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
@@ -1464,6 +1681,116 @@
"@jridgewell/sourcemap-codec": "^1.5.5"
}
},
"node_modules/mdast-util-to-hast": {
"version": "13.2.1",
"resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz",
"integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==",
"license": "MIT",
"dependencies": {
"@types/hast": "^3.0.0",
"@types/mdast": "^4.0.0",
"@ungap/structured-clone": "^1.0.0",
"devlop": "^1.0.0",
"micromark-util-sanitize-uri": "^2.0.0",
"trim-lines": "^3.0.0",
"unist-util-position": "^5.0.0",
"unist-util-visit": "^5.0.0",
"vfile": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/micromark-util-character": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz",
"integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-symbol": "^2.0.0",
"micromark-util-types": "^2.0.0"
}
},
"node_modules/micromark-util-encode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz",
"integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromark-util-sanitize-uri": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz",
"integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT",
"dependencies": {
"micromark-util-character": "^2.0.0",
"micromark-util-encode": "^2.0.0",
"micromark-util-symbol": "^2.0.0"
}
},
"node_modules/micromark-util-symbol": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz",
"integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/micromark-util-types": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz",
"integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==",
"funding": [
{
"type": "GitHub Sponsors",
"url": "https://github.com/sponsors/unifiedjs"
},
{
"type": "OpenCollective",
"url": "https://opencollective.com/unified"
}
],
"license": "MIT"
},
"node_modules/mri": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/mri/-/mri-1.2.0.tgz",
@@ -1514,6 +1841,23 @@
"node": ">=12.20.0"
}
},
"node_modules/oniguruma-parser": {
"version": "0.12.2",
"resolved": "https://registry.npmjs.org/oniguruma-parser/-/oniguruma-parser-0.12.2.tgz",
"integrity": "sha512-6HVa5oIrgMC6aA6WF6XyyqbhRPJrKR02L20+2+zpDtO5QAzGHAUGw5TKQvwi5vctNnRHkJYmjAhRVQF2EKdTQw==",
"license": "MIT"
},
"node_modules/oniguruma-to-es": {
"version": "4.3.6",
"resolved": "https://registry.npmjs.org/oniguruma-to-es/-/oniguruma-to-es-4.3.6.tgz",
"integrity": "sha512-csuQ9x3Yr0cEIs/Zgx/OEt9iBw9vqIunAPQkx19R/fiMq2oGVTgcMqO/V3Ybqefr1TBvosI6jU539ksaBULJyA==",
"license": "MIT",
"dependencies": {
"oniguruma-parser": "^0.12.2",
"regex": "^6.1.0",
"regex-recursion": "^6.0.2"
}
},
"node_modules/pathe": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz",
@@ -1580,6 +1924,16 @@
"node": "^10 || ^12 || >=14"
}
},
"node_modules/property-information": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/property-information/-/property-information-7.2.0.tgz",
"integrity": "sha512-IAtzIB6sUiWaJYrX9smp3V46pBGbBeLFRGdh25kg1334VcBlD8HzhPeNIWQH9zhGmo2itIe25EHt9dQP7G5hmg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/readdirp": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz",
@@ -1594,6 +1948,30 @@
"url": "https://paulmillr.com/funding/"
}
},
"node_modules/regex": {
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/regex/-/regex-6.1.0.tgz",
"integrity": "sha512-6VwtthbV4o/7+OaAF9I5L5V3llLEsoPyq9P1JVXkedTP33c7MfCG0/5NOPcSJn0TzXcG9YUrR0gQSWioew3LDg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-recursion": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/regex-recursion/-/regex-recursion-6.0.2.tgz",
"integrity": "sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==",
"license": "MIT",
"dependencies": {
"regex-utilities": "^2.3.0"
}
},
"node_modules/regex-utilities": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/regex-utilities/-/regex-utilities-2.3.0.tgz",
"integrity": "sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==",
"license": "MIT"
},
"node_modules/rollup": {
"version": "4.57.1",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz",
@@ -1675,6 +2053,16 @@
"node": ">=0.10.0"
}
},
"node_modules/space-separated-tokens": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz",
"integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
@@ -1689,6 +2077,20 @@
"dev": true,
"license": "MIT"
},
"node_modules/stringify-entities": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz",
"integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==",
"license": "MIT",
"dependencies": {
"character-entities-html4": "^2.0.0",
"character-entities-legacy": "^3.0.0"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/svelte": {
"version": "5.56.10",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.10.tgz",
@@ -1813,6 +2215,16 @@
"tree-sitter-wasms": "^0.1.11"
}
},
"node_modules/trim-lines": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz",
"integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -1835,6 +2247,102 @@
"dev": true,
"license": "MIT"
},
"node_modules/unist-util-is": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz",
"integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-position": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz",
"integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-stringify-position": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz",
"integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz",
"integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0",
"unist-util-visit-parents": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/unist-util-visit-parents": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz",
"integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-is": "^6.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz",
"integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"vfile-message": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vfile-message": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz",
"integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==",
"license": "MIT",
"dependencies": {
"@types/unist": "^3.0.0",
"unist-util-stringify-position": "^4.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/unified"
}
},
"node_modules/vite": {
"version": "5.4.21",
"resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz",
@@ -2043,6 +2551,16 @@
"dev": true,
"license": "MIT"
},
"node_modules/zwitch": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz",
"integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/wooorm"
}
},
"ui": {
"name": "codegraph-ui",
"version": "0.0.0",
+6 -2
View File
@@ -20,7 +20,7 @@
"ui"
],
"scripts": {
"build": "tsc && npm run copy-assets && npm run build:ui && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"",
"build": "tsc && npm run copy-assets && npm run build:textmate && npm run build:ui && node -e \"require('fs').chmodSync('dist/bin/codegraph.js', 0o755)\"",
"build:ui": "npm run build --workspace ui && node scripts/check-ui-build.mjs",
"preuninstall": "node dist/bin/uninstall.js",
"copy-assets": "node -e \"const fs=require('fs');fs.mkdirSync('dist/db',{recursive:true});fs.copyFileSync('src/db/schema.sql','dist/db/schema.sql');fs.mkdirSync('dist/extraction/wasm',{recursive:true});fs.readdirSync('src/extraction/wasm').filter(f=>f.endsWith('.wasm')).forEach(f=>fs.copyFileSync('src/extraction/wasm/'+f,'dist/extraction/wasm/'+f))\"",
@@ -31,7 +31,8 @@
"test:watch": "vitest",
"test:eval": "vitest run __tests__/evaluation/",
"eval": "npm run build && npx tsx __tests__/evaluation/runner.ts",
"clean": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true})\""
"clean": "node -e \"const fs=require('fs');fs.rmSync('dist',{recursive:true,force:true})\"",
"build:textmate": "node scripts/prune-grammars.mjs"
},
"keywords": [
"code-intelligence",
@@ -42,6 +43,8 @@
"license": "MIT",
"dependencies": {
"@clack/prompts": "^1.3.0",
"@shikijs/core": "^4.4.3",
"@shikijs/engine-javascript": "^4.4.3",
"commander": "^14.0.2",
"fast-string-width": "^3.0.2",
"fast-wrap-ansi": "^0.2.0",
@@ -53,6 +56,7 @@
"web-tree-sitter": "^0.25.3"
},
"devDependencies": {
"@shikijs/langs": "^4.4.3",
"@types/better-sqlite3": "^7.6.0",
"@types/node": "^20.19.30",
"@types/picomatch": "^4.0.2",
+38 -1
View File
@@ -13,6 +13,11 @@
* where tsc puts the TERMINAL ui, so a mis-pointed outDir silently deletes
* modules the CLI requires at startup.
*
* The pruned TextMate grammars in dist/textmate/ are checked the same way and
* for the same reason: without them every file the viewer shows falls back to
* unhighlighted text, which looks like a styling bug rather than a missing
* build step.
*
* Usage: node scripts/check-ui-build.mjs [--root <dir>]
* --root directory holding dist/ (default: the repo root). The release
* bundler points this at its staging dir to verify the copy.
@@ -90,4 +95,36 @@ for (const compiled of [join('bin', 'codegraph.js'), 'index.js', join('ui', 'shi
}
}
console.log(`[check-ui-build] dist/viewer ok (index.html + ${assets} referenced asset(s)); dist/ engine intact`);
// The pruned syntax grammars (scripts/prune-grammars.mjs). Their absence is
// survivable at runtime — source is served unhighlighted — which is exactly why
// it has to fail here: nothing downstream would ever complain.
const textmateDir = join(root, 'dist', 'textmate');
const manifestPath = join(textmateDir, 'manifest.json');
if (!existsSync(manifestPath)) {
fail(
`missing ${manifestPath}`,
staged
? 'this bundle was assembled before the syntax grammars were added, or dist/textmate was not copied'
: 'run `npm run build:textmate` (it needs @shikijs/langs from devDependencies)'
);
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
const languages = Object.keys(manifest.languages ?? {});
if (languages.length === 0) fail('dist/textmate/manifest.json lists no languages');
const grammarFiles = new Set(Object.values(manifest.languages).flat());
const missingGrammars = [...grammarFiles].filter(
(name) => !existsSync(join(textmateDir, `${name}.json`))
);
if (missingGrammars.length > 0) {
fail(
`dist/textmate is missing ${missingGrammars.length} grammar file(s): ${missingGrammars.join(', ')}`,
'the prune step was interrupted or dist/textmate was copied incompletely'
);
}
console.log(
`[check-ui-build] dist/viewer ok (index.html + ${assets} referenced asset(s)); ` +
`dist/textmate ok (${languages.length} languages, ${grammarFiles.size} grammars); dist/ engine intact`
);
+110
View File
@@ -0,0 +1,110 @@
#!/usr/bin/env node
/**
* Write the TextMate grammars the viewer needs into `dist/textmate/`.
*
* Shiki carries about 700 grammars, 11 MB of JSON. The engine indexes about 40
* languages. Shipping the other 660 to every user of a code-intelligence CLI is
* not a trade worth making, so `@shikijs/langs` stays a devDependency and this
* step copies out exactly the closure the viewer can reach: every grammar named
* in `src/ui-server/highlight/languages.ts`, plus every grammar those embed
* (`vue` needs html, css, typescript, json and four Vue-specific ones before it
* will highlight a single-file component).
*
* Run from `npm run build`, after `tsc`, because the language table is read
* from the compiled `dist/ui-server/highlight/languages.js` rather than being
* duplicated here — one source of truth for what ships.
*
* Output:
* dist/textmate/manifest.json grammar id -> files to load, deps first
* dist/textmate/<name>.json one TextMate grammar, verbatim
*/
import { createRequire } from 'node:module';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { fileURLToPath } from 'node:url';
const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
const OUT = path.join(ROOT, 'dist', 'textmate');
const require = createRequire(import.meta.url);
function fail(message) {
process.stderr.write(`[prune-grammars] ${message}\n`);
process.exit(1);
}
const languagesModule = path.join(ROOT, 'dist', 'ui-server', 'highlight', 'languages.js');
if (!fs.existsSync(languagesModule)) {
fail(`${path.relative(ROOT, languagesModule)} is missing — run tsc before this script.`);
}
const { REQUIRED_GRAMMARS } = require(languagesModule);
if (!Array.isArray(REQUIRED_GRAMMARS) || REQUIRED_GRAMMARS.length === 0) {
fail('REQUIRED_GRAMMARS is empty — the language table did not compile as expected.');
}
const shikiVersion = JSON.parse(
fs.readFileSync(path.join(ROOT, 'node_modules', '@shikijs', 'langs', 'package.json'), 'utf-8')
).version;
/**
* Load one Shiki language module and return its registrations.
*
* The default export is already the flattened chain — embedded grammars first,
* the language itself last — which is exactly the order Shiki's registry needs
* to resolve `embeddedLangs`. Keeping that order is the whole reason the
* manifest stores a list rather than a single filename.
*/
async function loadChain(id) {
const mod = await import(`@shikijs/langs/${id}`);
const chain = mod.default;
if (!Array.isArray(chain) || chain.length === 0) {
fail(`@shikijs/langs/${id} did not export a grammar array.`);
}
return chain;
}
fs.rmSync(OUT, { recursive: true, force: true });
fs.mkdirSync(OUT, { recursive: true });
const manifest = { shikiVersion, languages: {} };
const written = new Map();
let bytes = 0;
for (const id of REQUIRED_GRAMMARS) {
let chain;
try {
chain = await loadChain(id);
} catch (err) {
fail(`could not load the ${id} grammar: ${err?.message ?? err}`);
}
const files = [];
for (const grammar of chain) {
// A chain can name the same dependency more than once (Vue reaches
// JavaScript four different ways). Registering it twice is wasted work and
// a confusing manifest; the FIRST occurrence is the one that keeps the
// dependencies-before-dependents ordering intact.
// `name` is the grammar's own id and is unique across the bundle, so two
// languages that embed html write (and share) exactly one html.json.
const file = grammar.name;
if (typeof file !== 'string' || !/^[\w.+-]+$/.test(file)) {
fail(`the ${id} chain contains a grammar with an unusable name: ${JSON.stringify(file)}`);
}
if (files.includes(file)) continue;
if (!written.has(file)) {
const json = JSON.stringify(grammar);
fs.writeFileSync(path.join(OUT, `${file}.json`), json);
written.set(file, json.length);
bytes += json.length;
}
files.push(file);
}
manifest.languages[id] = files;
}
fs.writeFileSync(path.join(OUT, 'manifest.json'), JSON.stringify(manifest, null, 2));
process.stdout.write(
`[prune-grammars] ${REQUIRED_GRAMMARS.length} languages -> ${written.size} grammars, ` +
`${(bytes / 1024 / 1024).toFixed(1)} MB in dist/textmate (shiki ${shikiVersion})\n`
);
+4 -2
View File
@@ -87,7 +87,9 @@ const API_INDEX = {
export function createGraphApi(options: GraphApiOptions): GraphApi {
const session = new GraphSession(options.projectRoot);
const handler: UiApiHandler = (_req, res, ctx) => {
// Async because `/api/source` highlights: everything else answers straight
// out of SQLite and resolves on the same tick.
const handler: UiApiHandler = async (_req, res, ctx) => {
const route = normalize(ctx.pathname);
try {
switch (route) {
@@ -104,7 +106,7 @@ export function createGraphApi(options: GraphApiOptions): GraphApi {
case '/api/nodes':
return ok(res, buildNodeRefs(session.acquire(), ctx.query), ctx.method);
case '/api/source':
return ok(res, buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
return ok(res, await buildSource(session.acquire(), ctx.projectRoot, ctx.query), ctx.method);
default:
return dispatchPathRoutes(route, res, ctx, session);
}
+22 -3
View File
@@ -30,6 +30,7 @@ import * as path from 'path';
import type { FileRecord } from '../../types';
import type { CodeGraph } from '../../index';
import { resolveProjectFile } from '../security';
import { highlightLines, type HighlightResult } from '../highlight';
import { ApiError, badRequest, intParam, notFound, textParam } from './respond';
/**
@@ -186,13 +187,23 @@ export interface SourceResult {
lines?: string[];
truncated?: boolean;
reason?: string;
/**
* The same lines, classified for the code block — one entry per line, each a
* list of `[classId, text]` pairs indexed into `highlight.classes`.
*
* It rides with the slice rather than living behind its own endpoint because
* the two are only ever wanted together, and because a second round-trip
* would let the viewer paint unhighlighted source and then reflow it. Absent
* whenever `lines` is — a drifted file is not served at all.
*/
highlight?: HighlightResult;
}
export function buildSource(
export async function buildSource(
cg: CodeGraph,
projectRoot: string,
query: URLSearchParams
): SourceResult {
): Promise<SourceResult> {
const requested = textParam(query, 'file');
// Refusal first, index lookup second — see `resolveRequestedFile`.
const { record, storedPath, absolute } = resolveRequestedFile(cg, projectRoot, requested);
@@ -265,13 +276,21 @@ export function buildSource(
const start = from;
const requestedEnd = to === 0 ? all.length : Math.min(to, all.length);
const end = Math.min(requestedEnd, start + MAX_SOURCE_LINES - 1);
const slice = all.slice(start - 1, end);
return {
...base,
totalLines: all.length,
from: start,
to: end,
lines: all.slice(start - 1, end),
lines: slice,
truncated: end < requestedEnd,
// Keyed on the content hash, so the cache is invalidated by the file
// changing rather than by a clock, and two viewers looking at the same
// symbol share one tokenisation.
highlight: await highlightLines(slice, {
language: record.language,
cacheKey: `${record.contentHash}:${start}:${end}`,
}),
};
}
+86
View File
@@ -0,0 +1,86 @@
/**
* Finding and reading the pruned TextMate grammars on disk.
*
* Shiki ships 700-odd grammars; the engine indexes 40-odd languages. The build
* writes only the closure those 40 need — including the grammars they embed, so
* a `.vue` file still gets its `<script lang="ts">` — into `dist/textmate/`,
* and `@shikijs/langs` stays a devDependency that never reaches a user's disk.
* See `scripts/prune-grammars.mjs`.
*
* They are located the way `db/index.ts` finds `schema.sql` and `assets.ts`
* finds the viewer: relative to `__dirname`, never to `process.cwd()`, which is
* whatever directory the user happened to be standing in.
*
* `dist/textmate`, not `dist/highlight` — `src/ui-server/highlight/` is this
* module and tsc already owns `dist/ui-server/highlight/`. The same collision
* that put the viewer in `dist/viewer` rather than `dist/ui`.
*/
import * as fs from 'fs';
import * as path from 'path';
/** Overrides where the grammars are read from. For tests and for packagers. */
export const TEXTMATE_PATH_ENV = 'CODEGRAPH_TEXTMATE_PATH';
/** What `scripts/prune-grammars.mjs` writes beside the grammar files. */
export interface GrammarManifest {
/** Shiki version the grammars were pruned from — surfaced when one fails. */
shikiVersion: string;
/** Grammar id → the files to load, dependencies first, the grammar itself last. */
languages: Record<string, string[]>;
}
/**
* Candidate locations, most-specific first.
*
* 1. The `CODEGRAPH_TEXTMATE_PATH` override.
* 2. `<__dirname>/../../textmate` — the shipped layout
* (`dist/ui-server/highlight/` → `dist/textmate/`).
* 3. `<__dirname>/../../../dist/textmate` — running the TypeScript straight out
* of `src/` (vitest, tsx), where `__dirname` is `src/ui-server/highlight/`.
*/
export function grammarDirCandidates(): string[] {
const override = process.env[TEXTMATE_PATH_ENV]?.trim();
const candidates = [
path.join(__dirname, '..', '..', 'textmate'),
path.join(__dirname, '..', '..', '..', 'dist', 'textmate'),
];
return override ? [path.resolve(override), ...candidates] : candidates;
}
/**
* The grammar directory and its manifest, or null when the build did not run.
*
* Null is a normal outcome, not an error: a source checkout that has only had
* `tsc` run against it has no `dist/textmate`, and the right answer there is
* plain text, not a 500 on a request for source.
*/
export function loadManifest(): { dir: string; manifest: GrammarManifest } | null {
for (const dir of grammarDirCandidates()) {
try {
const raw = fs.readFileSync(path.join(dir, 'manifest.json'), 'utf-8');
const manifest = JSON.parse(raw) as GrammarManifest;
if (manifest && typeof manifest === 'object' && manifest.languages) return { dir, manifest };
} catch {
// Not here — try the next candidate.
}
}
return null;
}
/**
* Read the grammar registrations one language needs, dependencies first.
*
* Shiki resolves a grammar's `embeddedLangs` against what is already in its
* registry, so the order the manifest records matters: `vue` must arrive after
* the `html`, `css` and `typescript` it embeds, or the embedded blocks come
* back unhighlighted.
*/
export function readGrammarChain(dir: string, manifest: GrammarManifest, id: string): unknown[] {
const files = manifest.languages[id];
if (!files) return [];
return files.map((file) => {
const full = path.join(dir, `${file}.json`);
return JSON.parse(fs.readFileSync(full, 'utf-8')) as unknown;
});
}
+407
View File
@@ -0,0 +1,407 @@
/**
* Server-side syntax classification for the viewer's code block (CG-43).
*
* The viewer used to lex on the client with a hand-rolled dialect table. This
* replaces it with real TextMate grammars, run once here, so a Go file reads as
* Go rather than as "something with braces". Three properties keep that from
* becoming a liability:
*
* * **It never fails a request.** A missing grammar, a corrupt grammar file, an
* ESM import that did not resolve, a slice too big to be worth tokenising —
* every one of them answers `engine: 'plain'` with a reason and the source
* still goes out. Highlighting is the part that degrades; nothing else does.
* * **Identifiers survive whatever token boundaries the grammar chose.** Every
* code token is split into identifier runs before it goes on the wire, which
* is what lets the viewer wrap a call site as a link by *claiming a token*
* rather than re-tokenising the line on top of the highlighter's answer.
* * **The classification is a class name, not a colour.** See `theme.ts` — the
* viewer paints from CSS custom properties, so one token stream serves light
* and dark and the design tokens live in exactly one place.
*
* ## Cost, measured
*
* Shiki's JavaScript regex engine (no oniguruma wasm, no native module) runs at
* roughly 17 us/line on Go, 34 us/line on Python and 230 us/line on TypeScript,
* whose TextMate grammar is by a wide margin the most expensive one here. A
* 3 000-line TypeScript file is therefore ~700 ms cold, which is why
* {@link SLICE_CACHE_LIMIT} exists: a slice is keyed by the file's content hash
* and its line range, so every re-render — a theme flip, a resize, stepping
* back to a symbol — is a map lookup. Phase 1 only ever asks for one symbol's
* range (tens of lines); the whole-file view is CG-52 and tree-sitter tokens
* from the engine's own parse replace this module entirely in CG-57.
*/
import type {
ShikiCoreModule,
ShikiHighlighter,
ShikiJavaScriptEngineModule,
ShikiThemedToken,
} from './shiki-types';
import { CLASS_ID, MONO_THEME, TOKEN_CLASSES, classOf, type TokenClassName } from './theme';
import { grammarFor } from './languages';
import { loadManifest, readGrammarChain, type GrammarManifest } from './grammars';
export { TOKEN_CLASSES } from './theme';
export { LANGUAGE_GRAMMAR, REQUIRED_GRAMMARS, grammarFor } from './languages';
export { TEXTMATE_PATH_ENV } from './grammars';
/** One token on the wire: its class id, then its text. */
export type WireToken = [number, string];
export interface HighlightResult {
/** `shiki` when a grammar produced the classes; `plain` when nothing did. */
engine: 'shiki' | 'plain';
/** The TextMate grammar used, or null. */
grammar: string | null;
/** Class names, indexed by the first element of every {@link WireToken}. */
classes: readonly string[];
/** One entry per source line, in order. */
lines: WireToken[][];
/** Why the answer is plain, when it is. Absent on the happy path. */
reason?: string;
}
/**
* Lines above this are not tokenised.
*
* Matches `MAX_SOURCE_LINES`, so anything the source endpoint will serve, this
* will try to highlight.
*/
export const MAX_HIGHLIGHT_LINES = 4000;
/**
* Characters above this are not tokenised.
*
* The line cap alone does not bound the work: one minified bundle line can be
* two megabytes, and a TextMate scanner walks it character by character. This
* is the guard that keeps a single request from wedging a single-threaded
* loopback server, and it is generous — 600 kB is far more source than any
* screen renders.
*/
export const MAX_HIGHLIGHT_CHARS = 600_000;
/** Highlighted slices kept in memory. Most are one symbol's body. */
export const SLICE_CACHE_LIMIT = 96;
/**
* Total cached lines, which is the bound that actually matters.
*
* The entry count alone does not bound memory: 96 slices of a symbol body is a
* megabyte, 96 whole 4 000-line files is two orders of magnitude more, and this
* process is a reader someone leaves open all day. Twenty thousand lines is
* roughly a working set of every symbol a session visits, or a handful of whole
* files, and the eviction is the same recency order.
*/
export const SLICE_CACHE_LINES = 20_000;
/* ----------------------------------------------------------- the runtime -- */
/**
* tsc compiles `import()` to `require()` under `module: commonjs`, which fails
* for an ESM-only package. Same escape hatch `src/bin/codegraph.ts` uses.
*/
const importESM = new Function('specifier', 'return import(specifier)') as (
specifier: string
) => Promise<unknown>;
/**
* Import an ESM-only package from this CommonJS build.
*
* The `new Function` route is the one that runs in production. It does NOT run
* under Vitest, whose module runner evaluates this file without a dynamic-import
* callback ("A dynamic import callback was not specified") — there, the
* transformed `import()` below is the working one, and in the shipped CommonJS
* build it is the one that cannot work. Each covers exactly the other's gap;
* neither alone is enough, which is why both are here.
*/
async function loadEsm<T>(specifier: string): Promise<T> {
try {
return (await importESM(specifier)) as T;
} catch (err) {
if (!(err instanceof Error) || !/dynamic import callback/i.test(err.message)) throw err;
return (await import(/* @vite-ignore */ specifier)) as T;
}
}
interface Runtime {
highlighter: ShikiHighlighter;
dir: string;
manifest: GrammarManifest;
}
let runtimePromise: Promise<Runtime | null> | null = null;
/** Why the runtime is unavailable, for the `reason` on a plain answer. */
let runtimeFailure: string | null = null;
async function getRuntime(): Promise<Runtime | null> {
if (!runtimePromise) runtimePromise = createRuntime();
return runtimePromise;
}
async function createRuntime(): Promise<Runtime | null> {
const found = loadManifest();
if (!found) {
runtimeFailure =
'No syntax grammars are installed with this build, so source is shown unhighlighted.';
return null;
}
try {
const core = await loadEsm<ShikiCoreModule>('@shikijs/core');
const engineModule = await loadEsm<ShikiJavaScriptEngineModule>('@shikijs/engine-javascript');
const highlighter = core.createHighlighterCoreSync({
themes: [MONO_THEME],
langs: [],
// The JavaScript regex engine, deliberately: no oniguruma wasm and no
// native module, so the viewer adds nothing to the install that has to
// be compiled or fetched per platform. `forgiving` skips the handful of
// Oniguruma-only patterns it cannot translate rather than refusing the
// whole grammar over them.
engine: engineModule.createJavaScriptRegexEngine({ forgiving: true, cache: new Map() }),
});
return { highlighter, dir: found.dir, manifest: found.manifest };
} catch (err) {
runtimeFailure = `Syntax highlighting is unavailable (${
err instanceof Error ? err.message : String(err)
}).`;
return null;
}
}
/** Grammar ids already handed to the highlighter, and the ones that failed. */
const loadedGrammars = new Set<string>();
const brokenGrammars = new Map<string, string>();
function ensureGrammar(runtime: Runtime, id: string): string | null {
if (loadedGrammars.has(id)) return null;
const broken = brokenGrammars.get(id);
if (broken !== undefined) return broken;
try {
const chain = readGrammarChain(runtime.dir, runtime.manifest, id);
if (chain.length === 0) {
const reason = `No ${id} grammar shipped with this build, so it is shown unhighlighted.`;
brokenGrammars.set(id, reason);
return reason;
}
runtime.highlighter.loadLanguageSync(chain);
loadedGrammars.add(id);
return null;
} catch (err) {
const reason = `The ${id} grammar could not be loaded (${
err instanceof Error ? err.message : String(err)
}).`;
brokenGrammars.set(id, reason);
return reason;
}
}
/* -------------------------------------------------------------- the cache -- */
const sliceCache = new Map<string, HighlightResult>();
let cachedLines = 0;
function cacheGet(key: string): HighlightResult | undefined {
const hit = sliceCache.get(key);
// Re-insert so the map's insertion order is a recency order and the first
// key is always the coldest.
if (hit) {
sliceCache.delete(key);
sliceCache.set(key, hit);
}
return hit;
}
function cachePut(key: string, value: HighlightResult): void {
sliceCache.set(key, value);
cachedLines += value.lines.length;
while (
sliceCache.size > SLICE_CACHE_LIMIT ||
(cachedLines > SLICE_CACHE_LINES && sliceCache.size > 1)
) {
const oldest = sliceCache.keys().next();
if (oldest.done) break;
cachedLines -= sliceCache.get(oldest.value)?.lines.length ?? 0;
sliceCache.delete(oldest.value);
}
}
/** Drop everything cached. Tests use it; nothing in the server needs to. */
export function clearHighlightCache(): void {
sliceCache.clear();
cachedLines = 0;
}
/** What the slice cache is holding — for tests, and for anyone diagnosing it. */
export function highlightCacheStats(): { entries: number; lines: number } {
return { entries: sliceCache.size, lines: cachedLines };
}
/* ------------------------------------------------------------- the entry -- */
export interface HighlightOptions {
/** The engine's language for the file, e.g. `typescript`. */
language?: string | null;
/**
* A key that changes whenever the text does — the file's content hash plus
* the requested range. Omit it and the slice is tokenised every time.
*/
cacheKey?: string;
}
/**
* Classify `lines` for the viewer's code block.
*
* Never throws and never rejects: every failure path returns a plain result
* carrying the reason, because the caller is serving source and the source is
* the part that matters.
*/
export async function highlightLines(
lines: readonly string[],
options: HighlightOptions = {}
): Promise<HighlightResult> {
const grammar = grammarFor(options.language);
const key = options.cacheKey ? `${grammar ?? '-'} ${options.cacheKey}` : null;
if (key) {
const hit = cacheGet(key);
if (hit) return hit;
}
const result = await highlightUncached(lines, grammar);
if (key) cachePut(key, result);
return result;
}
async function highlightUncached(
lines: readonly string[],
grammar: string | null
): Promise<HighlightResult> {
if (!grammar) {
return plain(lines, null, 'No syntax grammar covers this file type.');
}
if (lines.length > MAX_HIGHLIGHT_LINES) {
return plain(lines, grammar, `Too many lines to highlight (over ${MAX_HIGHLIGHT_LINES}).`);
}
let chars = 0;
for (const line of lines) chars += line.length + 1;
if (chars > MAX_HIGHLIGHT_CHARS) {
return plain(lines, grammar, 'Too much text on too few lines to highlight (minified?).');
}
const runtime = await getRuntime();
if (!runtime) return plain(lines, grammar, runtimeFailure ?? undefined);
const failure = ensureGrammar(runtime, grammar);
if (failure) return plain(lines, grammar, failure);
let tokenized: ShikiThemedToken[][];
try {
tokenized = runtime.highlighter.codeToTokensBase(lines.join('\n'), {
lang: grammar,
theme: MONO_THEME.name,
});
} catch (err) {
// A grammar that throws once will throw again on the next request for the
// same file type, so it is retired rather than retried.
const reason = `The ${grammar} grammar failed on this file (${
err instanceof Error ? err.message : String(err)
}).`;
brokenGrammars.set(grammar, reason);
loadedGrammars.delete(grammar);
return plain(lines, grammar, reason);
}
// A trailing empty line, or a grammar that answered short, must not shift the
// viewer's line numbering — the rows are indexed positionally.
const out: WireToken[][] = lines.map((line, i) => {
const row = tokenized[i];
return row ? atomize(row) : atomizePlain(line);
});
return { engine: 'shiki', grammar, classes: TOKEN_CLASSES, lines: out };
}
function plain(lines: readonly string[], grammar: string | null, reason?: string): HighlightResult {
return {
engine: 'plain',
grammar,
classes: TOKEN_CLASSES,
lines: lines.map(atomizePlain),
...(reason ? { reason } : {}),
};
}
/* ---------------------------------------------------------- atomisation -- */
/**
* An identifier, in the loosest sense every indexed language agrees on.
*
* The high range is there because `\w` is ASCII-only in JavaScript and a symbol
* name can be Chinese, Japanese or Cyrillic; a call site in those repositories
* has to be linkable too.
*/
const IDENT = /[A-Za-z_$À-￿][\w$À-￿]*/g;
/**
* Split a grammar's tokens into identifier runs, merging everything else.
*
* This is the step that makes the graph's call-site links independent of how a
* grammar chose to chunk a line. TextMate is free to emit `this.mutex.withLock`
* as one token, three, or five, and the viewer has to be able to wrap exactly
* `withLock`; giving it identifier-sized atoms up front means the overlay only
* ever *claims* a token, never re-cuts one.
*
* Comments and strings are left whole on purpose: no edge points inside one,
* and a doc comment split into forty atoms is forty times the wire bytes for
* nothing.
*/
function atomize(tokens: readonly ShikiThemedToken[]): WireToken[] {
const out: WireToken[] = [];
for (const token of tokens) {
const cls = classOf(token.color);
if (cls === 'comment' || cls === 'string') {
push(out, cls, token.content);
continue;
}
splitIdentifiers(out, token.content, cls);
}
return out;
}
function atomizePlain(line: string): WireToken[] {
const out: WireToken[] = [];
splitIdentifiers(out, line, 'other');
return out;
}
/**
* Emit `text` as alternating non-identifier and identifier runs.
*
* An identifier inside a token the grammar called a keyword keeps the keyword
* class — `func` should still carry its weight — while the overlay's matcher
* looks at a token's *text*, not its class, so a language whose grammar scopes
* type names as `storage.type` still links.
*/
function splitIdentifiers(out: WireToken[], text: string, cls: TokenClassName): void {
if (text === '') return;
IDENT.lastIndex = 0;
let at = 0;
let match: RegExpExecArray | null;
while ((match = IDENT.exec(text)) !== null) {
if (match.index > at) push(out, cls === 'ident' ? 'other' : cls, text.slice(at, match.index));
push(out, cls === 'other' ? 'ident' : cls, match[0]);
at = match.index + match[0].length;
}
if (at < text.length) push(out, cls === 'ident' ? 'other' : cls, text.slice(at));
}
/** Append, merging into the previous token when it carries the same class. */
function push(out: WireToken[], cls: TokenClassName, text: string): void {
if (text === '') return;
const id = CLASS_ID[cls];
const last = out[out.length - 1];
// Identifiers are never merged: each one has to stay claimable on its own.
if (last && last[0] === id && id !== CLASS_ID.ident) {
last[1] += text;
return;
}
out.push([id, text]);
}
+93
View File
@@ -0,0 +1,93 @@
/**
* Engine `Language` → TextMate grammar, and the closure of grammars that has
* to ship for those to load.
*
* The engine indexes 40-odd languages; Shiki carries 700-odd grammars. Shipping
* all of them would put 11 MB of JSON in the bundle to serve 40, so the build
* prunes them (`scripts/prune-grammars.mjs`) to exactly the closure this table
* names — which is why the table lives in its own module: the build script
* reads the compiled `dist/ui-server/highlight/languages.js` rather than keeping
* a second copy of the mapping that could drift from the runtime's.
*
* A language with no entry (or with `null`) is not an error. It renders as
* plain text with its identifiers still split out, so the graph's call-site
* links land exactly as they do everywhere else — highlighting is the part that
* degrades, never the linking.
*/
import type { Language } from '../../types';
/**
* The grammar each indexed language is read with.
*
* Three of these are deliberate approximations, marked below: Shiki has no
* ColdFusion grammar, and the three CFML dialects the engine distinguishes are
* each a close relative of something it does have. An approximate keyword set
* is a better answer than no colouring at all, and nothing downstream depends
* on the grammar being exact — the links come from the graph.
*/
export const LANGUAGE_GRAMMAR: Record<Language, string | null> = {
typescript: 'typescript',
javascript: 'javascript',
tsx: 'tsx',
jsx: 'jsx',
// ArkTS is TypeScript plus HarmonyOS decorators — the TS grammar reads it.
arkts: 'typescript',
python: 'python',
go: 'go',
rust: 'rust',
java: 'java',
c: 'c',
cpp: 'cpp',
csharp: 'csharp',
razor: 'razor',
php: 'php',
ruby: 'ruby',
swift: 'swift',
kotlin: 'kotlin',
dart: 'dart',
svelte: 'svelte',
vue: 'vue',
astro: 'astro',
liquid: 'liquid',
pascal: 'pascal',
scala: 'scala',
lua: 'lua',
luau: 'luau',
objc: 'objective-c',
r: 'r',
solidity: 'solidity',
nix: 'nix',
yaml: 'yaml',
twig: 'twig',
xml: 'xml',
properties: 'properties',
// Approximations — no CFML grammar exists. Tag soup reads as HTML, cfscript
// is a JavaScript-shaped dialect, and a <cfquery> body is SQL.
cfml: 'html',
cfscript: 'javascript',
cfquery: 'sql',
cobol: 'cobol',
vbnet: 'vb',
erlang: 'erlang',
terraform: 'terraform',
// Not a language, the absence of one: a file no extractor claimed.
unknown: null,
};
/** Every grammar the build must prune to, de-duplicated, in a stable order. */
export const REQUIRED_GRAMMARS: readonly string[] = [
...new Set(Object.values(LANGUAGE_GRAMMAR).filter((id): id is string => id !== null)),
].sort();
/**
* The grammar for an indexed language, or null when it has none.
*
* Accepts the raw string off a `FileRecord` rather than a `Language`, because
* an index written by an older engine can hold a language this build has since
* renamed, and a viewer must not throw over that.
*/
export function grammarFor(language: string | undefined | null): string | null {
if (!language) return null;
return LANGUAGE_GRAMMAR[language as Language] ?? null;
}
+40
View File
@@ -0,0 +1,40 @@
/**
* The slice of Shiki's surface this server uses, declared locally.
*
* `@shikijs/core` is ESM-only and the engine compiles to CommonJS, so it is
* loaded through the same `new Function('return import(...)')` escape hatch the
* CLI uses for `@clack/prompts` — which means tsc never sees the import and
* cannot type it. Rather than fight `.d.mts` resolution under
* `module: commonjs`, the four shapes actually touched are written out here.
* They are checked against the real package by the highlighter's tests: a
* signature change shows up as a failing highlight, not as a silent `any`.
*/
export interface ShikiThemedToken {
content: string;
color?: string;
fontStyle?: number;
}
export interface ShikiHighlighter {
loadLanguageSync(lang: unknown): void;
getLoadedLanguages(): string[];
codeToTokensBase(code: string, options: { lang: string; theme: string }): ShikiThemedToken[][];
dispose?(): void;
}
export interface ShikiCoreModule {
createHighlighterCoreSync(options: {
themes: unknown[];
langs: unknown[];
engine: unknown;
}): ShikiHighlighter;
}
export interface ShikiJavaScriptEngineModule {
createJavaScriptRegexEngine(options?: {
forgiving?: boolean;
target?: 'auto' | 'ES2025' | 'ES2024' | 'ES2018';
cache?: Map<string, RegExp | Error> | null;
}): unknown;
}
+115
View File
@@ -0,0 +1,115 @@
/**
* The near-monochrome code theme (design spec §2.2).
*
* The colouring is deliberately almost absent: comments and strings recede,
* keywords carry weight rather than hue, and the ONLY colour in the body is a
* resolved call site. A six-colour syntax theme buries exactly the thing the
* screen exists to show.
*
* ## Why the theme's colours are sentinels, not colours
*
* A TextMate theme classifies by mapping scopes to colours, so that is how the
* classification is *expressed* — but the values here are placeholders that
* mean "comment", "string", "keyword", "number", nothing. The server turns each
* one back into a class name; the viewer paints it from a CSS custom property.
*
* That indirection is load-bearing, not decoration:
*
* * **One token stream serves both modes.** The viewer flips light/dark from
* `prefers-color-scheme` with no reload and no refetch. Baking `#6a675d` into
* the payload would make dark mode a second request for the same source, and
* would put the design tokens in two places at once.
* * **Contrast is fixed where the tokens live.** `ui/src/app.css` owns the
* ramp; a colour change there cannot leave the server's copy behind.
*
* The sentinels are arbitrary but must be distinct and must never be a colour a
* grammar could plausibly emit through some other path, hence the `#00000n`
* block: TextMate themes only ever return values *this* theme defines.
*/
/** The classes a token can carry — the viewer's `TokenClass`, server side. */
export const TOKEN_CLASSES = ['other', 'ident', 'comment', 'string', 'keyword', 'number'] as const;
export type TokenClassName = (typeof TOKEN_CLASSES)[number];
/** Class name → its index in {@link TOKEN_CLASSES}, which is what the wire carries. */
export const CLASS_ID: Record<TokenClassName, number> = {
other: 0,
ident: 1,
comment: 2,
string: 3,
keyword: 4,
number: 5,
};
const FG_DEFAULT = '#000001';
const FG_COMMENT = '#000002';
const FG_STRING = '#000003';
const FG_KEYWORD = '#000004';
const FG_NUMBER = '#000005';
/** Sentinel foreground → the class it stands for. */
export const SENTINEL_CLASS: Record<string, TokenClassName> = {
[FG_DEFAULT]: 'other',
[FG_COMMENT]: 'comment',
[FG_STRING]: 'string',
[FG_KEYWORD]: 'keyword',
[FG_NUMBER]: 'number',
};
/**
* The theme itself.
*
* Scope selection follows the spec exactly: `comment` recedes furthest,
* `string`/`constant.numeric` sit one step in, `keyword`/`storage` stay ink and
* gain weight, everything else is ink. Nothing sets a background — a token that
* painted its own would fight the hovered-line and hot-line tints the rails use
* to point at it.
*/
export const MONO_THEME = {
name: 'codegraph-mono',
type: 'light' as const,
fg: FG_DEFAULT,
// TextMate wants a background; the viewer never reads it (the code block
// paints `--paper`), and it must not equal a foreground sentinel.
bg: '#ffffff',
settings: [
{ settings: { foreground: FG_DEFAULT } },
{ scope: ['comment', 'punctuation.definition.comment'], settings: { foreground: FG_COMMENT } },
{
scope: [
'string',
'string.template',
'punctuation.definition.string',
'constant.character.escape',
],
settings: { foreground: FG_STRING },
},
{
scope: ['constant.numeric', 'constant.language', 'keyword.other.unit'],
settings: { foreground: FG_NUMBER },
},
{
scope: ['keyword', 'keyword.control', 'storage', 'storage.type', 'storage.modifier'],
settings: { foreground: FG_KEYWORD },
},
// `keyword.operator` is a keyword scope by name only: it covers `=`, `+`,
// `=>` and `?.`. Weighting punctuation buys nothing and costs the calm the
// rest of the block is built on, so it drops back to plain ink — while the
// operators that are actually WORDS (`new`, `typeof`, `instanceof`, `in`)
// keep their weight through the more specific rule below. Shiki resolves
// the longest matching scope, so the order here is the order of rescue,
// not of priority.
{ scope: ['keyword.operator'], settings: { foreground: FG_DEFAULT } },
{
scope: ['keyword.operator.expression', 'keyword.operator.word', 'keyword.operator.new'],
settings: { foreground: FG_KEYWORD },
},
],
};
/** The class a Shiki token's resolved colour stands for. */
export function classOf(color: string | undefined): TokenClassName {
if (!color) return 'other';
return SENTINEL_CLASS[color.toLowerCase()] ?? 'other';
}
+14
View File
@@ -30,6 +30,18 @@
--amber: #8a5a0b;
--amber-soft: #f3e9d2;
/* The one code colour that is not a plain re-use of the ink ramp.
The spec asks for comments at --ink-3; measured against --paper that
is 3.46:1 and against the hot-line tint --accent-soft it is 3.00:1,
both under the 4.5:1 an AA reading of 12.5px body text needs. This is
the smallest step DOWN the same warm-grey ramp that clears 4.5:1 on
all three backgrounds a code line can have (paper 5.23, paper-2 4.92,
accent-soft 4.53) while staying quieter than --ink-2, which strings
and numbers use — so the recession order the spec describes is
unchanged, only legible. Dark needed the mirror step UP (4.51 on
accent-soft, where --ink-3 was 4.10). */
--code-comment: #6a675d;
--sans: 'Archivo Variable', 'Archivo', -apple-system, BlinkMacSystemFont, 'Helvetica Neue', Arial, sans-serif;
--mono: 'IBM Plex Mono', ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
--code-size: 12.5px;
@@ -66,6 +78,7 @@
--accent-line: #6b3a42;
--amber: #d9a94a;
--amber-soft: #2e2716;
--code-comment: #8e8b81;
}
}
@@ -87,6 +100,7 @@
--accent-line: #6b3a42;
--amber: #d9a94a;
--amber-soft: #2e2716;
--code-comment: #8e8b81;
}
/* ---------- reset ---------- */
+30 -20
View File
@@ -4,21 +4,25 @@
Two things make this more than a <pre>:
* The lexer state is threaded across lines AND across the gaps between
windows, so the first line after a skipped block is not mis-read as the
inside of a comment that closed 200 lines ago.
* Syntax classification arrives already done, from `/api/source` — real
TextMate grammars, run server-side, indexed by file line. The whole slice
is tokenised in one pass there, so a window that starts 200 lines into a
body still knows it is inside a block comment; nothing is re-lexed here.
* Each ref is matched to an actual token rather than to a column, because the
recorded column points at the start of the calling expression — see
`assignRefs`.
`assignRefs`. The overlay CLAIMS a token the highlighter produced; it never
re-cuts one, which is what keeps the accent underline landing on the
callee's own name whatever boundaries a grammar chose.
-->
<script lang="ts">
import { newLexState, tokenClass, tokenize, type Token } from '../../lib/highlight';
import { tokenClass, type Token } from '../../lib/highlight';
import { assignRefs, type CodeBlock, type LineRef } from '../../lib/symbol-model';
import { hot } from '../../lib/focus.svelte';
interface Props {
block: CodeBlock;
language: string;
/** Classified source by 1-based file line — see `tokensByLine`. */
tokens: Map<number, Token[]>;
refs: Map<number, LineRef[]>;
/** The line the definition's own name sits on — it is set in bold there. */
defLine: number;
@@ -28,7 +32,7 @@
onfollow: (ref: LineRef) => void;
}
let { block, language, refs, defLine, defName, highlight, onfollow }: Props = $props();
let { block, tokens, refs, defLine, defName, highlight, onfollow }: Props = $props();
interface Part {
text: string;
@@ -52,33 +56,37 @@
lines: RenderedLine[];
}
let chunks = $derived.by<Chunk[]>(() => {
const state = newLexState();
return block.windows.map((window, windowIndex) => ({
let chunks = $derived.by<Chunk[]>(() =>
block.windows.map((window, windowIndex) => ({
gapBefore: windowIndex === 0 ? 0 : (block.gapsAfter[windowIndex - 1] ?? 0),
lines: window.lines.map((text, offset) => {
const n = window.start + offset;
const tokens = tokenize(text, state, language);
const lineTokens = tokens.get(n) ?? [{ cls: 'other' as const, text, col: 0 }];
const lineRefs = refs.get(n) ?? [];
const claimed = assignRefs(tokens, lineRefs);
const claimed = assignRefs(lineTokens, lineRefs);
return {
n,
parts: toParts(tokens, claimed, n === defLine ? defName : null),
parts: toParts(lineTokens, claimed, n === defLine ? defName : null),
port: portFor(lineRefs),
targets: [...new Set(lineRefs.map((r) => r.targetId).filter((id): id is string => !!id))],
};
}),
}));
});
}))
);
function toParts(tokens: Token[], claimed: Map<number, LineRef>, definition: string | null): Part[] {
return tokens.map((token, index) => {
function toParts(line: Token[], claimed: Map<number, LineRef>, definition: string | null): Part[] {
return line.map((token, index) => {
const ref = claimed.get(index) ?? null;
return {
text: token.text,
cls: ref ? null : tokenClass(token.cls),
ref,
def: !ref && definition !== null && token.cls === 'ident' && token.text === definition,
def:
!ref &&
definition !== null &&
token.text === definition &&
token.cls !== 'comment' &&
token.cls !== 'string',
};
});
}
@@ -215,9 +223,11 @@
font-size: 11px;
}
/* ---- token classes (near-monochrome by design, spec §2.2) ---- */
/* ---- token classes (near-monochrome by design, spec §2.2) ----
Comments use --code-comment rather than --ink-3: the spec's colour reads
at 3.46:1 on paper, under AA for 12.5px text. See app.css. */
.t-c {
color: var(--ink-3);
color: var(--code-comment);
}
.t-s {
+9
View File
@@ -11,6 +11,8 @@
* carries the server's own sentence instead of "Failed to fetch".
*/
import type { WireHighlight } from './highlight';
/* ---------------------------------------------------------------- shapes -- */
export type NodeKind = string;
@@ -153,6 +155,13 @@ export interface WireSource {
lines?: string[];
truncated?: boolean;
reason?: string;
/**
* The same lines, classified by the server's TextMate grammars — one entry
* per line, each a list of `[classId, text]` pairs indexed into `classes`.
* Absent whenever `lines` is, and `engine: 'plain'` whenever no grammar
* covers the file. See `lib/highlight.ts`.
*/
highlight?: WireHighlight;
}
export interface WireBlastScale {
+92 -299
View File
@@ -1,326 +1,119 @@
/**
* Near-monochrome tokenising for the code block (design spec §2.2).
* Turning the server's classified source into tokens the code block can draw.
*
* The colouring is deliberately almost absent: comments and strings recede,
* keywords carry weight rather than hue, and the ONLY colour in the body is a
* resolved call site. That is the point of the screen — the graph's edges are
* what the eye should find, and a six-colour syntax theme buries them.
* The classification itself happens on the server (`src/ui-server/highlight/`),
* with real TextMate grammars via Shiki. What arrives is deliberately small:
* one array per line, each entry a `[classId, text]` pair, with the class names
* carried alongside so the payload is self-describing. This module does two
* things to it and nothing else — resolve the class ids to names, and compute
* each token's column, which is what the graph's call-site overlay matches
* against.
*
* A hand-rolled lexer, not a highlighter library. It has one job — separate
* comments, strings, numbers and keywords from everything else, well enough to
* be honest across the 30-odd languages the engine indexes — and doing it here
* keeps the viewer free of a runtime dependency and of a per-grammar download
* on a machine that is reading its own source offline. CG-43 replaces this
* with Shiki tokens produced server-side; `tokenize` is the seam.
* ## Why the classes are names and not colours
*
* A theme that sent colours would have to send two of them, or force a refetch
* every time `prefers-color-scheme` flipped. Class names let one token stream
* serve light and dark and keep the design tokens in `app.css`, which is the
* only place they should live. Design spec §2.2 — comments recede furthest,
* strings and numbers one step in, keywords stay ink and gain weight, and the
* only colour in the body is a call site the graph resolved.
*
* Nothing here re-tokenises. The overlay claims tokens the highlighter already
* produced (`assignRefs` in `symbol-model.ts`), which is what makes the accent
* underline land on the callee's own name whatever boundaries a grammar chose.
*/
export type TokenClass =
| 'comment'
| 'string'
| 'keyword'
| 'number'
| 'ident'
| 'space'
| 'punct';
export type TokenClass = 'other' | 'ident' | 'comment' | 'string' | 'keyword' | 'number';
export interface Token {
cls: TokenClass;
text: string;
/** Column of the token's first character, 0-based — how a ref finds its identifier. */
/** Column of the token's first character, 0-based — how a ref finds it. */
col: number;
}
/**
* Lexer state that survives from one line to the next: a block comment or a
* multi-line string opened on an earlier line. Rendering a window of a file
* without this makes the first line after a `/*` look like code.
*/
export interface LexState {
block: boolean;
/** The delimiter that will close the open multi-line string (a backtick, `"""`, …). */
stringEnd: string | null;
/** `[classId, text]`, indexed into the payload's `classes` table. */
export type WireToken = [number, string];
export interface WireHighlight {
engine: 'shiki' | 'plain';
grammar: string | null;
classes: string[];
lines: WireToken[][];
/** Why the answer is unhighlighted, when it is. */
reason?: string;
}
export function newLexState(): LexState {
return { block: false, stringEnd: null };
}
/* --------------------------------------------------------------- dialects -- */
interface Dialect {
lineComment: string[];
blockComment: [string, string] | null;
/** Quote characters that never span lines. */
quotes: string[];
/** Delimiters that MAY span lines (template literals, triple quotes, heredoc-ish). */
multiline: string[];
keywords: ReadonlySet<string>;
}
const kw = (words: string): ReadonlySet<string> => new Set(words.split(/\s+/).filter(Boolean));
const CLASS_NAMES: ReadonlySet<string> = new Set<TokenClass>([
'other',
'ident',
'comment',
'string',
'keyword',
'number',
]);
/**
* Keywords shared widely enough across the C-family that listing them once is
* both shorter and more accurate than a per-language table nobody maintains.
*/
const C_FAMILY = `
abstract as async await break case catch class const constexpr continue default defer delete do
else enum export extends extern false final finally for from func function go goto if impl implements
import in instanceof interface internal is let match mod module mut namespace new nil null object
operator out override package private protected public readonly record ref return sealed select self
static struct super switch this throw throws trait true try type typedef typeof union unsafe use using
var virtual void when where while with yield
`;
const DIALECTS: Record<string, Dialect> = {
c: {
lineComment: ['//'],
blockComment: ['/*', '*/'],
quotes: ['"', "'"],
multiline: [],
keywords: kw(C_FAMILY),
},
ts: {
lineComment: ['//'],
blockComment: ['/*', '*/'],
quotes: ['"', "'"],
multiline: ['`'],
keywords: kw(
`${C_FAMILY} any asserts bigint boolean declare infer keyof never number readonly satisfies
string symbol undefined unknown`
),
},
hash: {
// Python, Ruby, shell, YAML, Nix, Terraform, Perl, R, Elixir…
lineComment: ['#'],
blockComment: null,
quotes: ['"', "'"],
multiline: ['"""', "'''"],
keywords: kw(
`and as assert async await begin break case class def defp defmodule del do elif else elsif end
ensure except exec finally for from global if import in is lambda let module next nil none not
or pass raise require rescue return self struct then trait true false try unless until use when
while with yield`
),
},
sql: {
lineComment: ['--'],
blockComment: ['/*', '*/'],
quotes: ["'", '"'],
multiline: [],
keywords: kw(
`select insert update delete from where group by order having join left right inner outer on as
and or not null create table index view primary key foreign references into values set limit`
),
},
lisp: {
lineComment: [';'],
blockComment: null,
quotes: ['"'],
multiline: [],
keywords: kw('def defn defmacro let fn if cond do loop recur ns require import when case'),
},
};
/** Engine `Language` values → the lexer that reads them closely enough. */
const LANGUAGE_DIALECT: Record<string, keyof typeof DIALECTS> = {
typescript: 'ts',
tsx: 'ts',
javascript: 'ts',
jsx: 'ts',
svelte: 'ts',
vue: 'ts',
astro: 'ts',
dart: 'c',
java: 'c',
kotlin: 'c',
scala: 'c',
csharp: 'c',
vbnet: 'hash',
go: 'c',
rust: 'c',
swift: 'c',
objc: 'c',
c: 'c',
cpp: 'c',
cuda: 'c',
metal: 'c',
php: 'c',
zig: 'c',
solidity: 'c',
glsl: 'c',
python: 'hash',
ruby: 'hash',
crystal: 'hash',
elixir: 'hash',
perl: 'hash',
r: 'hash',
shell: 'hash',
bash: 'hash',
powershell: 'hash',
yaml: 'hash',
toml: 'hash',
nix: 'hash',
terraform: 'hash',
hcl: 'hash',
dockerfile: 'hash',
makefile: 'hash',
sql: 'sql',
clojure: 'lisp',
lisp: 'lisp',
scheme: 'lisp',
elm: 'ts',
haskell: 'ts',
lua: 'hash',
erlang: 'hash',
cobol: 'hash',
};
function dialectFor(language: string | undefined): Dialect {
const key = LANGUAGE_DIALECT[(language ?? '').toLowerCase()] ?? 'ts';
return DIALECTS[key] as Dialect;
}
/* ----------------------------------------------------------------- lexer -- */
const IDENT_START = /[A-Za-z_$@]/;
const IDENT_BODY = /[\w$]/;
/**
* Split one line into tokens, carrying `state` across lines.
* Decode one line's tokens, filling in columns.
*
* Mutates `state` — a window of source is tokenised line by line in order, and
* threading the block-comment flag through a return value would make every
* caller responsible for a detail only this function understands.
* Columns are derived rather than sent: they are the running sum of the token
* texts, so putting them on the wire would be duplicating a fact the payload
* already determines — and a wire column that disagreed with the text would be
* a silent mis-underline rather than a visible error.
*/
export function tokenize(line: string, state: LexState, language?: string): Token[] {
const d = dialectFor(language);
export function decodeLine(wire: readonly WireToken[], classes: readonly string[]): Token[] {
const out: Token[] = [];
const len = line.length;
let i = 0;
const push = (cls: TokenClass, from: number, to: number): void => {
if (to > from) out.push({ cls, text: line.slice(from, to), col: from });
};
while (i < len) {
// --- continuations of something opened on an earlier line ---------------
if (state.block && d.blockComment) {
const close = line.indexOf(d.blockComment[1], i);
if (close < 0) {
push('comment', i, len);
i = len;
} else {
push('comment', i, close + d.blockComment[1].length);
i = close + d.blockComment[1].length;
state.block = false;
}
continue;
}
if (state.stringEnd) {
const end = findUnescaped(line, state.stringEnd, i);
if (end < 0) {
push('string', i, len);
i = len;
} else {
push('string', i, end + state.stringEnd.length);
i = end + state.stringEnd.length;
state.stringEnd = null;
}
continue;
}
const rest = line.slice(i);
// --- comments -----------------------------------------------------------
const lineMarker = d.lineComment.find((m) => rest.startsWith(m));
if (lineMarker) {
push('comment', i, len);
i = len;
continue;
}
if (d.blockComment && rest.startsWith(d.blockComment[0])) {
const close = line.indexOf(d.blockComment[1], i + d.blockComment[0].length);
if (close < 0) {
push('comment', i, len);
i = len;
state.block = true;
} else {
push('comment', i, close + d.blockComment[1].length);
i = close + d.blockComment[1].length;
}
continue;
}
// --- strings ------------------------------------------------------------
// Longest delimiter first, so `"""` never matches as `"`.
const multi = [...d.multiline].sort((a, b) => b.length - a.length).find((m) => rest.startsWith(m));
if (multi) {
const end = findUnescaped(line, multi, i + multi.length);
if (end < 0) {
push('string', i, len);
i = len;
state.stringEnd = multi;
} else {
push('string', i, end + multi.length);
i = end + multi.length;
}
continue;
}
const quote = d.quotes.find((q) => rest.startsWith(q));
if (quote) {
const end = findUnescaped(line, quote, i + quote.length);
// An unterminated single-line quote is an apostrophe in prose far more
// often than a real string, so it stops at the line rather than eating
// the rest of the window.
push('string', i, end < 0 ? len : end + quote.length);
i = end < 0 ? len : end + quote.length;
continue;
}
// --- words, numbers, space, everything else -----------------------------
const ch = line[i] as string;
if (IDENT_START.test(ch)) {
let j = i + 1;
while (j < len && IDENT_BODY.test(line[j] as string)) j++;
const word = line.slice(i, j);
push(d.keywords.has(word) ? 'keyword' : 'ident', i, j);
i = j;
continue;
}
if (ch >= '0' && ch <= '9') {
let j = i + 1;
while (j < len && /[\w.]/.test(line[j] as string)) j++;
push('number', i, j);
i = j;
continue;
}
if (/\s/.test(ch)) {
let j = i + 1;
while (j < len && /\s/.test(line[j] as string)) j++;
push('space', i, j);
i = j;
continue;
}
push('punct', i, i + 1);
i++;
let col = 0;
for (const [id, text] of wire) {
const name = classes[id];
out.push({ cls: CLASS_NAMES.has(name as string) ? (name as TokenClass) : 'other', text, col });
col += text.length;
}
return out;
}
/** Index of `needle` at or after `from`, skipping backslash-escaped ones. */
function findUnescaped(line: string, needle: string, from: number): number {
let i = from;
while (i < line.length) {
if (line[i] === '\\') {
i += 2;
continue;
}
if (line.startsWith(needle, i)) return i;
i++;
/**
* Split a line into identifier runs with no syntax classification at all.
*
* The fallback for the moment before a payload arrives, and for a payload that
* carries no `highlight` block (an index served by an older build). It keeps
* the call-site overlay working — the links come from the graph, never from the
* grammar — so a line rendered this way loses only its colouring.
*/
export function plainLine(text: string): Token[] {
const out: Token[] = [];
const ident = /[A-Za-z_$À-￿][\w$À-￿]*/g;
let at = 0;
let match: RegExpExecArray | null;
while ((match = ident.exec(text)) !== null) {
if (match.index > at) out.push({ cls: 'other', text: text.slice(at, match.index), col: at });
out.push({ cls: 'ident', text: match[0], col: match.index });
at = match.index + match[0].length;
}
return -1;
if (at < text.length) out.push({ cls: 'other', text: text.slice(at), col: at });
return out;
}
/**
* The tokens for a slice, by 1-based file line.
*
* `from` is the slice's first line, so the map is keyed the way every other
* part of the Symbol view counts: real file lines, never offsets into a window.
*/
export function tokensByLine(
lines: readonly string[],
from: number,
highlight: WireHighlight | undefined
): Map<number, Token[]> {
const byLine = new Map<number, Token[]>();
for (let i = 0; i < lines.length; i++) {
const wire = highlight?.lines[i];
byLine.set(
from + i,
wire ? decodeLine(wire, highlight.classes) : plainLine(lines[i] as string)
);
}
return byLine;
}
/** The CSS class for a token, or null where the default ink is right. */
+9 -1
View File
@@ -286,6 +286,13 @@ function outsideRefs(outside: WireOutsideIndex): Array<{ line: number; ref: Line
* unclaimed one, then the last — is what makes `this.mutex.withLock(…)` mark
* `withLock` instead of `this`.
*
* A candidate is any token whose TEXT is the identifier and that is not inside
* a comment or a string. Deliberately not "any token the highlighter called an
* identifier": grammars disagree about that constantly — Go scopes `string` as
* `storage.type`, Java scopes a declared type name the same way — and a link
* that vanished because a grammar had an opinion about a scope name would be a
* highlighting change silently breaking navigation.
*
* @returns token index → the ref that claimed it
*/
export function assignRefs(
@@ -296,7 +303,8 @@ export function assignRefs(
for (const ref of refs) {
const candidates: number[] = [];
tokens.forEach((token, index) => {
if (token.cls === 'ident' && token.text === ref.ident) candidates.push(index);
if (token.cls === 'comment' || token.cls === 'string') return;
if (token.text === ref.ident) candidates.push(index);
});
if (candidates.length === 0) continue;
+14 -1
View File
@@ -23,6 +23,7 @@
import SourceBlock from '../components/symbol/SourceBlock.svelte';
import SymbolHeader from '../components/symbol/SymbolHeader.svelte';
import { ApiFailure, fetchSource, fetchSymbol, type WireNodeRef, type WireSource, type WireSymbolPayload } from '../lib/api';
import { tokensByLine, type Token } from '../lib/highlight';
import { hot, railFocus } from '../lib/focus.svelte';
import { project } from '../lib/project.svelte';
import {
@@ -148,6 +149,18 @@
return buildCodeBlock(from, source.lines, graphCallLines(payload));
});
/**
* Classified source by file line, from `/api/source`.
*
* Keyed by real file line rather than by window offset, because a windowed
* body renumbers nothing: the gaps are holes in the same numbering, and the
* code block looks a line up by the number it prints in the gutter.
*/
let codeTokens = $derived.by(() => {
if (!source?.lines) return new Map<number, Token[]>();
return tokensByLine(source.lines, source.from ?? 1, source.highlight);
});
let origin = $derived(arrivedFrom());
let originLeft = $derived(origin?.rail === 'left' ? origin.id : null);
let originRight = $derived(origin?.rail === 'right' ? origin.id : null);
@@ -432,7 +445,7 @@
{:else if codeBlock}
<SourceBlock
block={codeBlock}
language={payload.node.language}
tokens={codeTokens}
{refs}
defLine={payload.node.line}
defName={payload.node.name}