|
|
@@ -1,50 +1,45 @@
|
|
|
/**
|
|
|
- * The viewer's server-side syntax classification (CG-43).
|
|
|
+ * The viewer's server-side syntax classification (CG-43, rebuilt on the
|
|
|
+ * engine's own tree-sitter parse in CG-57).
|
|
|
*
|
|
|
* 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'`.
|
|
|
+ * highlighting never becomes a way for a source request to fail: a language
|
|
|
+ * with no grammar, an oversized slice, a minified line 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.
|
|
|
+ *
|
|
|
+ * These run against the real grammars, which live in `src/extraction/wasm/`
|
|
|
+ * and `tree-sitter-wasms` — the same ones indexing uses — so unlike the Shiki
|
|
|
+ * era there is nothing to build first and nothing to skip.
|
|
|
*/
|
|
|
|
|
|
-import { describe, it, expect, beforeAll, vi } from 'vitest';
|
|
|
+import { describe, it, expect, beforeAll } from 'vitest';
|
|
|
import * as fs from 'fs';
|
|
|
-import * as os from 'os';
|
|
|
import * as path from 'path';
|
|
|
import {
|
|
|
clearHighlightCache,
|
|
|
grammarFor,
|
|
|
highlightCacheStats,
|
|
|
highlightLines,
|
|
|
- LANGUAGE_GRAMMAR,
|
|
|
+ isHighlightable,
|
|
|
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 { classifyTree, syntaxRegionsFor } from '../src/extraction/syntax-tokens';
|
|
|
+import { getParser, initGrammars, loadGrammarsForLanguages } from '../src/extraction/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);
|
|
|
}
|
|
|
@@ -74,10 +69,14 @@ function claimedText(result: HighlightResult, line: number, ref: LineRef): strin
|
|
|
return index === undefined ? undefined : tokens[index]?.text;
|
|
|
}
|
|
|
|
|
|
-describe('the language table', () => {
|
|
|
- it('has an entry for every language the engine indexes', () => {
|
|
|
+describe('which languages classify', () => {
|
|
|
+ it('answers for every language the engine indexes, without throwing', () => {
|
|
|
for (const language of LANGUAGES) {
|
|
|
- expect(LANGUAGE_GRAMMAR).toHaveProperty(language);
|
|
|
+ expect(() => grammarFor(language)).not.toThrow();
|
|
|
+ }
|
|
|
+ // The ones the classification is measured on all have a grammar.
|
|
|
+ for (const language of ['typescript', 'go', 'python', 'rust', 'swift', 'csharp', 'ruby', 'php']) {
|
|
|
+ expect(isHighlightable(language)).toBe(true);
|
|
|
}
|
|
|
});
|
|
|
|
|
|
@@ -87,33 +86,30 @@ describe('the language table', () => {
|
|
|
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);
|
|
|
- }
|
|
|
+ it('reads a single-file component through its script block', () => {
|
|
|
+ // A .svelte file has no grammar of its own; its symbols live in <script>
|
|
|
+ // and the extractor hands those to TypeScript. The classifier follows.
|
|
|
+ expect(grammarFor('svelte')).toBe('typescript');
|
|
|
+ const regions = syntaxRegionsFor('<p>{x}</p>\n<script lang="ts">\nlet x = 1;\n</script>\n', 'svelte');
|
|
|
+ expect(regions).toHaveLength(1);
|
|
|
+ expect(regions?.[0]?.language).toBe('typescript');
|
|
|
});
|
|
|
|
|
|
- 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);
|
|
|
+ it('has no grammar for the formats that only have file-level extraction', () => {
|
|
|
+ for (const language of ['yaml', 'xml', 'properties', 'twig', 'unknown']) {
|
|
|
+ expect(grammarFor(language)).toBeNull();
|
|
|
+ }
|
|
|
});
|
|
|
});
|
|
|
|
|
|
describe('classification', () => {
|
|
|
beforeAll(() => clearHighlightCache());
|
|
|
|
|
|
- withGrammars('reads TypeScript with the four classes the theme paints', async () => {
|
|
|
+ it('reads TypeScript with the classes the theme paints', async () => {
|
|
|
const result = await highlightLines(['const answer = 42; // note'], {
|
|
|
language: 'typescript',
|
|
|
});
|
|
|
- expect(result.engine).toBe('shiki');
|
|
|
+ expect(result.engine).toBe('tree-sitter');
|
|
|
expect(result.grammar).toBe('typescript');
|
|
|
expect(result.classes).toEqual([...TOKEN_CLASSES]);
|
|
|
const rendered = shape(result, 0);
|
|
|
@@ -123,7 +119,7 @@ describe('classification', () => {
|
|
|
expect(rendered).toContain('comment:// note');
|
|
|
});
|
|
|
|
|
|
- withGrammars('reads a # comment as a comment in Python and as code in TypeScript', async () => {
|
|
|
+ it('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');
|
|
|
|
|
|
@@ -131,7 +127,7 @@ describe('classification', () => {
|
|
|
expect(shape(ts, 0).at(-1)).not.toBe('comment:# note');
|
|
|
});
|
|
|
|
|
|
- withGrammars('carries a block comment across lines within one slice', async () => {
|
|
|
+ it('carries a block comment across lines within one slice', async () => {
|
|
|
const result = await highlightLines(['/* open', 'still comment', 'done */ const x = 1;'], {
|
|
|
language: 'typescript',
|
|
|
});
|
|
|
@@ -140,21 +136,73 @@ describe('classification', () => {
|
|
|
expect(shape(result, 2)).toContain('keyword:const');
|
|
|
});
|
|
|
|
|
|
- withGrammars('reads Go, which has its own idea of what a keyword is', async () => {
|
|
|
+ it('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');
|
|
|
+ expect(shape(result, 0)).toContain('def:Greet');
|
|
|
});
|
|
|
|
|
|
- withGrammars('reads ArkTS with the TypeScript grammar', async () => {
|
|
|
+ it('reads ArkTS with its own grammar, not TypeScript’s', async () => {
|
|
|
const result = await highlightLines(['@Entry struct Index { build() {} }'], {
|
|
|
language: 'arkts',
|
|
|
});
|
|
|
- expect(result.engine).toBe('shiki');
|
|
|
- expect(result.grammar).toBe('typescript');
|
|
|
+ expect(result.engine).toBe('tree-sitter');
|
|
|
+ expect(result.grammar).toBe('arkts');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('does not read a type annotation’s `string` as a string literal', async () => {
|
|
|
+ // An anonymous tree-sitter node's type IS its text, so `string` in a
|
|
|
+ // signature arrives as a node literally typed `string`. Reading that as a
|
|
|
+ // string literal greys out half of every signature in TypeScript and PHP.
|
|
|
+ for (const [language, line] of [
|
|
|
+ ['typescript', 'function put(key: string): void {}'],
|
|
|
+ ['php', '<?php function put(string $key): void {}'],
|
|
|
+ ] as const) {
|
|
|
+ const result = await highlightLines([line], { language });
|
|
|
+ expect(shape(result, 0)).toContain('type:string');
|
|
|
+ expect(shape(result, 0)).not.toContain('string:string');
|
|
|
+ }
|
|
|
});
|
|
|
|
|
|
- withGrammars('emits one entry per source line, always', async () => {
|
|
|
+ it('paints a built-in type the same way in every language', async () => {
|
|
|
+ // The grammars disagree: `string` is a `type_identifier` in Go and an
|
|
|
+ // anonymous token inside a `predefined_type` in TypeScript. Left alone that
|
|
|
+ // is one word painting two ways on the same screen.
|
|
|
+ for (const [language, line] of [
|
|
|
+ ['typescript', 'let a: string;'],
|
|
|
+ ['go', 'var a string'],
|
|
|
+ ['csharp', 'string a;'],
|
|
|
+ ['rust', 'let a: u32 = 1;'],
|
|
|
+ ] as const) {
|
|
|
+ const rendered = shape(await highlightLines([line], { language }), 0);
|
|
|
+ expect(rendered.some((t) => t.startsWith('type:'))).toBe(true);
|
|
|
+ expect(rendered.some((t) => t === 'keyword:string' || t === 'keyword:u32')).toBe(false);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('keeps a template literal’s interpolated call as code, so it can link', async () => {
|
|
|
+ const line = 'const s = `n=${store.size()} done`;';
|
|
|
+ const result = await highlightLines([line], { language: 'typescript' });
|
|
|
+ expect(shape(result, 0)).toContain('ident:size');
|
|
|
+ expect(claimedText(result, 0, lineRef({ ident: 'size' }))).toBe('size');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('marks a definition’s own name, from the extractor’s tables', async () => {
|
|
|
+ const cases: [string, string, string][] = [
|
|
|
+ ['typescript', 'export class Store {}', 'Store'],
|
|
|
+ ['python', 'def put(self):', 'put'],
|
|
|
+ ['rust', 'pub fn put(&self) {}', 'put'],
|
|
|
+ ['ruby', 'class Store', 'Store'],
|
|
|
+ ['csharp', 'public class Store {}', 'Store'],
|
|
|
+ ['swift', 'final class Store {}', 'Store'],
|
|
|
+ ];
|
|
|
+ for (const [language, line, name] of cases) {
|
|
|
+ const result = await highlightLines([line], { language });
|
|
|
+ expect(shape(result, 0)).toContain(`def:${name}`);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ it('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
|
|
|
@@ -162,6 +210,34 @@ describe('classification', () => {
|
|
|
expect(result.lines).toHaveLength(lines.length);
|
|
|
expect(result.lines[1]).toEqual([]);
|
|
|
});
|
|
|
+
|
|
|
+ it('reproduces every line of a real file exactly', async () => {
|
|
|
+ // The code block renders these tokens and nothing else, so a dropped or
|
|
|
+ // duplicated character is a corrupted file on screen — silently.
|
|
|
+ const file = path.join(__dirname, '..', 'src', 'ui-server', 'api', 'source.ts');
|
|
|
+ const lines = fs.readFileSync(file, 'utf-8').split('\n');
|
|
|
+ const result = await highlightLines(lines, { language: 'typescript' });
|
|
|
+ expect(result.engine).toBe('tree-sitter');
|
|
|
+ result.lines.forEach((row, i) => {
|
|
|
+ expect(row.map(([, text]) => text).join('')).toBe(lines[i]);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ it('classifies a component’s script and leaves its markup plain', async () => {
|
|
|
+ const lines = [
|
|
|
+ '<script lang="ts">',
|
|
|
+ ' let count = 0;',
|
|
|
+ '</script>',
|
|
|
+ '',
|
|
|
+ '<button onclick={bump}>{count}</button>',
|
|
|
+ ];
|
|
|
+ const result = await highlightLines(lines, { language: 'svelte' });
|
|
|
+ expect(result.engine).toBe('tree-sitter');
|
|
|
+ expect(shape(result, 1)).toContain('keyword:let');
|
|
|
+ // The markup still splits into identifiers, so a call site in it links.
|
|
|
+ expect(claimedText(result, 4, lineRef({ ident: 'bump' }))).toBe('bump');
|
|
|
+ expect(result.lines.map((row) => row.map(([, t]) => t).join(''))).toEqual(lines);
|
|
|
+ });
|
|
|
});
|
|
|
|
|
|
describe('the plain fallback', () => {
|
|
|
@@ -182,7 +258,7 @@ describe('the plain fallback', () => {
|
|
|
expect(claimedText(result, 0, lineRef({ ident: 'withLock', col: 9 }))).toBe('withLock');
|
|
|
});
|
|
|
|
|
|
- it('refuses to tokenise a minified line rather than wedging on it', async () => {
|
|
|
+ it('refuses to classify 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');
|
|
|
@@ -191,41 +267,17 @@ describe('the plain fallback', () => {
|
|
|
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();
|
|
|
- }
|
|
|
+ it('answers plain for a component whose script block is empty', async () => {
|
|
|
+ const result = await highlightLines(['<p>hello</p>'], { language: 'svelte' });
|
|
|
+ expect(result.engine).toBe('plain');
|
|
|
+ expect(result.lines[0]?.map(([, text]) => text).join('')).toBe('<p>hello</p>');
|
|
|
});
|
|
|
});
|
|
|
|
|
|
describe('graph links land on the right token', () => {
|
|
|
beforeAll(() => clearHighlightCache());
|
|
|
|
|
|
- withGrammars('marks the callee, not the receiver the recorded column points at', async () => {
|
|
|
+ it('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 () => {';
|
|
|
@@ -235,7 +287,7 @@ describe('graph links land on the right token', () => {
|
|
|
);
|
|
|
});
|
|
|
|
|
|
- withGrammars('lands on a real call site in the engine’s own src/index.ts', async () => {
|
|
|
+ it('lands on a real call site in the engine’s 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
|
|
|
@@ -252,7 +304,7 @@ describe('graph links land on the right token', () => {
|
|
|
);
|
|
|
});
|
|
|
|
|
|
- withGrammars('lands on a Go method call', async () => {
|
|
|
+ it('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(
|
|
|
@@ -260,7 +312,7 @@ describe('graph links land on the right token', () => {
|
|
|
);
|
|
|
});
|
|
|
|
|
|
- withGrammars('lands on a Python method call, not on the receiver of the same name', async () => {
|
|
|
+ it('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(
|
|
|
@@ -268,7 +320,7 @@ describe('graph links land on the right token', () => {
|
|
|
);
|
|
|
});
|
|
|
|
|
|
- withGrammars('leaves a word inside a comment or a string alone', async () => {
|
|
|
+ it('leaves a word inside a comment or a string alone', async () => {
|
|
|
const result = await highlightLines(
|
|
|
[' // call render here', ' const s = "render";'],
|
|
|
{ language: 'typescript' }
|
|
|
@@ -277,7 +329,7 @@ describe('graph links land on the right token', () => {
|
|
|
expect(claimedText(result, 1, lineRef({ ident: 'render' }))).toBeUndefined();
|
|
|
});
|
|
|
|
|
|
- withGrammars('keeps every identifier separately claimable', async () => {
|
|
|
+ it('keeps every identifier separately claimable', async () => {
|
|
|
const result = await highlightLines(['render(); render();'], { language: 'typescript' });
|
|
|
const tokens = tokensOf(result, 0);
|
|
|
const claimed = assignRefs(tokens, [
|
|
|
@@ -287,7 +339,13 @@ describe('graph links land on the right token', () => {
|
|
|
expect(claimed.size).toBe(2);
|
|
|
});
|
|
|
|
|
|
- withGrammars('reproduces the line exactly — the code block renders these tokens', async () => {
|
|
|
+ it('keeps a type name claimable — it is a distinct class, not an excluded one', async () => {
|
|
|
+ const result = await highlightLines(['let store: Store = make();'], { language: 'typescript' });
|
|
|
+ expect(shape(result, 0)).toContain('type:Store');
|
|
|
+ expect(claimedText(result, 0, lineRef({ ident: 'Store' }))).toBe('Store');
|
|
|
+ });
|
|
|
+
|
|
|
+ it('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(
|
|
|
@@ -299,7 +357,27 @@ describe('graph links land on the right token', () => {
|
|
|
});
|
|
|
|
|
|
describe('cost', () => {
|
|
|
- withGrammars('answers a cached slice without re-tokenising it', async () => {
|
|
|
+ it('classifies three thousand lines of TypeScript well inside the budget', async () => {
|
|
|
+ clearHighlightCache();
|
|
|
+ const lines = fs
|
|
|
+ .readFileSync(path.join(__dirname, '..', 'src', 'extraction', 'tree-sitter.ts'), 'utf-8')
|
|
|
+ .split('\n')
|
|
|
+ .slice(0, 3000);
|
|
|
+ // Warm the grammar load, which is a one-off per language per process.
|
|
|
+ await highlightLines(lines.slice(0, 5), { language: 'typescript' });
|
|
|
+ clearHighlightCache();
|
|
|
+
|
|
|
+ const started = Date.now();
|
|
|
+ const result = await highlightLines(lines, { language: 'typescript' });
|
|
|
+ const elapsed = Date.now() - started;
|
|
|
+
|
|
|
+ expect(result.engine).toBe('tree-sitter');
|
|
|
+ // The whole point of CG-57's swap: the TextMate grammar took ~700 ms here.
|
|
|
+ // Generous against a loaded CI box; the dev Mac measures 24–41 ms.
|
|
|
+ expect(elapsed).toBeLessThan(400);
|
|
|
+ });
|
|
|
+
|
|
|
+ it('answers a cached slice without re-classifying it', async () => {
|
|
|
clearHighlightCache();
|
|
|
const lines = fs
|
|
|
.readFileSync(path.join(__dirname, '..', 'src', 'ui-server', 'api', 'source.ts'), 'utf-8')
|
|
|
@@ -313,7 +391,7 @@ describe('cost', () => {
|
|
|
const second = await highlightLines(lines, { language: 'typescript', cacheKey: 'a:1:9999' });
|
|
|
const warmMs = Date.now() - warm;
|
|
|
|
|
|
- expect(second.engine).toBe('shiki');
|
|
|
+ expect(second.engine).toBe('tree-sitter');
|
|
|
// 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));
|
|
|
@@ -333,7 +411,7 @@ describe('cost', () => {
|
|
|
expect(stats.lines).toBeLessThanOrEqual(SLICE_CACHE_LINES);
|
|
|
});
|
|
|
|
|
|
- withGrammars('keys the cache on the content, so an edited file re-highlights', async () => {
|
|
|
+ it('keys the cache on the content, so an edited file re-classifies', async () => {
|
|
|
clearHighlightCache();
|
|
|
const first = await highlightLines(['const a = 1;'], {
|
|
|
language: 'typescript',
|
|
|
@@ -347,3 +425,34 @@ describe('cost', () => {
|
|
|
expect(second.lines[0]?.map(([, t]) => t).join('')).toBe('const bbb = 2;');
|
|
|
});
|
|
|
});
|
|
|
+
|
|
|
+describe('the classifier itself', () => {
|
|
|
+ it('covers the source with ordered, non-overlapping spans', async () => {
|
|
|
+ const source = fs
|
|
|
+ .readFileSync(path.join(__dirname, '..', 'src', 'ui-server', 'api', 'flow.ts'), 'utf-8')
|
|
|
+ .slice(0, 40_000);
|
|
|
+ await initGrammars();
|
|
|
+ await loadGrammarsForLanguages(['typescript']);
|
|
|
+ const parser = getParser('typescript');
|
|
|
+ expect(parser).not.toBeNull();
|
|
|
+ const tree = (parser as NonNullable<typeof parser>).parse(source);
|
|
|
+ const spans = classifyTree((tree as NonNullable<typeof tree>).rootNode, source, 'typescript');
|
|
|
+
|
|
|
+ expect(spans.length).toBeGreaterThan(1000);
|
|
|
+ let previous = 0;
|
|
|
+ for (const span of spans) {
|
|
|
+ expect(span.start).toBeGreaterThanOrEqual(previous);
|
|
|
+ expect(span.end).toBeGreaterThan(span.start);
|
|
|
+ previous = span.end;
|
|
|
+ }
|
|
|
+ expect(previous).toBeLessThanOrEqual(source.length);
|
|
|
+ // Everything the walk did not claim is whitespace the caller fills in.
|
|
|
+ const uncovered: string[] = [];
|
|
|
+ let at = 0;
|
|
|
+ for (const span of spans) {
|
|
|
+ if (span.start > at) uncovered.push(source.slice(at, span.start));
|
|
|
+ at = span.end;
|
|
|
+ }
|
|
|
+ expect(uncovered.every((gap) => gap.trim() === '')).toBe(true);
|
|
|
+ });
|
|
|
+});
|