diff --git a/cli/src/__tests__/scm-loader.test.ts b/cli/src/__tests__/scm-loader.test.ts new file mode 100644 index 0000000000..59f4fee8ed --- /dev/null +++ b/cli/src/__tests__/scm-loader.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, test } from 'bun:test' +import { mkdtemp, rm } from 'fs/promises' +import * as os from 'os' +import * as path from 'path' + +import { scmTextModule } from '../../../test/setup-scm-loader' + +/** + * Import what the loader would emit. The loader's whole job is to hand the + * importer a module instead of a path, so a round trip through a real import is + * the only assertion that covers both the escaping and the default export. + */ +async function importEmitted(text: string): Promise { + const dir = await mkdtemp(path.join(os.tmpdir(), 'scm-loader-')) + const file = path.join(dir, 'query.mjs') + try { + await Bun.write(file, scmTextModule(text)) + const module = (await import(file)) as { default: string } + return module.default + } finally { + await rm(dir, { recursive: true, force: true }) + } +} + +describe('scm-text-loader', () => { + // The loader is a preload for `cd cli && bun test` (cli/bunfig.toml), so its + // test lives with the package that preloads it. + test('hands the importer the file text, not its path', async () => { + const query = + '(method_declaration name: (identifier) @name) @definition.method' + + expect(await importEmitted(query)).toBe(query) + }) + + test('escapes text that would otherwise end the string literal', async () => { + // Real queries carry regexes with backslashes and quoted literals. The + // unescaped form is a syntax error, not a wrong query, so this asserts the + // module still evaluates and still carries every character. + const query = '(string_literal) @literal (#match? @literal "\\\\n")' + + const emitted = scmTextModule(query) + + expect(emitted).not.toContain('\n') + expect(await importEmitted(query)).toBe(query) + }) + + test('round-trips newlines and quotes', async () => { + const query = '(comment) @c\n// "quoted" \\ backslash\n(program) @p' + + expect(await importEmitted(query)).toBe(query) + }) + + test('the queries it loads are real, non-empty text', async () => { + const file = path.join( + __dirname, + '../../../packages/code-map/src/tree-sitter-queries/tree-sitter-c_sharp-tags.scm', + ) + + const text = await Bun.file(file).text() + + expect(text.length).toBeGreaterThan(0) + expect(await importEmitted(text)).toBe(text) + }) +}) diff --git a/test/setup-scm-loader.ts b/test/setup-scm-loader.ts new file mode 100644 index 0000000000..05722fafb5 --- /dev/null +++ b/test/setup-scm-loader.ts @@ -0,0 +1,37 @@ +/** + * Loads `*.scm` tree-sitter query files as text. + * + * `cli/bunfig.toml` lists this file as a preload and `docs/testing.md` passes it + * to `bun test --preload`. Bun's default for an extension it does not know is to + * resolve the import to the file's *path*, so without this the query text + * `@codebuff/code-map` imports through the `@codebuff/sdk` barrel is a path. + * `createLanguageConfig` reads an absolute path back off disk as a fallback + * (`packages/code-map/src/languages.ts`), which is why the CLI still works when + * this loader is missing — but only because the path it gets happens to be + * absolute, and any bundler that inlines the import has no file to read. + * + * bun 1.3.14 has no `loader: 'text'`, so the contents are emitted as a JS module + * whose default export is the JSON-escaped text. + */ +import { plugin } from 'bun' + +/** + * The module bun should hand an importer in place of the `.scm` file. + * + * `JSON.stringify` is both the escaper and the quoting: it produces a literal + * JavaScript can evaluate back to the exact text, so a query containing + * backslashes, quotes or newlines survives the round trip. + */ +export function scmTextModule(text: string): string { + return `export default ${JSON.stringify(text)}` +} + +plugin({ + name: 'scm-text-loader', + setup(build) { + build.onLoad({ filter: /\.scm$/ }, async (args) => ({ + contents: scmTextModule(await Bun.file(args.path).text()), + loader: 'js', + })) + }, +})