From fcd18464611f4fd64610f2c6640d7ffd6f119110 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Mon, 27 Jul 2026 13:26:53 +0800 Subject: [PATCH 1/3] feat: add bounded SQL language intelligence --- .github/workflows/test.yml | 19 +- README.md | 5 + ...7-language-feature-provider-composition.md | 46 ++ docs/capability-charter.md | 4 +- docs/language-features.md | 85 ++ implementation.md | 4 +- package.json | 1 + scripts/mutation-pilot.mjs | 43 + scripts/package-smoke.mjs | 45 ++ scripts/worker-placement.mjs | 8 +- src/__tests__/language-feature-fuzz.test.ts | 62 ++ .../language-feature-performance.test.ts | 71 ++ .../language-feature-runtime.test.ts | 389 +++++++++ src/__tests__/language-features.test.ts | 523 ++++++++++++ .../relation-catalog-boundary.test.ts | 12 + src/codemirror/__tests__/sql-editor.test.ts | 81 +- src/codemirror/sql-editor.ts | 150 ++++ src/index.ts | 31 + src/language-feature-runtime.ts | 604 ++++++++++++++ src/language-features.ts | 217 +++++ src/relation-catalog-boundary.ts | 1 + src/relation-completion-types.ts | 1 + src/relation-completion.ts | 7 +- src/session.ts | 762 +++++++++++++++++- src/types.ts | 18 +- test/types/language-features.test-d.ts | 62 ++ test/worker-placement/README.md | 16 +- vitest.browser.config.ts | 11 +- 28 files changed, 3251 insertions(+), 27 deletions(-) create mode 100644 docs/adr/0007-language-feature-provider-composition.md create mode 100644 docs/language-features.md create mode 100644 scripts/mutation-pilot.mjs create mode 100644 src/__tests__/language-feature-fuzz.test.ts create mode 100644 src/__tests__/language-feature-performance.test.ts create mode 100644 src/__tests__/language-feature-runtime.test.ts create mode 100644 src/__tests__/language-features.test.ts create mode 100644 src/language-feature-runtime.ts create mode 100644 src/language-features.ts create mode 100644 test/types/language-features.test-d.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index b31b752..3b5b3c1 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -58,6 +58,9 @@ jobs: - name: โšก Performance Gates run: pnpm run test:performance + - name: ๐Ÿงฌ Mutation Pilot + run: pnpm run test:mutation + - name: ๐Ÿ“ˆ Changed-Code Coverage if: github.event_name == 'pull_request' || github.event_name == 'push' env: @@ -77,6 +80,13 @@ jobs: browser: runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + browser: + - chromium + - firefox + - webkit permissions: contents: read @@ -96,13 +106,16 @@ jobs: - name: ๐Ÿ“ฅ Install dependencies run: pnpm install --ignore-scripts --frozen-lockfile - - name: ๐ŸŽญ Install Chromium - run: pnpm exec playwright install --with-deps chromium + - name: ๐ŸŽญ Install ${{ matrix.browser }} + run: pnpm exec playwright install --with-deps "${{ matrix.browser }}" - - name: ๐Ÿงช Browser Tests + - name: ๐Ÿงช Browser Tests (${{ matrix.browser }}) + env: + VITEST_BROWSER: ${{ matrix.browser }} run: pnpm run test:browser - name: ๐Ÿงต Worker Placement Evidence + if: matrix.browser == 'chromium' run: pnpm run test:worker-placement package: diff --git a/README.md b/README.md index 18064e7..862c6b7 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,9 @@ Published as [`@marimo-team/codemirror-sql`](https://www.npmjs.com/package/@mari - **Schema-aware completion** โ€” complete relations, namespaces, physical columns, inferred CTE/derived outputs, aliases, correlated scopes, set-operation arms, and DML targets - **CodeMirror integration** โ€” cancellation-safe completion, disposable detail panels, atomic context updates, and an optional virtualized statement gutter - **Composable completion** โ€” package results coexist with SQL keywords, functions, embedded-language sources, and host sources +- **Language intelligence** โ€” bounded diagnostics, hover, navigation, rename, + symbols, folding, formatting, and code-action providers share one revision + and cancellation model - **Isolated parsing** โ€” optional browser-worker parser execution kept off the public API surface ## Installation @@ -64,6 +67,8 @@ service.dispose(); Configure relation, column, and namespace providers on the shared service for schema-aware completion. See the [CodeMirror adapter](./docs/codemirror-adapter.md) and [session primitives](./docs/session-primitives.md) for the full contracts. +See [language feature providers](./docs/language-features.md) for native-engine, +LSP, formatter, and host-validation integration. ## Demo diff --git a/docs/adr/0007-language-feature-provider-composition.md b/docs/adr/0007-language-feature-provider-composition.md new file mode 100644 index 0000000..b2fdfe5 --- /dev/null +++ b/docs/adr/0007-language-feature-provider-composition.md @@ -0,0 +1,46 @@ +# ADR 0007: Bounded language-feature provider composition + +Status: accepted +Date: 2026-07-27 + +## Context + +Diagnostics, hover, navigation, rename, formatting, symbols, folding, and code +actions need the same revision, coordinate, cancellation, failure-isolation, +and result-validation rules. Leaving those rules to each host creates stale +results, leaked provider errors, unsafe edits, and incompatible native-engine +and language-server integrations. + +## Decision + +The framework-independent session owns one generic provider composition +runtime. Providers run concurrently under one absolute request deadline and receive +per-provider abort signals. The runtime validates, bounds, freezes, and +normalizes every public result. Collection features compose in configuration +order; scalar features use the first non-empty successful result. Provider ASTs, +errors, transports, and mutable editor objects never cross the boundary. + +Statement symbols and folding have a parser-free local baseline. DOM rendering, +debouncing, lint panels, and tooltip presentation remain host policy. + +This adds 3,695 gzip bytes (about 3.6 KiB) to the complete +framework-independent core. The measured +artifact is 57,307 gzip bytes and 215,639 raw bytes, so the enforced core +ceilings move from 54 KiB/200 KiB to 57 KiB/212 KiB. Optional parser chunks +remain unchanged and independently chunkable. +The worker-placement page includes the core, so its raw aggregate ceiling moves +from 720 KiB to 725 KiB; its parser chunks do not move, while the aggregate +gzip ceiling moves from 164 KiB to 165 KiB. +The increase is accepted because one shared validator is smaller and safer than +duplicating feature-specific boundaries in every consumer. + +## Consequences + +- Native engines, LSP clients, host validators, and formatters use one stable + plain-data contract. +- A slow or failed provider cannot suppress unrelated provider evidence. +- Cancellation and disposal settle without waiting for provider cooperation. +- Synchronous provider time counts against the deadline but cannot be + preempted; CPU-heavy integrations must run off-thread. +- Core size remains measured in CI with 1,061 gzip bytes and 1,449 raw bytes + of headroom at acceptance. diff --git a/docs/capability-charter.md b/docs/capability-charter.md index 8e1614e..b899dac 100644 --- a/docs/capability-charter.md +++ b/docs/capability-charter.md @@ -238,8 +238,8 @@ count and estimated retained bytes. Provisional compressed bundle budgets: -- Framework-independent core with relation, namespace, column, and local query - output completion: 54 KiB +- Framework-independent core with completion and the validated language-feature + provider runtime: 57 KiB gzip / 212 KiB raw - Core plus CodeMirror adapter, excluding parser and dialect data: 75 KiB - Each ordinary dialect module: 25 KiB - Optional `node-sql-parser` chunk: no regression from its recorded baseline diff --git a/docs/language-features.md b/docs/language-features.md new file mode 100644 index 0000000..6157649 --- /dev/null +++ b/docs/language-features.md @@ -0,0 +1,85 @@ +# Language feature providers + +The language service exposes diagnostics, hover, definition, references, +highlights, rename, document symbols, folding, formatting, and code actions +through the same document session used for completion. + +```ts +const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviders: [{ + id: "duckdb", + diagnostics: async ({ document, signal }) => { + const response = await validateSql(document.text, { signal }); + return response.diagnostics; + }, + hover: ({ document, request }) => + describeSqlAt(document.text, request.position), + format: ({ document, request }) => ({ + changes: [{ + from: request.range?.from ?? 0, + to: request.range?.to ?? document.text.length, + insert: formatSql(document.text, request), + }], + }), + }], +}); + +const session = service.openDocument({ + context: { dialect: "duckdb" }, + text: "select * form users", +}); + +const task = session.diagnostics(); +const result = await task.result; +if (result.status === "ready" && session.isCurrent(result.revision)) { + renderDiagnostics(result.value); +} +``` + +Every provider receives a frozen document snapshot, a normalized request, and +an `AbortSignal`. Public ranges are absolute, half-open UTF-16 ranges in the +original document. Results are copied, bounded, validated, and frozen before +publication. Provider exceptions, rejections, timeouts, malformed ranges, and +malformed edits do not escape the service boundary. + +Providers run concurrently. `featureProviderBudgetMs` is one absolute +per-request deadline, not a budget multiplied by the provider count. Elapsed +synchronous invocation time counts against that deadline, but JavaScript +cannot preempt a provider that blocks the current thread. Expensive or +untrusted integrations must yield immediately and continue asynchronously in a +worker, process, or remote service. Cancellation, session updates, catalog +invalidation, and disposal stop publication promptly once control returns, +even when an underlying provider ignores its signal. + +Collection features compose successful providers in configuration order. +Scalar features such as hover, rename, and format select the first successful +non-empty result. `isIncomplete` explicitly records bounded collection +truncation. Source reports remain available on ready and unavailable results, +preserving which providers were ready, failed, or timed out. Formatting is +explicit; typing never invokes a formatter. + +The built-in local structure provider supplies statement-level document +symbols and multiline folding without loading an optional parser. Parser, +native-engine, language-server, host-policy, and formatter integrations remain +ordinary providers and do not expose their AST or transport types. + +`sqlEditor` exposes the same tasks through its returned support object: + +```ts +const support = sqlEditor({ initialContext, service }); +const diagnostics = support.diagnostics(view); +const hover = support.hover(view, { position: view.state.selection.main.head }); +``` + +The host owns presentation and scheduling. This keeps the core SSR-safe and +allows applications to use CodeMirror lint, tooltip, panel, or custom UI +extensions without the SQL package imposing DOM rendering or debounce policy. + +Function, scalar parameter, and snippet completion use +`autocomplete.externalSources`, which accepts ordinary CodeMirror +`CompletionSource` values and therefore preserves snippet application and +parameter syntax without translating them through a lossy catalog shape. +Table-valued functions can also be returned by relation catalogs with +`relationKind: "table-function"`; the CodeMirror adapter presents them as +functions at relation sites. diff --git a/implementation.md b/implementation.md index f887a83..bb59caa 100644 --- a/implementation.md +++ b/implementation.md @@ -1,6 +1,6 @@ # SQL editor completion plan -Status: active +Status: complete Updated: 2026-07-27 The standard language-service overhaul is delivered by PR #204. Two additional @@ -32,6 +32,8 @@ columns. ## PR 2: language intelligence and release hardening +Status: complete + The final PR adds the remaining feature methods and provider composition: - syntax, semantic, and host diagnostics; diff --git a/package.json b/package.json index e830a86..12bc60b 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "test:worker-placement": "node ./scripts/worker-placement.mjs", "test:integrity": "node ./scripts/check-test-integrity.mjs", "test:performance": "vitest run --config vitest.performance.config.ts", + "test:mutation": "node ./scripts/mutation-pilot.mjs", "bench:catalog-boundary": "vitest bench --run src/__tests__/relation-catalog-boundary.bench.ts", "bench:catalog-coordinator": "vitest bench --run src/__tests__/relation-catalog-epoch-coordinator.bench.ts", "bench:editor": "vitest bench --run src/codemirror/__tests__/sql-editor.bench.ts", diff --git a/scripts/mutation-pilot.mjs b/scripts/mutation-pilot.mjs new file mode 100644 index 0000000..582a4fb --- /dev/null +++ b/scripts/mutation-pilot.mjs @@ -0,0 +1,43 @@ +import { spawnSync } from "node:child_process"; +import { readFileSync, writeFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const repository = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const target = resolve(repository, "src", "language-feature-runtime.ts"); +const original = readFileSync(target, "utf8"); +const before = "value.length > MAX_SQL_FEATURE_RESULTS"; +const after = "value.length >= MAX_SQL_FEATURE_RESULTS"; +if (original.split(before).length !== 2) { + throw new Error("Mutation pilot target is missing or ambiguous"); +} + +let outcome; +try { + writeFileSync(target, original.replace(before, after)); + outcome = spawnSync( + process.execPath, + [ + resolve(repository, "node_modules", "vitest", "vitest.mjs"), + "run", + "--config", + "vitest.config.ts", + "src/__tests__/language-feature-runtime.test.ts", + ], + { + cwd: repository, + encoding: "utf8", + stdio: "pipe", + }, + ); +} finally { + writeFileSync(target, original); +} + +if (outcome.status === 0) { + throw new Error( + "Mutation survived: the feature result limit boundary is not tested", + ); +} +if (outcome.error) throw outcome.error; +process.stdout.write("Mutation killed: feature result limit boundary\n"); diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index fbd9a8e..e93614a 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -239,6 +239,18 @@ const cleanup: SqlCatalogSubscriptionCleanup = () => undefined; const service = createSqlLanguageService({ dialects: [duckdbDialect()], + featureProviders: [{ + id: "marimo-duckdb", + diagnostics: ({ document }) => document.context.engine === "remote" + ? [{ + from: 0, + message: "Remote validation", + severity: "information", + source: "duckdb", + to: 6, + }] + : [], + }], }); const editorSupport = sqlEditor({ initialContext: { dialect: "duckdb", engine: "local" }, @@ -262,12 +274,14 @@ const statement: SqlStatementBoundaryAtResult = session.statementBoundaryAt({ affinity: "left", position: 0, }); +const diagnostics = session.diagnostics(); void editorSupport.extension; void cleanup; void range; void session; void statement; +void diagnostics; `, ); @@ -337,6 +351,37 @@ if ( throw new Error("The packaged statement boundary is invalid"); } service.dispose(); + +const marimoService = api.createSqlLanguageService({ + dialects: [api.duckdbDialect()], + featureProviders: [{ + id: "marimo-duckdb", + diagnostics: ({ document }) => [{ + from: 0, + message: \`Validated \${document.context.engine}\`, + severity: "information", + source: "duckdb", + to: 6, + }], + }], +}); +const marimoSession = marimoService.openDocument({ + context: { dialect: "duckdb", engine: "remote" }, + embeddedRegions: [{ from: 14, language: "python", to: 18 }], + text: "SELECT * FROM {df}", +}); +const diagnostics = await marimoSession.diagnostics().result; +const symbols = await marimoSession.documentSymbols().result; +if ( + diagnostics.status !== "ready" || + diagnostics.value[0]?.message !== "Validated remote" || + symbols.status !== "ready" || + symbols.value[0]?.name !== "SELECT statement" +) { + throw new Error("The packed marimo language-feature fixture failed"); +} +marimoSession.dispose(); +marimoService.dispose(); `, ); diff --git a/scripts/worker-placement.mjs b/scripts/worker-placement.mjs index 353ed8e..4d59f26 100644 --- a/scripts/worker-placement.mjs +++ b/scripts/worker-placement.mjs @@ -35,11 +35,11 @@ const PARSER_MARKERS = [ "tableList", ]; const BIGQUERY_GZIP_LIMIT = 50 * 1024; -const CORE_TOTAL_GZIP_LIMIT = 54 * 1024; -const CORE_TOTAL_RAW_LIMIT = 200 * 1024; +const CORE_TOTAL_GZIP_LIMIT = 57 * 1024; +const CORE_TOTAL_RAW_LIMIT = 212 * 1024; const POSTGRESQL_GZIP_LIMIT = 68 * 1024; -const WORKER_TOTAL_GZIP_LIMIT = 164 * 1024; -const WORKER_TOTAL_RAW_LIMIT = 720 * 1024; +const WORKER_TOTAL_GZIP_LIMIT = 165 * 1024; +const WORKER_TOTAL_RAW_LIMIT = 725 * 1024; const MIME_TYPES = new Map([ [".css", "text/css; charset=utf-8"], [".html", "text/html; charset=utf-8"], diff --git a/src/__tests__/language-feature-fuzz.test.ts b/src/__tests__/language-feature-fuzz.test.ts new file mode 100644 index 0000000..8437060 --- /dev/null +++ b/src/__tests__/language-feature-fuzz.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, +} from "../index.js"; + +function generator(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (Math.imul(state, 1_664_525) + 1_013_904_223) >>> 0; + return state / 0x1_0000_0000; + }; +} + +describe("deterministic language feature fuzzing", () => { + test("keeps local structure ranges valid across hostile incomplete text", async () => { + const random = generator(0x5eed_2026); + const fragments = [ + "select", " from ", "(", ")", ";", "\n", "/*", "*/", "'", + "\"", "-- comment\n", "{python}", "with x as (", " union all ", + ]; + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + for (let iteration = 0; iteration < 250; iteration += 1) { + let text = ""; + const count = 1 + Math.floor(random() * 30); + for (let index = 0; index < count; index += 1) { + text += fragments[Math.floor(random() * fragments.length)] ?? ""; + } + const session = service.openDocument({ + context: { dialect: "duckdb" }, + text, + }); + const [symbols, folds] = await Promise.all([ + session.documentSymbols().result, + session.foldingRanges().result, + ]); + expect(symbols.status).toBe("ready"); + expect(folds.status).toBe("ready"); + if (symbols.status === "ready") { + for (const symbol of symbols.value) { + expect(symbol.range.from).toBeGreaterThanOrEqual(0); + expect(symbol.range.to).toBeLessThanOrEqual(text.length); + expect(symbol.selectionRange.from).toBeGreaterThanOrEqual( + symbol.range.from, + ); + expect(symbol.selectionRange.to).toBeLessThanOrEqual(symbol.range.to); + } + } + if (folds.status === "ready") { + for (const fold of folds.value) { + expect(fold.from).toBeGreaterThanOrEqual(0); + expect(fold.to).toBeLessThanOrEqual(text.length); + expect(fold.from).toBeLessThanOrEqual(fold.to); + } + } + session.dispose(); + } + service.dispose(); + }); +}); diff --git a/src/__tests__/language-feature-performance.test.ts b/src/__tests__/language-feature-performance.test.ts new file mode 100644 index 0000000..297e175 --- /dev/null +++ b/src/__tests__/language-feature-performance.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + createSqlLanguageService, + duckdbDialect, +} from "../index.js"; + +function percentile95(values: readonly number[]): number { + const sorted = [...values].sort((left, right) => left - right); + return sorted[Math.ceil(sorted.length * 0.95) - 1] ?? + Number.POSITIVE_INFINITY; +} + +describe("language feature performance gates", () => { + it("keeps warm local structure analysis below 150 ms p95", async () => { + const statement = "select id, name\nfrom users\nwhere active = true;\n"; + const text = statement.repeat(Math.ceil(10_240 / statement.length)) + .slice(0, 10_240); + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + }); + const session = service.openDocument({ + context: { dialect: "duckdb" }, + text, + }); + await session.documentSymbols().result; + const samples: number[] = []; + for (let index = 0; index < 20; index += 1) { + const started = performance.now(); + const result = await session.documentSymbols().result; + expect(result.status).toBe("ready"); + samples.push(performance.now() - started); + } + expect(percentile95(samples)).toBeLessThan(150); + session.dispose(); + service.dispose(); + }); + + it("aborts feature work for fifty disposed sessions", async () => { + const signals: AbortSignal[] = []; + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviderBudgetMs: 1_000, + featureProviders: [{ + id: "pending-engine", + diagnostics: ({ signal }) => { + signals.push(signal); + return new Promise(() => {}); + }, + }], + }); + const sessions = Array.from({ length: 50 }, () => + service.openDocument({ + context: { dialect: "duckdb" }, + text: "select 1", + })); + const tasks = sessions.map((session) => session.diagnostics().result); + await Promise.resolve(); + for (const session of sessions) session.dispose(); + const results = await Promise.all(tasks); + + expect(signals).toHaveLength(50); + expect(signals.every((signal) => signal.aborted)).toBe(true); + expect( + results.every( + (result) => + result.status === "cancelled" && result.reason === "disposed", + ), + ).toBe(true); + service.dispose(); + }); +}); diff --git a/src/__tests__/language-feature-runtime.test.ts b/src/__tests__/language-feature-runtime.test.ts new file mode 100644 index 0000000..41ecf12 --- /dev/null +++ b/src/__tests__/language-feature-runtime.test.ts @@ -0,0 +1,389 @@ +import { describe, expect, test } from "vitest"; +import { + captureSqlLanguageFeatureProviders, + composeSqlCodeActionResults, + createSqlFeatureDocument, + invokeSqlFeatureProviders, + normalizeSqlCodeActions, + normalizeSqlDiagnostics, + normalizeSqlDocumentEdit, + normalizeSqlDocumentSymbols, + normalizeSqlFoldingRanges, + normalizeSqlHover, + normalizeSqlLocations, + normalizeSqlRanges, +} from "../language-feature-runtime.js"; + +describe("language feature runtime boundaries", () => { + test("captures only bounded, unique, data-only providers", () => { + expect(captureSqlLanguageFeatureProviders(undefined)).toEqual([]); + expect(captureSqlLanguageFeatureProviders([ + { id: "one", hover: () => null }, + ])).toHaveLength(1); + for (const candidate of [ + null, + {}, + { id: "" }, + { id: "x".repeat(257) }, + { diagnostics: () => [] }, + { diagnostics: 1, id: "bad-method" }, + ]) { + expect(() => captureSqlLanguageFeatureProviders([candidate])).toThrow(); + } + expect(() => captureSqlLanguageFeatureProviders([ + { id: "same" }, + { id: "same" }, + ])).toThrow(); + expect(() => captureSqlLanguageFeatureProviders({})).toThrow(); + expect(() => captureSqlLanguageFeatureProviders( + Array.from({ length: 65 }, (_, index) => ({ id: String(index) })), + )).toThrow(); + const accessor = {}; + Object.defineProperty(accessor, "id", { get: () => "unsafe" }); + expect(() => captureSqlLanguageFeatureProviders([accessor])).toThrow(); + }); + + test("normalizes all range-bearing result families", () => { + expect(normalizeSqlDiagnostics([ + { + code: "E1", + from: 0, + message: "error", + severity: "error", + source: "syntax", + to: 1, + }, + { + from: 1, + message: "warning", + severity: "warning", + source: "host", + to: 2, + }, + { + from: 2, + message: "info", + severity: "information", + source: "host", + to: 3, + }, + { + from: 3, + message: "hint", + severity: "hint", + source: "host", + to: 4, + }, + ], 4)).toHaveLength(4); + expect(normalizeSqlLocations([ + { range: { from: 0, to: 1 } }, + { range: { from: 1_000, to: 1_010 }, uri: "file:///query.sql" }, + ], 2)).toHaveLength(2); + expect(normalizeSqlRanges([{ from: 0, to: 0 }], 1)).toEqual([ + { from: 0, to: 0 }, + ]); + expect(normalizeSqlRanges( + Array.from({ length: 1_000 }, () => ({ from: 0, to: 0 })), + 1, + )).toHaveLength(1_000); + for (const candidate of [ + null, + {}, + Array.from({ length: 1_001 }, () => ({})), + ]) { + expect(() => normalizeSqlRanges(candidate, 1)).toThrow(); + } + expect(() => normalizeSqlDiagnostics([{ + from: 0, + message: "bad", + severity: "fatal", + source: "host", + to: 1, + }], 1)).toThrow(); + expect(() => normalizeSqlLocations([ + { range: { from: 0, to: 1 }, uri: "" }, + ], 1)).toThrow(); + }); + + test("normalizes hover, symbols, and folding", () => { + expect(normalizeSqlHover(null, 1)).toBeNull(); + expect(normalizeSqlHover({ + contents: { kind: "plaintext", value: "value" }, + range: { from: 0, to: 1 }, + }, 1)).toMatchObject({ contents: { kind: "plaintext" } }); + expect(normalizeSqlHover({ + contents: { kind: "markdown", value: "**value**" }, + range: { from: 0, to: 1 }, + }, 1)).toMatchObject({ contents: { kind: "markdown" } }); + expect(() => normalizeSqlHover({ + contents: { kind: "html", value: "unsafe" }, + range: { from: 0, to: 1 }, + }, 1)).toThrow(); + expect(() => normalizeSqlHover(1, 1)).toThrow(); + expect(() => normalizeSqlHover({}, 1)).toThrow(); + + const kinds = ["statement", "relation", "column", "function", "parameter"] as const; + expect(normalizeSqlDocumentSymbols(kinds.map((kind) => ({ + detail: `${kind} detail`, + kind, + name: kind, + range: { from: 0, to: 2 }, + selectionRange: { from: 0, to: 1 }, + })), 2)).toHaveLength(5); + expect(() => normalizeSqlDocumentSymbols([{ + kind: "unknown", + name: "x", + range: { from: 0, to: 1 }, + selectionRange: { from: 0, to: 1 }, + }], 1)).toThrow(); + expect(() => normalizeSqlDocumentSymbols([{ + kind: "relation", + name: "x", + range: { from: 1, to: 2 }, + selectionRange: { from: 0, to: 1 }, + }], 2)).toThrow(); + + expect(normalizeSqlFoldingRanges([ + { from: 0, kind: "statement", to: 1 }, + { from: 1, kind: "region", to: 2 }, + { from: 2, kind: "comment", to: 3 }, + { from: 3, to: 4 }, + ], 4)).toHaveLength(4); + expect(() => normalizeSqlFoldingRanges([ + { from: 0, kind: "custom", to: 1 }, + ], 1)).toThrow(); + }); + + test("normalizes edits and code actions atomically", () => { + expect(normalizeSqlDocumentEdit(null, 2)).toBeNull(); + expect(normalizeSqlDocumentEdit({ + changes: [ + { from: 0, insert: "A", to: 1 }, + { from: 1, insert: "", to: 2 }, + ], + }, 2)).toMatchObject({ changes: [{ insert: "A" }, { insert: "" }] }); + expect(() => normalizeSqlDocumentEdit({ + changes: [ + { from: 1, insert: "A", to: 2 }, + { from: 0, insert: "B", to: 1 }, + ], + }, 2)).toThrow(); + expect(() => normalizeSqlDocumentEdit({ + changes: [{ from: 0, insert: 1, to: 1 }], + }, 1)).toThrow(); + expect(() => normalizeSqlDocumentEdit({ + changes: [ + { from: 0, insert: "x".repeat(16 * 1024 * 1024), to: 0 }, + { from: 0, insert: "x", to: 0 }, + ], + }, 0)).toThrow(); + expect(() => normalizeSqlDocumentEdit({ + changes: [{ from: 0, insert: "xx", to: 0 }], + }, 16 * 1024 * 1024 - 1)).toThrow(); + + expect(normalizeSqlCodeActions([ + { kind: "quickfix", title: "Fix" }, + { + diagnostics: ["E1"], + edit: { changes: [] }, + kind: "refactor", + title: "Refactor", + }, + { kind: "source", title: "Source" }, + { edit: null, title: "Null edit" }, + { title: "Other" }, + ], 1)).toHaveLength(5); + expect(() => normalizeSqlCodeActions([ + { kind: "unsafe", title: "Bad" }, + ], 1)).toThrow(); + const nested = Array.from({ length: 11 }, (_, index) => ({ + diagnostics: Array.from( + { length: 1_000 }, + (__, diagnostic) => `${index}:${diagnostic}`, + ), + title: `Action ${index}`, + })); + expect(() => normalizeSqlCodeActions(nested, 1)).toThrow(); + const action = Object.freeze({ + diagnostics: Object.freeze( + Array.from({ length: 1_000 }, (_, index) => `E${index}`), + ), + title: "Nested action", + }); + const composed = composeSqlCodeActionResults([ + Array.from({ length: 6 }, () => action), + Array.from({ length: 6 }, () => action), + ]); + expect(composed.isIncomplete).toBe(true); + expect(composed.value).toHaveLength(10); + }); + + test("isolates concurrent invocation outcomes in provider order", async () => { + let timedOutSignal: AbortSignal | undefined; + const providers = captureSqlLanguageFeatureProviders([ + { diagnostics: () => [1], id: "ready" }, + { diagnostics: () => { + throw new Error("private"); + }, id: "throw" }, + { id: "absent" }, + { + diagnostics: ({ signal }: { readonly signal: AbortSignal }) => { + timedOutSignal = signal; + return new Promise(() => {}); + }, + id: "timeout", + }, + ]); + const document = createSqlFeatureDocument( + "select", + { dialect: "duckdb" }, + [], + ); + const controller = new AbortController(); + const result = await invokeSqlFeatureProviders( + providers, + document, + {}, + controller.signal, + 5, + (provider) => provider.diagnostics !== undefined, + (provider, currentDocument, request, signal) => + provider.diagnostics?.({ + document: currentDocument, + request, + signal, + }), + (value) => value, + ); + + expect(result.reports).toEqual([ + { outcome: "ready", providerId: "ready" }, + { outcome: "failed", providerId: "throw" }, + { outcome: "timed-out", providerId: "timeout" }, + ]); + expect(result.values).toEqual([[1]]); + expect(timedOutSignal?.aborted).toBe(true); + expect(Object.isFrozen(document)).toBe(true); + }); + + test("drains ignored work while aborting publication promptly", async () => { + const providers = captureSqlLanguageFeatureProviders([ + { + diagnostics: () => new Promise(() => {}), + id: "pending", + }, + ]); + const controller = new AbortController(); + const result = invokeSqlFeatureProviders( + providers, + createSqlFeatureDocument("select", { dialect: "duckdb" }, []), + {}, + controller.signal, + 1_000, + (provider) => provider.diagnostics !== undefined, + (provider, document, request, signal) => + provider.diagnostics?.({ document, request, signal }), + (value) => value, + ); + controller.abort(); + await expect(result).resolves.toEqual({ reports: [], values: [] }); + + const alreadyAborted = new AbortController(); + alreadyAborted.abort(); + await expect(invokeSqlFeatureProviders( + providers, + createSqlFeatureDocument("select", { dialect: "duckdb" }, []), + {}, + alreadyAborted.signal, + 1_000, + () => true, + () => [], + (value) => value, + )).resolves.toEqual({ reports: [], values: [] }); + + const reentrant = new AbortController(); + await expect(invokeSqlFeatureProviders( + providers, + createSqlFeatureDocument("select", { dialect: "duckdb" }, []), + {}, + reentrant.signal, + 1_000, + () => true, + () => { + reentrant.abort(); + return new Promise(() => {}); + }, + (value) => value, + )).resolves.toEqual({ reports: [], values: [] }); + }); + + test("accounts for synchronous provider time in one request deadline", async () => { + let laterCalls = 0; + const providers = captureSqlLanguageFeatureProviders([ + { + diagnostics: () => { + const deadline = performance.now() + 10; + while (performance.now() < deadline) { + // Deliberately model a badly behaved synchronous integration. + } + return []; + }, + id: "blocking", + }, + { + diagnostics: () => { + laterCalls += 1; + return []; + }, + id: "later", + }, + ]); + const result = await invokeSqlFeatureProviders( + providers, + createSqlFeatureDocument("select", { dialect: "duckdb" }, []), + {}, + new AbortController().signal, + 1, + (provider) => provider.diagnostics !== undefined, + (provider, document, request, signal) => + provider.diagnostics?.({ document, request, signal }), + (value) => value, + ); + expect(result).toEqual({ + reports: [ + { outcome: "timed-out", providerId: "blocking" }, + { outcome: "timed-out", providerId: "later" }, + ], + values: [], + }); + expect(laterCalls).toBe(0); + }); + + test("isolates provider mutation during capability inspection", async () => { + const mutable = { + diagnostics: () => [], + id: "mutable", + }; + const providers = captureSqlLanguageFeatureProviders([mutable]); + Object.defineProperty(mutable, "diagnostics", { + configurable: true, + get: () => { + throw new Error("private accessor failure"); + }, + }); + const result = await invokeSqlFeatureProviders( + providers, + createSqlFeatureDocument("select", { dialect: "duckdb" }, []), + {}, + new AbortController().signal, + 10, + (provider) => provider.diagnostics !== undefined, + () => [], + (value) => value, + ); + expect(result).toEqual({ + reports: [{ outcome: "failed", providerId: "mutable" }], + values: [], + }); + }); +}); diff --git a/src/__tests__/language-features.test.ts b/src/__tests__/language-features.test.ts new file mode 100644 index 0000000..dfdd6c3 --- /dev/null +++ b/src/__tests__/language-features.test.ts @@ -0,0 +1,523 @@ +import { describe, expect, test, vi } from "vitest"; +import { + bigQueryDialect, + createSqlLanguageService, + duckdbDialect, + SqlSessionError, + type SqlDocumentContext, + type SqlLanguageFeatureProvider, +} from "../index.js"; + +function open( + text: string, + providers: readonly SqlLanguageFeatureProvider[] = [], + budget = 150, +) { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviderBudgetMs: budget, + featureProviders: providers, + }); + const session = service.openDocument({ + context: { dialect: "duckdb" }, + text, + }); + return { service, session }; +} + +describe("language feature sessions", () => { + test("provides bounded local statement symbols and folds", async () => { + const { service, session } = open( + "select\n 1;\n\ninsert into t\nvalues (1)", + ); + + await expect(session.documentSymbols().result).resolves.toMatchObject({ + status: "ready", + value: [ + { + kind: "statement", + name: "SELECT statement", + selectionRange: { from: 0, to: 6 }, + }, + { + kind: "statement", + name: "INSERT statement", + }, + ], + }); + await expect(session.foldingRanges().result).resolves.toMatchObject({ + status: "ready", + value: [ + { from: 0, kind: "statement", to: 10 }, + { from: 13, kind: "statement", to: 37 }, + ], + }); + session.dispose(); + service.dispose(); + }); + + test("composes diagnostics while preserving provider evidence", async () => { + const providers: readonly SqlLanguageFeatureProvider[] = [ + { + id: "syntax", + diagnostics: () => [{ + from: 0, + message: "Unexpected token", + severity: "error", + source: "parser", + to: 6, + }], + }, + { + id: "host", + diagnostics: () => [{ + code: "policy", + from: 7, + message: "Use a qualified relation", + severity: "warning", + source: "host", + to: 8, + }], + }, + ]; + const { service, session } = open("select t", providers); + + const result = await session.diagnostics().result; + + expect(result).toMatchObject({ + sources: [ + { outcome: "ready", providerId: "syntax" }, + { outcome: "ready", providerId: "host" }, + ], + status: "ready", + }); + if (result.status === "ready") { + expect(result.value).toHaveLength(2); + expect(result.isIncomplete).toBe(false); + expect(Object.isFrozen(result.value)).toBe(true); + expect(Object.isFrozen(result.value[0])).toBe(true); + } + session.dispose(); + service.dispose(); + }); + + test("supports every scalar and collection feature contract", async () => { + const provider: SqlLanguageFeatureProvider = { + id: "engine", + codeActions: () => [{ + edit: { changes: [{ from: 0, insert: "SELECT", to: 6 }] }, + kind: "quickfix", + title: "Uppercase SELECT", + }], + definitions: () => [{ range: { from: 7, to: 8 } }], + format: () => ({ + changes: [{ from: 0, insert: "SELECT a", to: 8 }], + }), + highlights: () => [{ from: 7, to: 8 }], + hover: () => ({ + contents: { kind: "markdown", value: "`a`: integer" }, + range: { from: 7, to: 8 }, + }), + references: () => [ + { range: { from: 7, to: 8 } }, + { range: { from: 17, to: 18 }, uri: "file:///query.sql" }, + ], + rename: ({ request }) => ({ + changes: [ + { from: 7, insert: request.newName, to: 8 }, + { from: 17, insert: request.newName, to: 18 }, + ], + }), + }; + const { service, session } = open( + "select a from t, a", + [provider], + ); + + await expect(session.hover({ position: 7 }).result).resolves.toMatchObject({ + status: "ready", + value: { range: { from: 7, to: 8 } }, + }); + await expect( + session.definitions({ position: 7 }).result, + ).resolves.toMatchObject({ status: "ready", value: [{ range: { from: 7 } }] }); + await expect( + session.references({ position: 7 }).result, + ).resolves.toMatchObject({ status: "ready", value: [{}, {}] }); + await expect( + session.highlights({ position: 7 }).result, + ).resolves.toMatchObject({ status: "ready", value: [{ from: 7 }] }); + await expect( + session.rename({ newName: "answer", position: 7 }).result, + ).resolves.toMatchObject({ + status: "ready", + value: { changes: [{ insert: "answer" }, { insert: "answer" }] }, + }); + await expect(session.format({ tabSize: 2 }).result).resolves.toMatchObject({ + status: "ready", + value: { changes: [{ insert: "SELECT a" }] }, + }); + await expect( + session.codeActions({ range: { from: 0, to: 6 } }).result, + ).resolves.toMatchObject({ + status: "ready", + value: [{ kind: "quickfix", title: "Uppercase SELECT" }], + }); + session.dispose(); + service.dispose(); + }); + + test("isolates provider failures and malformed results", async () => { + const malformed = { + id: "malformed", + diagnostics: () => [{ + from: -1, + message: "bad", + severity: "error", + source: "bad", + to: 1, + }], + } satisfies SqlLanguageFeatureProvider; + const rejected = { + id: "rejected", + diagnostics: () => Promise.reject(new Error("private detail")), + } satisfies SqlLanguageFeatureProvider; + const { service, session } = open("select 1", [malformed, rejected]); + + await expect(session.diagnostics().result).resolves.toEqual({ + reason: "no-result", + revision: session.revision, + sources: [ + { outcome: "failed", providerId: "malformed" }, + { outcome: "failed", providerId: "rejected" }, + ], + status: "unavailable", + }); + session.dispose(); + service.dispose(); + }); + + test("cancels promptly on callers, updates, and disposal", async () => { + const pending = vi.fn(() => new Promise(() => {})); + const { service, session } = open( + "select 1", + [{ id: "remote", hover: pending }], + 25, + ); + const controller = new AbortController(); + const caller = session.hover({ + position: 1, + signal: controller.signal, + }); + controller.abort(); + await expect(caller.result).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + + const superseded = session.hover({ position: 1 }); + session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "select 2" }, + embeddedRegions: [], + }); + await expect(superseded.result).resolves.toMatchObject({ + reason: "superseded", + status: "cancelled", + }); + + const disposed = session.hover({ position: 1 }); + session.dispose(); + await expect(disposed.result).resolves.toMatchObject({ + reason: "disposed", + status: "cancelled", + }); + service.dispose(); + }); + + test("runs independent providers concurrently under one latency envelope", async () => { + const provider = (id: string): SqlLanguageFeatureProvider => ({ + id, + diagnostics: () => new Promise(() => {}), + }); + const { service, session } = open( + "select 1", + [provider("one"), provider("two"), provider("three")], + 20, + ); + const started = performance.now(); + + const result = await session.diagnostics().result; + + expect(performance.now() - started).toBeLessThan(75); + expect(result).toMatchObject({ + reason: "no-result", + status: "unavailable", + }); + session.dispose(); + service.dispose(); + }); + + test("rejects unsafe request and provider shapes atomically", () => { + const { service, session } = open("select 1"); + expect(() => session.hover({ position: -1 })).toThrowError( + SqlSessionError, + ); + expect(() => session.rename({ newName: "", position: 1 })).toThrowError( + SqlSessionError, + ); + expect(() => session.format({ tabSize: 0 })).toThrowError(SqlSessionError); + session.dispose(); + service.dispose(); + + expect(() => createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviders: [ + { id: "same" }, + { id: "same" }, + ], + })).toThrowError(SqlSessionError); + }); + + test("normalizes the complete request surface", async () => { + const controller = new AbortController(); + const { service, session } = open("select 1", [{ + id: "empty", + codeActions: () => [], + diagnostics: () => [], + format: () => null, + hover: () => null, + rename: () => null, + }]); + await expect(session.diagnostics({ + range: undefined, + signal: controller.signal, + }).result).resolves.toMatchObject({ status: "ready" }); + await expect(session.diagnostics({ + range: { from: 0, to: 6 }, + }).result).resolves.toMatchObject({ status: "ready" }); + await expect(session.codeActions({ + range: { from: 0, to: 6 }, + signal: controller.signal, + }).result).resolves.toMatchObject({ status: "ready" }); + await expect(session.format({ + range: { from: 0, to: 6 }, + tabSize: 16, + useTabs: true, + }).result).resolves.toMatchObject({ + reason: "no-result", + status: "unavailable", + }); + await expect(session.format({ + tabSize: 1, + useTabs: false, + }).result).resolves.toMatchObject({ status: "unavailable" }); + await expect(session.hover({ + position: 1, + signal: controller.signal, + }).result).resolves.toMatchObject({ + reason: "no-result", + status: "unavailable", + }); + await expect(session.rename({ + newName: "x", + position: 1, + signal: controller.signal, + }).result).resolves.toMatchObject({ + reason: "no-result", + status: "unavailable", + }); + + for (const invoke of [ + () => Reflect.apply(session.hover, session, [null]), + () => Reflect.apply(session.hover, session, [{ position: Number.NaN }]), + () => Reflect.apply(session.hover, session, [{ position: 9 }]), + () => Reflect.apply(session.hover, session, [{ position: 1, signal: "bad" }]), + () => Reflect.apply(session.codeActions, session, [null]), + () => Reflect.apply(session.codeActions, session, [{}]), + () => Reflect.apply(session.codeActions, session, [{ range: { from: 2, to: 1 } }]), + () => Reflect.apply(session.diagnostics, session, [null]), + () => Reflect.apply(session.diagnostics, session, [{ range: null }]), + () => Reflect.apply(session.rename, session, [{ newName: 1, position: 1 }]), + () => Reflect.apply(session.rename, session, [{ newName: "x".repeat(1_025), position: 1 }]), + () => Reflect.apply(session.format, session, [{ tabSize: Number.NaN }]), + () => Reflect.apply(session.format, session, [{ tabSize: 17 }]), + () => Reflect.apply(session.format, session, [{ tabSize: "2" }]), + () => Reflect.apply(session.format, session, [{ useTabs: "yes" }]), + ]) { + expect(invoke).toThrowError(SqlSessionError); + } + const positionAccessor = {}; + Object.defineProperty(positionAccessor, "position", { + get: () => { + throw new Error("unsafe"); + }, + }); + const rangeAccessor = {}; + Object.defineProperty(rangeAccessor, "range", { + get: () => { + throw new Error("unsafe"); + }, + }); + expect(() => Reflect.apply( + session.hover, + session, + [positionAccessor], + )).toThrowError(SqlSessionError); + expect(() => Reflect.apply( + session.codeActions, + session, + [rangeAccessor], + )).toThrowError(SqlSessionError); + expect(() => Reflect.apply( + session.diagnostics, + session, + [rangeAccessor], + )).toThrowError(SqlSessionError); + session.dispose(); + expect(() => session.diagnostics()).toThrowError(SqlSessionError); + service.dispose(); + }); + + test("clamps composed collection results and masks embedded regions", async () => { + const diagnostics = Array.from({ length: 1_000 }, (_, index) => ({ + from: index % 2, + message: `message ${index}`, + severity: "hint" as const, + source: "host", + to: index % 2, + })); + const { service, session } = open("s{py}\nelect 1", [ + { diagnostics: () => diagnostics, id: "one" }, + { diagnostics: () => diagnostics, id: "two" }, + ]); + const result = await session.diagnostics().result; + expect(result.status).toBe("ready"); + if (result.status === "ready") { + expect(result.value).toHaveLength(1_000); + expect(result.isIncomplete).toBe(true); + } + session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "s{py}\nelect 2" }, + embeddedRegions: [{ from: 1, language: "python", to: 5 }], + }); + await expect(session.documentSymbols().result).resolves.toMatchObject({ + status: "ready", + }); + session.dispose(); + service.dispose(); + }); + + test("accepts budget boundaries and rejects every invalid budget class", () => { + const defaults = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviderBudgetMs: undefined, + featureProviders: undefined, + }); + defaults.dispose(); + for (const budget of [0, 5_000]) { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviderBudgetMs: budget, + }); + service.dispose(); + } + for (const budget of [-1, 5_001, Number.POSITIVE_INFINITY, "10"]) { + expect(() => Reflect.apply(createSqlLanguageService, undefined, [{ + dialects: [duckdbDialect()], + featureProviderBudgetMs: budget, + }])).toThrowError(SqlSessionError); + } + expect(() => createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviders: [{ id: "@marimo/local-structure" }], + })).toThrowError(SqlSessionError); + }); + + test("makes repeated cancellation idempotent", async () => { + const { service, session } = open("select", [{ + id: "pending", + hover: () => new Promise(() => {}), + }], 1_000); + const task = session.hover({ position: 1 }); + task.cancel(); + task.cancel(); + session.update({ + baseRevision: session.revision, + document: { kind: "replace", text: "select 2" }, + embeddedRegions: [], + }); + await expect(task.result).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + session.dispose(); + service.dispose(); + }); + + test("bounds local structure output and handles trivia-only statements", async () => { + const statement = "select\n1;"; + const text = `; 123;\n${statement.repeat(1_001)}`; + const { service, session } = open(text); + const symbols = await session.documentSymbols().result; + const folds = await session.foldingRanges().result; + expect(symbols.status).toBe("ready"); + expect(folds.status).toBe("ready"); + if (symbols.status === "ready") { + expect(symbols.value).toHaveLength(1_000); + expect(symbols.value[0]?.name).toBe("SQL statement"); + } + if (folds.status === "ready") { + expect(folds.value).toHaveLength(1_000); + } + session.dispose(); + service.dispose(); + }); + + test("does not invent structure inside opaque procedural blocks", async () => { + const service = createSqlLanguageService({ + dialects: [bigQueryDialect()], + }); + const session = service.openDocument({ + context: { dialect: "bigquery" }, + text: "IF condition THEN SELECT 1; END IF;", + }); + await expect(session.documentSymbols().result).resolves.toMatchObject({ + status: "ready", + value: [], + }); + await expect(session.foldingRanges().result).resolves.toMatchObject({ + status: "ready", + value: [], + }); + session.dispose(); + service.dispose(); + }); + + test("uses one provider-owned cancellation signal at maximum fan-out", async () => { + const providerSignals: AbortSignal[] = []; + const requestHasSignal: boolean[] = []; + const providers = Array.from({ length: 64 }, (_, index) => ({ + id: `provider-${index}`, + hover: ({ request, signal }) => { + requestHasSignal.push("signal" in request); + providerSignals.push(signal); + return new Promise(() => {}); + }, + } satisfies SqlLanguageFeatureProvider)); + const { service, session } = open("select", providers, 1_000); + const task = session.hover({ position: 1 }); + await Promise.resolve(); + task.cancel(); + await expect(task.result).resolves.toMatchObject({ + reason: "caller", + status: "cancelled", + }); + expect(providerSignals).toHaveLength(64); + expect(providerSignals.every((signal) => signal.aborted)).toBe(true); + expect(requestHasSignal.every((present) => !present)).toBe(true); + session.dispose(); + service.dispose(); + }); +}); diff --git a/src/__tests__/relation-catalog-boundary.test.ts b/src/__tests__/relation-catalog-boundary.test.ts index 0e610bc..80b7829 100644 --- a/src/__tests__/relation-catalog-boundary.test.ts +++ b/src/__tests__/relation-catalog-boundary.test.ts @@ -1046,6 +1046,18 @@ describe("catalog response decoding", () => { } }); + it("accepts table-valued functions as relation-site entities", () => { + const value = accepted(decodeSqlCatalogSearchResponse( + readyResponse([{ ...relation(), relationKind: "table-function" }]), + 20, + POSTGRESQL_SQL_RELATION_DIALECT, + )); + expect(value).toMatchObject({ + relations: [{ relationKind: "table-function" }], + status: "ready", + }); + }); + it("rejects present undefined detail and enforces relation bounds", () => { expectMalformed( decodeSqlCatalogSearchResponse( diff --git a/src/codemirror/__tests__/sql-editor.test.ts b/src/codemirror/__tests__/sql-editor.test.ts index d80cce6..7be6e11 100644 --- a/src/codemirror/__tests__/sql-editor.test.ts +++ b/src/codemirror/__tests__/sql-editor.test.ts @@ -25,6 +25,8 @@ import { type SqlDocumentContext, type SqlDocumentSession, type SqlDocumentUpdate, + type SqlFeatureTask, + type SqlLanguageFeatureMethods, type SqlLanguageService, type SqlRelationCatalogProvider, type SqlRevision, @@ -95,7 +97,7 @@ function relationResponse( function completionItem( from = 14, to = 16, - relationKind: "cte" | "table" = "table", + relationKind: "cte" | "table" | "table-function" = "table", ): SqlCompletionItem { const edit = { from, @@ -151,7 +153,29 @@ function fakeService( openDocument: () => { let disposed = false; let revision = createSqlRevisionToken(); + const unavailable = (): SqlFeatureTask => ({ + cancel: () => undefined, + result: Promise.resolve({ + reason: "no-provider", + revision, + sources: [], + status: "unavailable", + }), + }); + const featureMethods: SqlLanguageFeatureMethods = { + codeActions: () => unavailable(), + definitions: () => unavailable(), + diagnostics: () => unavailable(), + documentSymbols: () => unavailable(), + foldingRanges: () => unavailable(), + format: () => unavailable(), + highlights: () => unavailable(), + hover: () => unavailable(), + references: () => unavailable(), + rename: () => unavailable(), + }; const session: SqlDocumentSession = { + ...featureMethods, complete: (request): SqlCompletionTask => { completionRequests.push(request); completeSignals.push(request.signal ?? new AbortController().signal); @@ -704,6 +728,47 @@ describe("sqlEditor", () => { service.dispose(); }); + it("exposes every language feature through the editor lifecycle", async () => { + const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviders: [{ + id: "host", + codeActions: () => [], + definitions: () => [], + diagnostics: () => [], + format: () => ({ changes: [] }), + highlights: () => [], + hover: () => null, + references: () => [], + rename: () => ({ changes: [] }), + }], + }); + const support = sqlEditor({ + initialContext: { dialect: "duckdb", engine: "local" }, + service, + }); + const view = createView(support.extension, "select\n1"); + + const tasks = [ + support.codeActions(view, { range: { from: 0, to: 6 } }), + support.definitions(view, { position: 1 }), + support.diagnostics(view), + support.documentSymbols(view), + support.foldingRanges(view), + support.format(view), + support.highlights(view, { position: 1 }), + support.hover(view, { position: 1 }), + support.references(view, { position: 1 }), + support.rename(view, { newName: "value", position: 1 }), + ]; + expect(tasks.every((task) => task !== null)).toBe(true); + await Promise.all(tasks.map((task) => task?.result)); + + view.destroy(); + expect(support.diagnostics(view)).toBeNull(); + service.dispose(); + }); + it("marks internal blank lines but not separator trivia", async () => { const service = createSqlLanguageService({ dialects: [duckdbDialect()], @@ -821,6 +886,20 @@ describe("sqlEditor", () => { service.dispose(); }); + it("presents table-valued relation completions as functions", async () => { + const harness = fakeService((revision) => + readyResult(revision, [completionItem(14, 16, "table-function")]) + ); + const support = sqlEditor({ + initialContext: context(), + service: harness.service, + }); + const view = createView(support.extension); + expect(startCompletion(view)).toBe(true); + await waitForActiveCompletion(view); + expect(currentCompletions(view.state)[0]?.type).toBe("function"); + }); + it("owns rich completion info until CodeMirror destroys it", async () => { const item = completionItem(); const destroys: Array> = []; diff --git a/src/codemirror/sql-editor.ts b/src/codemirror/sql-editor.ts index 4ff6188..47627ff 100644 --- a/src/codemirror/sql-editor.ts +++ b/src/codemirror/sql-editor.ts @@ -52,7 +52,23 @@ import type { SqlLanguageService, SqlRevision, SqlTextChange, + SqlTextRange, } from "../types.js"; +import type { + SqlCodeAction, + SqlDiagnostic, + SqlDiagnosticsRequest, + SqlDocumentEditResult, + SqlDocumentSymbol, + SqlFeatureTask, + SqlFoldingRange, + SqlFormatRequest, + SqlHover, + SqlLocation, + SqlPositionFeatureRequest, + SqlRangeFeatureRequest, + SqlRenameRequest, +} from "../language-features.js"; import { createSqlStatementGutter, type SqlEditorStatementGutterOptions, @@ -96,6 +112,36 @@ export interface SqlEditorSupport< readonly SqlEmbeddedRegion[] >; readonly extension: Extension; + readonly codeActions: ( + view: EditorView, + request: SqlRangeFeatureRequest, + ) => SqlFeatureTask | null; + readonly definitions: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask | null; + readonly diagnostics: ( + view: EditorView, + request?: SqlDiagnosticsRequest, + ) => SqlFeatureTask | null; + readonly documentSymbols: ( + view: EditorView, + ) => SqlFeatureTask | null; + readonly foldingRanges: ( + view: EditorView, + ) => SqlFeatureTask | null; + readonly format: ( + view: EditorView, + request?: SqlFormatRequest, + ) => SqlFeatureTask | null; + readonly highlights: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask | null; + readonly hover: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask | null; readonly invalidateCatalog: (view: EditorView) => SqlRevision | null; readonly statementBoundariesIntersecting: ( view: EditorView, @@ -113,6 +159,14 @@ export interface SqlEditorSupport< view: EditorView, regions: readonly SqlEmbeddedRegion[], ) => void; + readonly references: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask | null; + readonly rename: ( + view: EditorView, + request: SqlRenameRequest, + ) => SqlFeatureTask | null; } export interface SqlEditorRuntime { @@ -225,6 +279,7 @@ function haveOneEditRange(items: readonly SqlCompletionItem[]): boolean { function completionType(item: SqlCompletionItem): string { if (item.kind === "column") return "property"; if (item.kind === "namespace") return "namespace"; + if (item.relationKind === "table-function") return "function"; return item.relationKind === "cte" ? "type" : "table"; } @@ -314,6 +369,10 @@ export function createSqlEditorInternal< readonly #subscription; readonly #visibilityListener: () => void; + get destroyed(): boolean { + return this.#destroyed; + } + constructor(view: EditorView) { this.#view = view; const initialRegions = view.state.field(embeddedRegionsField); @@ -725,6 +784,54 @@ export function createSqlEditorInternal< this.#clearCompletionState(); }; + readonly codeActions = ( + request: SqlRangeFeatureRequest, + ): SqlFeatureTask => + this.#session.codeActions(request); + + readonly definitions = ( + request: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#session.definitions(request); + + readonly diagnostics = ( + request?: SqlDiagnosticsRequest, + ): SqlFeatureTask => + this.#session.diagnostics(request); + + readonly documentSymbols = (): SqlFeatureTask< + readonly SqlDocumentSymbol[] + > => this.#session.documentSymbols(); + + readonly foldingRanges = (): SqlFeatureTask< + readonly SqlFoldingRange[] + > => this.#session.foldingRanges(); + + readonly format = ( + request?: SqlFormatRequest, + ): SqlFeatureTask => + this.#session.format(request); + + readonly highlights = ( + request: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#session.highlights(request); + + readonly hover = ( + request: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#session.hover(request); + + readonly references = ( + request: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#session.references(request); + + readonly rename = ( + request: SqlRenameRequest, + ): SqlFeatureTask => + this.#session.rename(request); + readonly invalidateCatalog = (): SqlRevision | null => { if (this.#destroyed) return null; try { @@ -933,8 +1040,29 @@ export function createSqlEditorInternal< view.state.field(embeddedRegionsField), ], }); + const withPlugin = ( + view: EditorView, + run: (instance: SqlEditorPlugin) => Value, + ): Value | null => { + const instance = view.plugin(plugin); + return instance === null || instance.destroyed ? null : run(instance); + }; return Object.freeze({ + codeActions: ( + view: EditorView, + request: SqlRangeFeatureRequest, + ) => withPlugin(view, (instance) => instance.codeActions(request)), contextEffect, + definitions: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => withPlugin(view, (instance) => instance.definitions(request)), + diagnostics: ( + view: EditorView, + request?: SqlDiagnosticsRequest, + ) => withPlugin(view, (instance) => instance.diagnostics(request)), + documentSymbols: (view: EditorView) => + withPlugin(view, (instance) => instance.documentSymbols()), embeddedRegionsEffect, extension: [ contextField, @@ -945,8 +1073,30 @@ export function createSqlEditorInternal< completionLanguageData, autocompletion(autocompleteOptions), ], + foldingRanges: (view: EditorView) => + withPlugin(view, (instance) => instance.foldingRanges()), + format: ( + view: EditorView, + request?: SqlFormatRequest, + ) => withPlugin(view, (instance) => instance.format(request)), + highlights: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => withPlugin(view, (instance) => instance.highlights(request)), + hover: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => withPlugin(view, (instance) => instance.hover(request)), invalidateCatalog: (view: EditorView): SqlRevision | null => view.plugin(plugin)?.invalidateCatalog() ?? null, + references: ( + view: EditorView, + request: SqlPositionFeatureRequest, + ) => withPlugin(view, (instance) => instance.references(request)), + rename: ( + view: EditorView, + request: SqlRenameRequest, + ) => withPlugin(view, (instance) => instance.rename(request)), statementBoundariesIntersecting: ( view: EditorView, request: SqlStatementBoundariesIntersectingRequest, diff --git a/src/index.ts b/src/index.ts index 0439452..c5afa72 100644 --- a/src/index.ts +++ b/src/index.ts @@ -54,6 +54,37 @@ export type { SqlTextRange, } from "./types.js"; export { SqlSessionError } from "./types.js"; +export { + MAX_SQL_FEATURE_RESULTS, + MAX_SQL_FEATURE_TEXT_LENGTH, +} from "./language-features.js"; +export type { + SqlCodeAction, + SqlDiagnostic, + SqlDiagnosticSeverity, + SqlDiagnosticsRequest, + SqlDocumentEditResult, + SqlDocumentSymbol, + SqlDocumentSymbolKind, + SqlFeatureCancelled, + SqlFeatureDocument, + SqlFeatureProviderReport, + SqlFeatureProviderRequest, + SqlFeatureReady, + SqlFeatureResult, + SqlFeatureTask, + SqlFeatureUnavailable, + SqlFoldingRange, + SqlFormatRequest, + SqlHover, + SqlLanguageFeatureMethods, + SqlLanguageFeatureProvider, + SqlLocation, + SqlMarkupContent, + SqlPositionFeatureRequest, + SqlRangeFeatureRequest, + SqlRenameRequest, +} from "./language-features.js"; export type { SqlExactStatementBoundary, SqlOpaqueStatementBoundary, diff --git a/src/language-feature-runtime.ts b/src/language-feature-runtime.ts new file mode 100644 index 0000000..ff318b5 --- /dev/null +++ b/src/language-feature-runtime.ts @@ -0,0 +1,604 @@ +import type { + SqlCodeAction, + SqlDiagnostic, + SqlDocumentEditResult, + SqlDocumentSymbol, + SqlFeatureDocument, + SqlFeatureProviderReport, + SqlFoldingRange, + SqlHover, + SqlLanguageFeatureProvider, + SqlLocation, +} from "./language-features.js"; +import { + MAX_SQL_FEATURE_RESULTS, + MAX_SQL_FEATURE_TEXT_LENGTH, +} from "./language-features.js"; +import { + MAX_SQL_SOURCE_LENGTH, + normalizeSqlTextRange, +} from "./source.js"; +import type { + SqlDocumentContext, + SqlTextChange, + SqlTextRange, +} from "./types.js"; + +const PROVIDER_METHODS = [ + "codeActions", + "definitions", + "diagnostics", + "documentSymbols", + "foldingRanges", + "format", + "highlights", + "hover", + "references", + "rename", +] as const; +const MAX_SQL_FEATURE_NESTED_ITEMS = 10_000; +const MAX_SQL_FEATURE_AGGREGATE_TEXT_LENGTH = MAX_SQL_SOURCE_LENGTH; + +export interface CapturedSqlLanguageFeatureProvider< + Context extends SqlDocumentContext, +> { + readonly provider: SqlLanguageFeatureProvider; + readonly id: string; +} + +function isSqlLanguageFeatureProvider< + Context extends SqlDocumentContext, +>(value: object): value is SqlLanguageFeatureProvider { + const id = ownData(value, "id"); + if (typeof id !== "string") return false; + return PROVIDER_METHODS.every((method) => { + const candidate = ownData(value, method); + return candidate === undefined || typeof candidate === "function"; + }); +} + +function ownData(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) return undefined; + return descriptor.value; +} + +export function captureSqlLanguageFeatureProviders< + Context extends SqlDocumentContext, +>( + candidates: unknown, +): readonly CapturedSqlLanguageFeatureProvider[] { + if (candidates === undefined) return Object.freeze([]); + if (!Array.isArray(candidates) || candidates.length > 64) { + throw new Error("SQL feature providers must be an array of at most 64 providers"); + } + const captured: CapturedSqlLanguageFeatureProvider[] = []; + const ids = new Set(); + for (const candidate of candidates) { + if (candidate === null || typeof candidate !== "object") { + throw new Error("SQL feature providers must be objects"); + } + if (!isSqlLanguageFeatureProvider(candidate)) { + throw new Error("SQL feature provider has an invalid shape"); + } + const id = candidate.id; + if ( + typeof id !== "string" || + id.length === 0 || + id.length > 256 || + ids.has(id) + ) { + throw new Error("SQL feature provider IDs must be unique non-empty strings"); + } + ids.add(id); + captured.push(Object.freeze({ + id, + provider: candidate, + })); + } + return Object.freeze(captured); +} + +export function createSqlFeatureDocument< + Context extends SqlDocumentContext, +>( + text: string, + context: Context, + embeddedRegions: SqlFeatureDocument["embeddedRegions"], +): SqlFeatureDocument { + return Object.freeze({ + context, + dialect: context.dialect, + embeddedRegions, + text, + }); +} + +function text(value: unknown, subject: string): string { + if ( + typeof value !== "string" || + value.length === 0 || + value.length > MAX_SQL_FEATURE_TEXT_LENGTH + ) { + throw new Error(`${subject} must be a bounded non-empty string`); + } + return value; +} + +function optionalText(value: unknown, subject: string): string | undefined { + return value === undefined ? undefined : text(value, subject); +} + +function range(value: unknown, length: number, subject: string): SqlTextRange { + return normalizeSqlTextRange(value, length, subject); +} + +function array(value: unknown, subject: string): readonly unknown[] { + if (!Array.isArray(value) || value.length > MAX_SQL_FEATURE_RESULTS) { + throw new Error(`${subject} must be a bounded array`); + } + return value; +} + +function object(value: unknown, subject: string): object { + if (value === null || typeof value !== "object") { + throw new Error(`${subject} must be an object`); + } + return value; +} + +function property(value: object, key: PropertyKey): unknown { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor || !("value" in descriptor)) { + throw new Error(`Feature result requires ${String(key)}`); + } + return descriptor.value; +} + +export function normalizeSqlDiagnostics( + value: unknown, + length: number, +): readonly SqlDiagnostic[] { + return Object.freeze(array(value, "SQL diagnostics").map((candidate) => { + const item = object(candidate, "SQL diagnostic"); + const location = range(item, length, "SQL diagnostic range"); + const severity = property(item, "severity"); + if ( + severity !== "error" && + severity !== "warning" && + severity !== "information" && + severity !== "hint" + ) { + throw new Error("SQL diagnostic severity is invalid"); + } + return Object.freeze({ + ...location, + code: optionalText(ownData(item, "code"), "SQL diagnostic code"), + message: text(property(item, "message"), "SQL diagnostic message"), + severity, + source: text(property(item, "source"), "SQL diagnostic source"), + }); + })); +} + +export function normalizeSqlHover( + value: unknown, + length: number, +): SqlHover | null { + if (value === null) return null; + const item = object(value, "SQL hover"); + const contents = object(property(item, "contents"), "SQL hover contents"); + const kind = property(contents, "kind"); + if (kind !== "plaintext" && kind !== "markdown") { + throw new Error("SQL hover markup kind is invalid"); + } + return Object.freeze({ + contents: Object.freeze({ + kind, + value: text(property(contents, "value"), "SQL hover contents"), + }), + range: range(property(item, "range"), length, "SQL hover range"), + }); +} + +function normalizeLocation(value: unknown, length: number): SqlLocation { + const item = object(value, "SQL location"); + const uri = ownData(item, "uri"); + return Object.freeze({ + range: range( + property(item, "range"), + uri === undefined ? length : MAX_SQL_SOURCE_LENGTH, + "SQL location range", + ), + uri: uri === undefined ? undefined : text(uri, "SQL location URI"), + }); +} + +export function normalizeSqlLocations( + value: unknown, + length: number, +): readonly SqlLocation[] { + return Object.freeze( + array(value, "SQL locations").map((item) => + normalizeLocation(item, length)), + ); +} + +export function normalizeSqlRanges( + value: unknown, + length: number, +): readonly SqlTextRange[] { + return Object.freeze( + array(value, "SQL ranges").map((item) => + range(item, length, "SQL feature range")), + ); +} + +export function normalizeSqlDocumentSymbols( + value: unknown, + length: number, +): readonly SqlDocumentSymbol[] { + return Object.freeze(array(value, "SQL document symbols").map((candidate) => { + const item = object(candidate, "SQL document symbol"); + const kind = property(item, "kind"); + if ( + kind !== "statement" && + kind !== "relation" && + kind !== "column" && + kind !== "function" && + kind !== "parameter" + ) { + throw new Error("SQL document symbol kind is invalid"); + } + const symbolRange = range(property(item, "range"), length, "SQL symbol range"); + const selectionRange = range( + property(item, "selectionRange"), + length, + "SQL symbol selection range", + ); + if ( + selectionRange.from < symbolRange.from || + selectionRange.to > symbolRange.to + ) { + throw new Error("SQL symbol selection must be inside its range"); + } + return Object.freeze({ + detail: optionalText(ownData(item, "detail"), "SQL symbol detail"), + kind, + name: text(property(item, "name"), "SQL symbol name"), + range: symbolRange, + selectionRange, + }); + })); +} + +export function normalizeSqlFoldingRanges( + value: unknown, + length: number, +): readonly SqlFoldingRange[] { + return Object.freeze(array(value, "SQL folding ranges").map((candidate) => { + const item = object(candidate, "SQL folding range"); + const normalized = range(item, length, "SQL folding range"); + const kind = ownData(item, "kind"); + if ( + kind !== undefined && + kind !== "statement" && + kind !== "region" && + kind !== "comment" + ) { + throw new Error("SQL folding range kind is invalid"); + } + return Object.freeze({ ...normalized, kind }); + })); +} + +function normalizeTextChanges( + value: unknown, + length: number, +): readonly SqlTextChange[] { + let insertedLength = 0; + let removedLength = 0; + const changes = array(value, "SQL text changes").map((candidate) => { + const item = object(candidate, "SQL text change"); + const normalized = range(item, length, "SQL text change range"); + const insert = property(item, "insert"); + if (typeof insert !== "string" || insert.length > 16 * 1024 * 1024) { + throw new Error("SQL text change insertion is invalid"); + } + insertedLength += insert.length; + removedLength += normalized.to - normalized.from; + if (insertedLength > MAX_SQL_FEATURE_AGGREGATE_TEXT_LENGTH) { + throw new Error("SQL text changes contain too much inserted text"); + } + return Object.freeze({ ...normalized, insert }); + }); + let previousEnd = 0; + for (const change of changes) { + if (change.from < previousEnd) { + throw new Error("SQL text changes must be ordered and non-overlapping"); + } + previousEnd = change.to; + } + if (length - removedLength + insertedLength > MAX_SQL_SOURCE_LENGTH) { + throw new Error("SQL text changes exceed the maximum document length"); + } + return Object.freeze(changes); +} + +export function normalizeSqlDocumentEdit( + value: unknown, + length: number, +): SqlDocumentEditResult | null { + if (value === null) return null; + const item = object(value, "SQL document edit"); + return Object.freeze({ + changes: normalizeTextChanges(property(item, "changes"), length), + }); +} + +export function normalizeSqlCodeActions( + value: unknown, + length: number, +): readonly SqlCodeAction[] { + const actions: SqlCodeAction[] = []; + let nestedItems = 0; + let aggregateTextLength = 0; + for (const candidate of array(value, "SQL code actions")) { + const item = object(candidate, "SQL code action"); + const kind = ownData(item, "kind"); + if ( + kind !== undefined && + kind !== "quickfix" && + kind !== "refactor" && + kind !== "source" + ) { + throw new Error("SQL code action kind is invalid"); + } + const edit = ownData(item, "edit"); + const diagnostics = ownData(item, "diagnostics"); + const normalizedDiagnostics = diagnostics === undefined + ? undefined + : Object.freeze( + array(diagnostics, "SQL code action diagnostics").map( + (code) => text(code, "SQL diagnostic code"), + ), + ); + const normalizedEdit = edit === undefined + ? undefined + : normalizeSqlDocumentEdit(edit, length) ?? undefined; + const title = text(property(item, "title"), "SQL code action title"); + nestedItems += + (normalizedDiagnostics?.length ?? 0) + + (normalizedEdit?.changes.length ?? 0); + aggregateTextLength += title.length; + for (const diagnostic of normalizedDiagnostics ?? []) { + aggregateTextLength += diagnostic.length; + } + for (const change of normalizedEdit?.changes ?? []) { + aggregateTextLength += change.insert.length; + } + if ( + nestedItems > MAX_SQL_FEATURE_NESTED_ITEMS || + aggregateTextLength > MAX_SQL_FEATURE_AGGREGATE_TEXT_LENGTH + ) { + throw new Error("SQL code actions exceed the aggregate result budget"); + } + actions.push(Object.freeze({ + diagnostics: normalizedDiagnostics, + edit: normalizedEdit, + kind, + title, + })); + } + return Object.freeze(actions); +} + +export function composeSqlCodeActionResults( + values: readonly (readonly SqlCodeAction[])[], +): { + readonly isIncomplete: boolean; + readonly value: readonly SqlCodeAction[]; +} { + const actions: SqlCodeAction[] = []; + let nestedItems = 0; + let aggregateTextLength = 0; + for (const value of values) { + for (const action of value) { + let actionTextLength = action.title.length; + const actionNestedItems = + (action.diagnostics?.length ?? 0) + + (action.edit?.changes.length ?? 0); + for (const diagnostic of action.diagnostics ?? []) { + actionTextLength += diagnostic.length; + } + for (const change of action.edit?.changes ?? []) { + actionTextLength += change.insert.length; + } + if ( + actions.length === MAX_SQL_FEATURE_RESULTS || + nestedItems + actionNestedItems > MAX_SQL_FEATURE_NESTED_ITEMS || + aggregateTextLength + actionTextLength > + MAX_SQL_FEATURE_AGGREGATE_TEXT_LENGTH + ) { + return Object.freeze({ + isIncomplete: true, + value: Object.freeze(actions), + }); + } + actions.push(action); + nestedItems += actionNestedItems; + aggregateTextLength += actionTextLength; + } + } + return Object.freeze({ + isIncomplete: values.some( + (value) => value.length === MAX_SQL_FEATURE_RESULTS, + ), + value: Object.freeze(actions), + }); +} + +export type SqlFeatureProviderInvocation< + Context extends SqlDocumentContext, + Request, + Value, +> = ( + provider: SqlLanguageFeatureProvider, + document: SqlFeatureDocument, + request: Request, + signal: AbortSignal, +) => PromiseLike | Value | undefined; + +export interface SqlFeatureInvocationResult { + readonly reports: readonly SqlFeatureProviderReport[]; + readonly values: readonly Value[]; +} + +export async function invokeSqlFeatureProviders< + Context extends SqlDocumentContext, + Request, + Value, +>( + providers: readonly CapturedSqlLanguageFeatureProvider[], + document: SqlFeatureDocument, + request: Request, + signal: AbortSignal, + budgetMs: number, + supports: ( + provider: SqlLanguageFeatureProvider, + ) => boolean, + invoke: SqlFeatureProviderInvocation, + normalize: (value: unknown) => Value, +): Promise> { + const timeoutMarker = Symbol("sql-feature-timeout"); + const abortMarker = Symbol("sql-feature-abort"); + const deadline = performance.now() + budgetMs; + const providerControllers = new Set(); + const abortProviders = (): void => { + for (const controller of providerControllers) controller.abort(); + }; + signal.addEventListener("abort", abortProviders, { once: true }); + if (signal.aborted) abortProviders(); + const settled = await Promise.all(providers.map(async (captured) => { + if (signal.aborted) return null; + let supported: boolean; + try { + supported = supports(captured.provider); + } catch { + return Object.freeze({ + report: Object.freeze({ + outcome: "failed" as const, + providerId: captured.id, + }), + }); + } + if (!supported) return null; + if (performance.now() >= deadline) { + return Object.freeze({ + report: Object.freeze({ + outcome: "timed-out" as const, + providerId: captured.id, + }), + }); + } + const providerController = new AbortController(); + providerControllers.add(providerController); + if (signal.aborted) providerController.abort(); + let operation: PromiseLike | unknown | undefined; + try { + operation = invoke( + captured.provider, + document, + request, + providerController.signal, + ); + } catch { + providerControllers.delete(providerController); + return Object.freeze({ + report: Object.freeze({ + outcome: "failed" as const, + providerId: captured.id, + }), + }); + } + if (operation === undefined) { + providerControllers.delete(providerController); + return null; + } + const remainingBudgetMs = deadline - performance.now(); + if (remainingBudgetMs <= 0) { + providerController.abort(); + providerControllers.delete(providerController); + return Object.freeze({ + report: Object.freeze({ + outcome: "timed-out" as const, + providerId: captured.id, + }), + }); + } + let timer: ReturnType | undefined; + let onAbort: (() => void) | undefined; + const timeout = new Promise((resolve) => { + timer = setTimeout(() => resolve(timeoutMarker), remainingBudgetMs); + }); + const aborted = new Promise((resolve) => { + onAbort = () => resolve(abortMarker); + providerController.signal.addEventListener( + "abort", + onAbort, + { once: true }, + ); + if (providerController.signal.aborted) onAbort(); + }); + try { + const value = await Promise.race([ + Promise.resolve(operation), + timeout, + aborted, + ]); + if (value === abortMarker) return null; + if (value === timeoutMarker) { + providerController.abort(); + return Object.freeze({ + report: Object.freeze({ + outcome: "timed-out" as const, + providerId: captured.id, + }), + }); + } + return Object.freeze({ + report: Object.freeze({ + outcome: "ready" as const, + providerId: captured.id, + }), + value: normalize(value), + }); + } catch { + return Object.freeze({ + report: Object.freeze({ + outcome: "failed" as const, + providerId: captured.id, + }), + }); + } finally { + if (timer !== undefined) clearTimeout(timer); + if (onAbort !== undefined) { + providerController.signal.removeEventListener("abort", onAbort); + } + providerControllers.delete(providerController); + } + })); + signal.removeEventListener("abort", abortProviders); + providerControllers.clear(); + const reports: SqlFeatureProviderReport[] = []; + const values: Value[] = []; + for (const result of settled) { + if (result === null) continue; + reports.push(result.report); + if ("value" in result) values.push(result.value); + } + return Object.freeze({ + reports: Object.freeze(reports), + values: Object.freeze(values), + }); +} diff --git a/src/language-features.ts b/src/language-features.ts new file mode 100644 index 0000000..8fade6f --- /dev/null +++ b/src/language-features.ts @@ -0,0 +1,217 @@ +import type { + SqlDocumentContext, + SqlEmbeddedRegion, + SqlRevision, + SqlTextChange, + SqlTextRange, +} from "./types.js"; + +export const MAX_SQL_FEATURE_RESULTS = 1_000; +export const MAX_SQL_FEATURE_TEXT_LENGTH = 8_192; + +export type SqlDiagnosticSeverity = + | "error" + | "warning" + | "information" + | "hint"; + +export interface SqlDiagnostic extends SqlTextRange { + readonly code?: string | undefined; + readonly message: string; + readonly severity: SqlDiagnosticSeverity; + readonly source: string; +} + +export interface SqlMarkupContent { + readonly kind: "plaintext" | "markdown"; + readonly value: string; +} + +export interface SqlHover { + readonly contents: SqlMarkupContent; + readonly range: SqlTextRange; +} + +export interface SqlLocation { + readonly range: SqlTextRange; + readonly uri?: string | undefined; +} + +export type SqlDocumentSymbolKind = + | "statement" + | "relation" + | "column" + | "function" + | "parameter"; + +export interface SqlDocumentSymbol { + readonly detail?: string | undefined; + readonly kind: SqlDocumentSymbolKind; + readonly name: string; + readonly range: SqlTextRange; + readonly selectionRange: SqlTextRange; +} + +export interface SqlFoldingRange extends SqlTextRange { + readonly kind?: "statement" | "region" | "comment" | undefined; +} + +export interface SqlDocumentEditResult { + readonly changes: readonly SqlTextChange[]; +} + +export interface SqlCodeAction { + readonly diagnostics?: readonly string[] | undefined; + readonly edit?: SqlDocumentEditResult | undefined; + readonly kind?: "quickfix" | "refactor" | "source" | undefined; + readonly title: string; +} + +export interface SqlPositionFeatureRequest { + readonly position: number; + readonly signal?: AbortSignal | undefined; +} + +export interface SqlRangeFeatureRequest { + readonly range: SqlTextRange; + readonly signal?: AbortSignal | undefined; +} + +export interface SqlDiagnosticsRequest { + readonly range?: SqlTextRange | undefined; + readonly signal?: AbortSignal | undefined; +} + +export interface SqlRenameRequest extends SqlPositionFeatureRequest { + readonly newName: string; +} + +export interface SqlFormatRequest { + readonly range?: SqlTextRange | undefined; + readonly signal?: AbortSignal | undefined; + readonly tabSize?: number | undefined; + readonly useTabs?: boolean | undefined; +} + +export interface SqlFeatureDocument< + Context extends SqlDocumentContext = SqlDocumentContext, +> { + readonly context: Context; + readonly dialect: string; + readonly embeddedRegions: readonly SqlEmbeddedRegion[]; + readonly text: string; +} + +export interface SqlFeatureProviderRequest< + Request, + Context extends SqlDocumentContext = SqlDocumentContext, +> { + readonly document: SqlFeatureDocument; + readonly request: Omit; + readonly signal: AbortSignal; +} + +export interface SqlLanguageFeatureProvider< + Context extends SqlDocumentContext = SqlDocumentContext, +> { + readonly id: string; + readonly codeActions?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlCodeAction[]; + readonly definitions?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlLocation[]; + readonly diagnostics?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlDiagnostic[]; + readonly documentSymbols?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlDocumentSymbol[]; + readonly foldingRanges?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlFoldingRange[]; + readonly format?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | SqlDocumentEditResult | null; + readonly highlights?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlTextRange[]; + readonly hover?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | SqlHover | null; + readonly references?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | readonly SqlLocation[]; + readonly rename?: ( + input: SqlFeatureProviderRequest, + ) => PromiseLike | SqlDocumentEditResult | null; +} + +export interface SqlFeatureProviderReport { + readonly outcome: "ready" | "failed" | "timed-out"; + readonly providerId: string; +} + +export interface SqlFeatureReady { + readonly isIncomplete: boolean; + readonly revision: SqlRevision; + readonly sources: readonly SqlFeatureProviderReport[]; + readonly status: "ready"; + readonly value: Value; +} + +export interface SqlFeatureCancelled { + readonly reason: "caller" | "disposed" | "superseded"; + readonly revision: SqlRevision; + readonly status: "cancelled"; +} + +export interface SqlFeatureUnavailable { + readonly reason: "no-provider" | "no-result"; + readonly revision: SqlRevision; + readonly sources: readonly SqlFeatureProviderReport[]; + readonly status: "unavailable"; +} + +export type SqlFeatureResult = + | SqlFeatureReady + | SqlFeatureCancelled + | SqlFeatureUnavailable; + +export interface SqlFeatureTask { + readonly cancel: () => void; + readonly result: Promise>; +} + +export interface SqlLanguageFeatureMethods { + readonly codeActions: ( + request: SqlRangeFeatureRequest, + ) => SqlFeatureTask; + readonly definitions: ( + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask; + readonly diagnostics: ( + request?: SqlDiagnosticsRequest, + ) => SqlFeatureTask; + readonly documentSymbols: () => SqlFeatureTask< + readonly SqlDocumentSymbol[] + >; + readonly foldingRanges: () => SqlFeatureTask< + readonly SqlFoldingRange[] + >; + readonly format: ( + request?: SqlFormatRequest, + ) => SqlFeatureTask; + readonly highlights: ( + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask; + readonly hover: ( + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask; + readonly references: ( + request: SqlPositionFeatureRequest, + ) => SqlFeatureTask; + readonly rename: ( + request: SqlRenameRequest, + ) => SqlFeatureTask; +} diff --git a/src/relation-catalog-boundary.ts b/src/relation-catalog-boundary.ts index 8149513..31b6983 100644 --- a/src/relation-catalog-boundary.ts +++ b/src/relation-catalog-boundary.ts @@ -189,6 +189,7 @@ const RELATION_KINDS: ReadonlySet = new Set([ "external-relation", "materialized-view", "table", + "table-function", "temporary-table", "view", ]); diff --git a/src/relation-completion-types.ts b/src/relation-completion-types.ts index 03e5448..62ba3dc 100644 --- a/src/relation-completion-types.ts +++ b/src/relation-completion-types.ts @@ -44,6 +44,7 @@ export interface SqlCatalogEpoch { export type SqlCatalogRelationKind = | "temporary-table" | "table" + | "table-function" | "view" | "materialized-view" | "external-relation"; diff --git a/src/relation-completion.ts b/src/relation-completion.ts index 87ac3cb..1b34d89 100644 --- a/src/relation-completion.ts +++ b/src/relation-completion.ts @@ -83,9 +83,10 @@ interface RankedCatalogItem { const CATALOG_KIND_ORDER = Object.freeze({ "temporary-table": 0, table: 1, - view: 2, - "materialized-view": 3, - "external-relation": 4, + "table-function": 2, + view: 3, + "materialized-view": 4, + "external-relation": 5, } as const); const ISSUE_ORDER = Object.freeze([ diff --git a/src/session.ts b/src/session.ts index bd25033..4ad200d 100644 --- a/src/session.ts +++ b/src/session.ts @@ -3,6 +3,7 @@ import type { SqlDocumentContext, SqlDocumentSession, SqlDocumentUpdate, + SqlEmbeddedRegion, SqlLanguageService, SqlLanguageServiceOptions, SqlRevision, @@ -114,6 +115,42 @@ import type { SqlStatementBoundaryAtResult, SqlStatementLexicalEnd, } from "./statement-boundary-types.js"; +import { + captureSqlLanguageFeatureProviders, + composeSqlCodeActionResults, + createSqlFeatureDocument, + invokeSqlFeatureProviders, + normalizeSqlCodeActions, + normalizeSqlDiagnostics, + normalizeSqlDocumentEdit, + normalizeSqlDocumentSymbols, + normalizeSqlFoldingRanges, + normalizeSqlHover, + normalizeSqlLocations, + normalizeSqlRanges, + type CapturedSqlLanguageFeatureProvider, + type SqlFeatureProviderInvocation, +} from "./language-feature-runtime.js"; +import { + MAX_SQL_FEATURE_RESULTS, + type SqlCodeAction, + type SqlDiagnostic, + type SqlDiagnosticsRequest, + type SqlDocumentEditResult, + type SqlDocumentSymbol, + type SqlFeatureCancelled, + type SqlFeatureProviderRequest, + type SqlFeatureResult, + type SqlFeatureTask, + type SqlFoldingRange, + type SqlFormatRequest, + type SqlHover, + type SqlLanguageFeatureProvider, + type SqlLocation, + type SqlPositionFeatureRequest, + type SqlRangeFeatureRequest, + type SqlRenameRequest, +} from "./language-features.js"; const MAX_CONTEXT_DEPTH = 100; const MAX_CONTEXT_NODES = 10_000; @@ -123,6 +160,8 @@ const MAX_CONTEXT_STRING_LENGTH = 1_000_000; const MAX_CONTEXT_ARRAY_LENGTH = 50_000; const MAX_CHANGES_PER_UPDATE = 10_000; const MAX_DIALECTS = 1_000; +const DEFAULT_FEATURE_PROVIDER_BUDGET_MS = 150; +const MAX_FEATURE_PROVIDER_BUDGET_MS = 5_000; const DEFAULT_CATALOG_RESPONSE_BUDGET_MS = 40; const MAX_CATALOG_RESPONSE_BUDGET_MS = 50; const TERMINAL_LOADING_INTENT_LEASE_MS = 1_000; @@ -178,6 +217,12 @@ interface CompletionConfiguration { readonly catalogResponseBudgetMs: number; } +interface ActiveFeatureRequest { + cancelReason: SqlFeatureCancelled["reason"] | null; + readonly controller: AbortController; + readonly revision: SqlRevision; +} + interface SqlDialectRuntime { readonly dialect: SqlDialect; readonly lexicalProfile: SqlLexicalProfile; @@ -1121,6 +1166,245 @@ function statementBoundary( }); } +function featureRequestObject(value: unknown, subject: string): object { + if (value === null || typeof value !== "object") { + throw new SqlSessionError( + "invalid-feature-request", + `${subject} must be an object`, + ); + } + return value; +} + +function featureSignal( + request: object, + subject: string, +): AbortSignal | undefined { + const candidate = readOwnDataProperty( + request, + "signal", + "invalid-feature-request", + subject, + ); + if (!candidate.found || candidate.value === undefined) return undefined; + if (!(candidate.value instanceof AbortSignal)) { + throw new SqlSessionError( + "invalid-feature-request", + `${subject} signal must be an AbortSignal`, + ); + } + return candidate.value; +} + +function positionFeatureRequest( + value: unknown, + length: number, + subject: string, +): SqlPositionFeatureRequest { + try { + const request = featureRequestObject(value, subject); + const position = readRequiredDataProperty( + request, + "position", + "invalid-feature-request", + subject, + ); + if ( + !Number.isSafeInteger(position) || + Number(position) < 0 || + Number(position) > length + ) { + throw new SqlSessionError( + "invalid-feature-request", + `${subject} position must be in bounds`, + ); + } + return Object.freeze({ + position: Number(position), + signal: featureSignal(request, subject), + }); + } catch (error) { + if (error instanceof SqlSessionError) throw error; + throw new SqlSessionError( + "invalid-feature-request", + `${subject} could not be inspected safely`, + ); + } +} + +function rangeFeatureRequest( + value: unknown, + length: number, + subject: string, +): SqlRangeFeatureRequest { + try { + const request = featureRequestObject(value, subject); + const candidate = readRequiredDataProperty( + request, + "range", + "invalid-feature-request", + subject, + ); + return Object.freeze({ + range: normalizeSqlTextRange(candidate, length, `${subject} range`), + signal: featureSignal(request, subject), + }); + } catch (error) { + if (error instanceof SqlSessionError) throw error; + throw new SqlSessionError( + "invalid-feature-request", + `${subject} could not be inspected safely`, + ); + } +} + +function optionalRangeFeatureRequest( + value: unknown, + length: number, + subject: string, +): SqlDiagnosticsRequest { + try { + const request = value === undefined + ? Object.freeze({}) + : featureRequestObject(value, subject); + const candidate = readOwnDataProperty( + request, + "range", + "invalid-feature-request", + subject, + ); + return Object.freeze({ + range: !candidate.found || candidate.value === undefined + ? undefined + : normalizeSqlTextRange(candidate.value, length, `${subject} range`), + signal: featureSignal(request, subject), + }); + } catch (error) { + if (error instanceof SqlSessionError) throw error; + throw new SqlSessionError( + "invalid-feature-request", + `${subject} could not be inspected safely`, + ); + } +} + +interface SqlFeatureComposition { + readonly isIncomplete: boolean; + readonly value: Value | null; +} + +function composeFeatureArrays( + values: readonly (readonly Value[])[], +): SqlFeatureComposition { + const merged: Value[] = []; + let isIncomplete = false; + for (const value of values) { + if (value.length === MAX_SQL_FEATURE_RESULTS) isIncomplete = true; + for (const item of value) { + if (merged.length === MAX_SQL_FEATURE_RESULTS) { + return Object.freeze({ + isIncomplete: true, + value: Object.freeze(merged), + }); + } + merged.push(item); + } + } + return Object.freeze({ + isIncomplete, + value: Object.freeze(merged), + }); +} + +function createLocalStructureProvider< + Context extends SqlDocumentContext, +>( + dialects: ReadonlyMap, +): SqlLanguageFeatureProvider { + const analyze = ( + text: string, + embeddedRegions: readonly SqlEmbeddedRegion[], + dialectId: string, + ): { + readonly source: SqlSourceSnapshot; + readonly index: SqlStatementIndex; + } => { + const dialect = dialects.get(dialectId); + if (!dialect) throw new Error("Unknown SQL dialect"); + const source = embeddedRegions.length === 0 + ? createIdentitySqlSource(text) + : createMaskedSqlSource(text, embeddedRegions); + return Object.freeze({ + index: buildSqlStatementIndex( + source.analysisText, + dialect.lexicalProfile, + ), + source, + }); + }; + return Object.freeze({ + id: "@marimo/local-structure", + documentSymbols: ({ + document, + }: SqlFeatureProviderRequest) => { + const { index, source } = analyze( + document.text, + document.embeddedRegions, + document.dialect, + ); + const symbols: SqlDocumentSymbol[] = []; + for (const slot of index.slots) { + if (slot.boundaryQuality === "opaque") continue; + if (slot.code === null) continue; + const code = statementRange(source, slot.code); + const extent = statementRange(source, slot.extent); + const codeText = document.text.slice(code.from, code.to); + const keyword = /^[\s]*(?[A-Za-z]+)/u.exec(codeText) + ?.groups?.keyword; + const selectionRange = keyword + ? Object.freeze({ + from: code.from + codeText.indexOf(keyword), + to: code.from + codeText.indexOf(keyword) + keyword.length, + }) + : Object.freeze({ from: code.from, to: code.from }); + symbols.push(Object.freeze({ + detail: slot.boundaryQuality, + kind: "statement", + name: keyword + ? `${keyword.toUpperCase()} statement` + : "SQL statement", + range: extent, + selectionRange, + })); + if (symbols.length === MAX_SQL_FEATURE_RESULTS) break; + } + return Object.freeze(symbols); + }, + foldingRanges: ({ + document, + }: SqlFeatureProviderRequest) => { + const { index, source } = analyze( + document.text, + document.embeddedRegions, + document.dialect, + ); + const ranges: SqlFoldingRange[] = []; + for (const slot of index.slots) { + if (slot.boundaryQuality === "opaque") continue; + if (slot.code === null) continue; + const code = statementRange(source, slot.code); + if (!document.text.slice(code.from, code.to).includes("\n")) continue; + ranges.push(Object.freeze({ + ...code, + kind: "statement", + })); + if (ranges.length === MAX_SQL_FEATURE_RESULTS) break; + } + return Object.freeze(ranges); + }, + }); +} + export class DefaultSqlDocumentSession implements SqlDocumentSession { @@ -1129,7 +1413,11 @@ export class DefaultSqlDocumentSession readonly #columnCoordinator: SqlColumnCatalogBatchCoordinator | null; readonly #namespaceCoordinator: SqlNamespaceCatalogCoordinator | null; readonly #dialects: ReadonlyMap; + readonly #featureProviderBudgetMs: number; + readonly #featureProviders: + readonly CapturedSqlLanguageFeatureProvider[]; readonly #onDispose: () => void; + readonly #activeFeatures = new Set(); readonly #listeners = new Set(); #activeCompletion: CompletionRequestState | null = null; #columnLoadingRetry: AuxiliaryLoadingRetry | null = null; @@ -1162,6 +1450,9 @@ export class DefaultSqlDocumentSession columnCoordinator: SqlColumnCatalogBatchCoordinator | null, namespaceCoordinator: SqlNamespaceCatalogCoordinator | null, completion: CompletionConfiguration, + featureProviders: + readonly CapturedSqlLanguageFeatureProvider[], + featureProviderBudgetMs: number, onDispose: () => void, ) { this.#catalogCoordinator = catalogCoordinator; @@ -1169,6 +1460,8 @@ export class DefaultSqlDocumentSession this.#namespaceCoordinator = namespaceCoordinator; this.#catalogResponseBudgetMs = completion.catalogResponseBudgetMs; + this.#featureProviders = featureProviders; + this.#featureProviderBudgetMs = featureProviderBudgetMs; this.#dialects = dialects; this.#onDispose = onDispose; const sequence = 0; @@ -1234,6 +1527,14 @@ export class DefaultSqlDocumentSession return this.#getStatementIndex(); } + #supersedeFeatures(): void { + for (const feature of this.#activeFeatures) { + if (feature.cancelReason !== null) continue; + feature.cancelReason = "superseded"; + feature.controller.abort(); + } + } + readonly statementBoundaryAt = ( input: SqlStatementBoundaryAtRequest, ): SqlStatementBoundaryAtResult => { @@ -1435,6 +1736,7 @@ export class DefaultSqlDocumentSession ? activeIntent.token : null; const previous = this.#snapshot; + this.#supersedeFeatures(); const revision = createSqlRevisionToken(); this.#snapshot = Object.freeze({ ...previous, @@ -2728,6 +3030,7 @@ export class DefaultSqlDocumentSession nextSourceSequence !== this.#snapshot.sourceSequence || nextDialect.relationDialect !== this.#snapshot.dialect.relationDialect; + this.#supersedeFeatures(); this.#snapshot = nextSnapshot; this.#statementIndexCache = nextStatementIndexCache; if (invalidatesLocalRelationCache) { @@ -2766,6 +3069,393 @@ export class DefaultSqlDocumentSession return !this.#disposed && revision === this.#snapshot.revision; }; + #featureTask( + request: Request, + callerSignal: AbortSignal | undefined, + supports: ( + provider: SqlLanguageFeatureProvider, + ) => boolean, + invoke: SqlFeatureProviderInvocation, + normalize: (value: unknown, length: number) => ProviderValue, + compose: ( + values: readonly ProviderValue[], + ) => SqlFeatureComposition, + ): SqlFeatureTask { + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + const snapshot = this.#snapshot; + const active: ActiveFeatureRequest = { + cancelReason: null, + controller: new AbortController(), + revision: snapshot.revision, + }; + this.#activeFeatures.add(active); + const cancel = (): void => { + if (active.cancelReason !== null) return; + active.cancelReason = "caller"; + active.controller.abort(); + }; + const onCallerAbort = (): void => cancel(); + if (callerSignal?.aborted) { + cancel(); + } else { + callerSignal?.addEventListener("abort", onCallerAbort, { once: true }); + } + const result = (async (): Promise> => { + try { + await Promise.resolve(); + if (active.cancelReason !== null) { + return Object.freeze({ + reason: active.cancelReason, + revision: snapshot.revision, + status: "cancelled", + }); + } + const document = createSqlFeatureDocument( + snapshot.source.originalText, + snapshot.context, + snapshot.source.embeddedRegions, + ); + const invocation = await invokeSqlFeatureProviders( + this.#featureProviders, + document, + request, + active.controller.signal, + this.#featureProviderBudgetMs, + supports, + invoke, + (value) => normalize(value, snapshot.source.originalText.length), + ); + if ( + active.cancelReason !== null || + this.#disposed || + this.#snapshot.revision !== snapshot.revision + ) { + const reason = active.cancelReason ?? + (this.#disposed ? "disposed" : "superseded"); + return Object.freeze({ + reason, + revision: snapshot.revision, + status: "cancelled", + }); + } + if (invocation.values.length === 0) { + return Object.freeze({ + reason: invocation.reports.length === 0 + ? "no-provider" + : "no-result", + revision: snapshot.revision, + sources: invocation.reports, + status: "unavailable", + }); + } + const composition = compose(invocation.values); + if (composition.value === null) { + return Object.freeze({ + reason: "no-result", + revision: snapshot.revision, + sources: invocation.reports, + status: "unavailable", + }); + } + return Object.freeze({ + isIncomplete: composition.isIncomplete, + revision: snapshot.revision, + sources: invocation.reports, + status: "ready", + value: composition.value, + }); + } finally { + callerSignal?.removeEventListener("abort", onCallerAbort); + this.#activeFeatures.delete(active); + } + })(); + return Object.freeze({ cancel, result }); + } + + readonly diagnostics = ( + input?: SqlDiagnosticsRequest, + ): SqlFeatureTask => { + const request = optionalRangeFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL diagnostics request", + ); + return this.#featureTask( + request, + request.signal, + (provider) => provider.diagnostics !== undefined, + (provider, document, current, signal) => + provider.diagnostics?.({ + document, + request: Object.freeze({ range: current.range }), + signal, + }), + normalizeSqlDiagnostics, + composeFeatureArrays, + ); + }; + + readonly hover = ( + input: SqlPositionFeatureRequest, + ): SqlFeatureTask => { + const request = positionFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL hover request", + ); + return this.#featureTask( + request, + request.signal, + (provider) => provider.hover !== undefined, + (provider, document, current, signal) => + provider.hover?.({ + document, + request: Object.freeze({ position: current.position }), + signal, + }), + normalizeSqlHover, + (values) => Object.freeze({ + isIncomplete: false, + value: values.find((value) => value !== null) ?? null, + }), + ); + }; + + readonly definitions = ( + input: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#locationFeature(input, "definitions", "SQL definition request"); + + readonly references = ( + input: SqlPositionFeatureRequest, + ): SqlFeatureTask => + this.#locationFeature(input, "references", "SQL references request"); + + #locationFeature( + input: SqlPositionFeatureRequest, + kind: "definitions" | "references", + subject: string, + ): SqlFeatureTask { + const request = positionFeatureRequest( + input, + this.#snapshot.source.originalText.length, + subject, + ); + return this.#featureTask( + request, + request.signal, + (provider) => ( + kind === "definitions" + ? provider.definitions !== undefined + : provider.references !== undefined + ), + (provider, document, current, signal) => { + const method = kind === "definitions" + ? provider.definitions + : provider.references; + return method?.({ + document, + request: Object.freeze({ position: current.position }), + signal, + }); + }, + normalizeSqlLocations, + composeFeatureArrays, + ); + } + + readonly highlights = ( + input: SqlPositionFeatureRequest, + ): SqlFeatureTask => { + const request = positionFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL highlights request", + ); + return this.#featureTask( + request, + request.signal, + (provider) => provider.highlights !== undefined, + (provider, document, current, signal) => + provider.highlights?.({ + document, + request: Object.freeze({ position: current.position }), + signal, + }), + normalizeSqlRanges, + composeFeatureArrays, + ); + }; + + readonly documentSymbols = (): SqlFeatureTask< + readonly SqlDocumentSymbol[] + > => this.#featureTask( + Object.freeze({}), + undefined, + (provider) => provider.documentSymbols !== undefined, + (provider, document, request, signal) => + provider.documentSymbols?.({ document, request, signal }), + normalizeSqlDocumentSymbols, + composeFeatureArrays, + ); + + readonly foldingRanges = (): SqlFeatureTask< + readonly SqlFoldingRange[] + > => this.#featureTask( + Object.freeze({}), + undefined, + (provider) => provider.foldingRanges !== undefined, + (provider, document, request, signal) => + provider.foldingRanges?.({ document, request, signal }), + normalizeSqlFoldingRanges, + composeFeatureArrays, + ); + + readonly rename = ( + input: SqlRenameRequest, + ): SqlFeatureTask => { + const position = positionFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL rename request", + ); + const candidate = featureRequestObject(input, "SQL rename request"); + const newName = readRequiredDataProperty( + candidate, + "newName", + "invalid-feature-request", + "SQL rename request", + ); + if ( + typeof newName !== "string" || + newName.length === 0 || + newName.length > 1_024 + ) { + throw new SqlSessionError( + "invalid-feature-request", + "SQL rename name must be a bounded non-empty string", + ); + } + const request: SqlRenameRequest = Object.freeze({ + ...position, + newName, + }); + return this.#featureTask( + request, + request.signal, + (provider) => provider.rename !== undefined, + (provider, document, current, signal) => + provider.rename?.({ + document, + request: Object.freeze({ + newName: current.newName, + position: current.position, + }), + signal, + }), + normalizeSqlDocumentEdit, + (values) => Object.freeze({ + isIncomplete: false, + value: values.find((value) => value !== null) ?? null, + }), + ); + }; + + readonly format = ( + input?: SqlFormatRequest, + ): SqlFeatureTask => { + const base = optionalRangeFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL format request", + ); + const candidate = input === undefined + ? Object.freeze({}) + : featureRequestObject(input, "SQL format request"); + const tabSizeValue = readOwnDataProperty( + candidate, + "tabSize", + "invalid-feature-request", + "SQL format request", + ); + const useTabsValue = readOwnDataProperty( + candidate, + "useTabs", + "invalid-feature-request", + "SQL format request", + ); + const tabSize = tabSizeValue.found ? tabSizeValue.value : undefined; + const useTabs = useTabsValue.found ? useTabsValue.value : undefined; + if ( + tabSize !== undefined && + (!Number.isSafeInteger(tabSize) || Number(tabSize) < 1 || Number(tabSize) > 16) + ) { + throw new SqlSessionError( + "invalid-feature-request", + "SQL format tab size must be an integer from 1 through 16", + ); + } + if (useTabs !== undefined && typeof useTabs !== "boolean") { + throw new SqlSessionError( + "invalid-feature-request", + "SQL format useTabs must be a boolean", + ); + } + const request: SqlFormatRequest = Object.freeze({ + ...base, + tabSize: tabSize === undefined ? undefined : Number(tabSize), + useTabs, + }); + return this.#featureTask( + request, + request.signal, + (provider) => provider.format !== undefined, + (provider, document, current, signal) => + provider.format?.({ + document, + request: Object.freeze({ + range: current.range, + tabSize: current.tabSize, + useTabs: current.useTabs, + }), + signal, + }), + normalizeSqlDocumentEdit, + (values) => Object.freeze({ + isIncomplete: false, + value: values.find((value) => value !== null) ?? null, + }), + ); + }; + + readonly codeActions = ( + input: SqlRangeFeatureRequest, + ): SqlFeatureTask => { + const request = rangeFeatureRequest( + input, + this.#snapshot.source.originalText.length, + "SQL code actions request", + ); + return this.#featureTask( + request, + request.signal, + (provider) => provider.codeActions !== undefined, + (provider, document, current, signal) => + provider.codeActions?.({ + document, + request: Object.freeze({ range: current.range }), + signal, + }), + normalizeSqlCodeActions, + composeSqlCodeActionResults, + ); + }; + readonly dispose = (): void => { if (this.#disposed) { return; @@ -2786,6 +3476,11 @@ export class DefaultSqlDocumentSession this.#clearSoftRefreshIntentTimer(); this.#clearTerminalIntent(); this.#listeners.clear(); + for (const feature of this.#activeFeatures) { + feature.cancelReason = "disposed"; + feature.controller.abort(); + } + this.#activeFeatures.clear(); this.#localRelationStatementCache = null; this.#statementIndexCache = null; if ( @@ -2820,10 +3515,13 @@ export class DefaultSqlLanguageService readonly #namespaceCoordinator: SqlNamespaceCatalogCoordinator | null; readonly #completion: CompletionConfiguration; readonly #dialects: ReadonlyMap; + readonly #featureProviderBudgetMs: number; + readonly #featureProviders: + readonly CapturedSqlLanguageFeatureProvider[]; readonly #sessions = new Set>(); #disposed = false; - constructor(options: SqlLanguageServiceOptions) { + constructor(options: SqlLanguageServiceOptions) { try { if (options === null || typeof options !== "object") { throw new SqlSessionError( @@ -2923,6 +3621,62 @@ export class DefaultSqlLanguageService catalogResponseBudgetMs, }); + const featureProviders = readOwnDataProperty( + options, + "featureProviders", + "invalid-service-options", + "SQL language service options", + ); + const configuredFeatureProviders = + captureSqlLanguageFeatureProviders( + featureProviders.found ? featureProviders.value : undefined, + ); + const localStructureProviders = + captureSqlLanguageFeatureProviders( + Object.freeze([createLocalStructureProvider(this.#dialects)]), + ); + const localProviderId = localStructureProviders[0]?.id; + if ( + localProviderId !== undefined && + configuredFeatureProviders.some( + (provider) => provider.id === localProviderId, + ) + ) { + throw new SqlSessionError( + "invalid-service-options", + `SQL feature provider ID ${localProviderId} is reserved`, + ); + } + this.#featureProviders = Object.freeze([ + ...localStructureProviders, + ...configuredFeatureProviders, + ]); + const featureBudget = readOwnDataProperty( + options, + "featureProviderBudgetMs", + "invalid-service-options", + "SQL language service options", + ); + if ( + featureBudget.found && + featureBudget.value !== undefined && + ( + typeof featureBudget.value !== "number" || + !Number.isFinite(featureBudget.value) || + featureBudget.value < 0 || + featureBudget.value > MAX_FEATURE_PROVIDER_BUDGET_MS + ) + ) { + throw new SqlSessionError( + "invalid-service-options", + `Feature provider budget must be between 0 and ${MAX_FEATURE_PROVIDER_BUDGET_MS} milliseconds`, + ); + } + this.#featureProviderBudgetMs = + featureBudget.found && featureBudget.value !== undefined + ? featureBudget.value + : DEFAULT_FEATURE_PROVIDER_BUDGET_MS; + const catalog = readOwnDataProperty( options, "catalog", @@ -3076,6 +3830,8 @@ export class DefaultSqlLanguageService this.#columnCoordinator, this.#namespaceCoordinator, this.#completion, + this.#featureProviders, + this.#featureProviderBudgetMs, () => { this.#sessions.delete(session); }, @@ -3124,6 +3880,8 @@ export class DefaultSqlLanguageService /** Creates a framework-independent SQL service with an immutable dialect registry. */ export function createSqlLanguageService< Context extends SqlDocumentContext = SqlDocumentContext, ->(options: SqlLanguageServiceOptions): SqlLanguageService { +>( + options: SqlLanguageServiceOptions, +): SqlLanguageService { return new DefaultSqlLanguageService(options); } diff --git a/src/types.ts b/src/types.ts index a0582b4..9422784 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,3 +1,8 @@ +import type { + SqlLanguageFeatureMethods, + SqlLanguageFeatureProvider, +} from "./language-features.js"; + const revisionBrand: unique symbol = Symbol("SqlRevision"); export function isDataArray( @@ -143,7 +148,9 @@ export interface OpenSqlDocument { } /** Owns all mutable state for one open SQL document. */ -export interface SqlDocumentSession { +export interface SqlDocumentSession + extends SqlLanguageFeatureMethods +{ readonly revision: SqlRevision; /** Invalidates relation, column, and namespace catalog observations. */ readonly invalidateCatalog: () => SqlRevision; @@ -172,13 +179,19 @@ export interface SqlLanguageService { readonly dispose: () => void; } -export interface SqlLanguageServiceOptions { +export interface SqlLanguageServiceOptions< + Context extends SqlDocumentContext = SqlDocumentContext, +> { readonly catalog?: SqlRelationCatalogProvider | undefined; readonly columns?: SqlColumnCatalogProvider | undefined; readonly completion?: { readonly catalogResponseBudgetMs?: number | undefined; } | undefined; readonly dialects: readonly SqlDialect[]; + readonly featureProviderBudgetMs?: number | undefined; + readonly featureProviders?: + | readonly SqlLanguageFeatureProvider[] + | undefined; readonly namespaces?: SqlNamespaceCatalogProvider | undefined; } @@ -189,6 +202,7 @@ export type SqlSessionErrorCode = | "invalid-completion-request" | "invalid-dialect" | "invalid-document" + | "invalid-feature-request" | "invalid-service-options" | "invalid-statement-boundary-request" | "invalid-update" diff --git a/test/types/language-features.test-d.ts b/test/types/language-features.test-d.ts new file mode 100644 index 0000000..f880cf9 --- /dev/null +++ b/test/types/language-features.test-d.ts @@ -0,0 +1,62 @@ +import { + createSqlLanguageService, + duckdbDialect, + type SqlDiagnostic, + type SqlDocumentContext, + type SqlLanguageFeatureProvider, +} from "../../src/index.js"; + +interface HostContext extends SqlDocumentContext { + readonly connection: string; +} + +const provider: SqlLanguageFeatureProvider = { + id: "host", + diagnostics: ({ document, request, signal }) => { + const connection: string = document.context.connection; + const dialect: string = document.dialect; + const range = request.range; + const aborted: boolean = signal.aborted; + void connection; + void dialect; + void range; + void aborted; + return [{ + from: 0, + message: "message", + severity: "warning", + source: "host", + to: 1, + }]; + }, + rename: ({ request }) => ({ + changes: [{ + from: request.position, + insert: request.newName, + to: request.position, + }], + }), +}; + +const service = createSqlLanguageService({ + dialects: [duckdbDialect()], + featureProviders: [provider], +}); +const session = service.openDocument({ + context: { + connection: "local", + dialect: "duckdb", + }, + text: "select 1", +}); + +const diagnostics = await session.diagnostics().result; +if (diagnostics.status === "ready") { + const item: SqlDiagnostic | undefined = diagnostics.value[0]; + void item; +} + +const hoverTask = session.hover({ position: 1 }); +hoverTask.cancel(); +const renameTask = session.rename({ newName: "answer", position: 1 }); +void renameTask.result; diff --git a/test/worker-placement/README.md b/test/worker-placement/README.md index d7076e7..33c7143 100644 --- a/test/worker-placement/README.md +++ b/test/worker-placement/README.md @@ -9,23 +9,23 @@ package's dependency. The fixture workspace pins Vite's floating transitive versions to the exact versions in the root lock, so the nested frozen install can run offline after a clean root CI install. -The minified Vite 8 packed-consumer baseline is 53,612 gzip/199,957 raw +The minified Vite 8 packed-consumer baseline is 57,307 gzip/215,639 raw bytes for the complete parser-free core, 67,573 gzip bytes for the PostgreSQL transitive graph, 50,470 gzip bytes for the BigQuery transitive graph, and -164,513 gzip/725,798 raw bytes for the complete worker build output. The core +168,218 gzip/741,480 raw bytes for the complete worker build output. The core measurement includes the four authenticated relation-dialect runtimes, their -reserved-word tables, and relation-completion/session orchestration. The +reserved-word tables, completion/session orchestration, and the validated +language-feature provider runtime. The PostgreSQL and BigQuery figures each include their transitive shared chunks; the report also identifies those shared chunks explicitly. -The fail-closed ceilings retain approximately 2โ€“3% headroom for the core and -complete worker output, plus -the existing tight dialect-graph headroom: +The fail-closed ceilings retain explicit tight headroom for the measured +graphs: -- Complete parser-free core: 54 KiB gzip and 200 KiB raw +- Complete parser-free core: 57 KiB gzip and 212 KiB raw - PostgreSQL transitive graph: 68 KiB gzip - BigQuery transitive graph: 50 KiB gzip -- Complete worker build output: 164 KiB gzip and 720 KiB raw +- Complete worker build output: 165 KiB gzip and 725 KiB raw These are provisional placement limits, not product bundle promises. The orchestration script fails closed when they are exceeded, when the dialects no diff --git a/vitest.browser.config.ts b/vitest.browser.config.ts index 9e3503f..09e6286 100644 --- a/vitest.browser.config.ts +++ b/vitest.browser.config.ts @@ -1,6 +1,15 @@ import { playwright } from "@vitest/browser-playwright"; import { defineConfig } from "vitest/config"; +const configuredBrowser = process.env.VITEST_BROWSER ?? "chromium"; +if ( + configuredBrowser !== "chromium" && + configuredBrowser !== "firefox" && + configuredBrowser !== "webkit" +) { + throw new Error(`Unsupported browser: ${configuredBrowser}`); +} + export default defineConfig({ optimizeDeps: { include: [ @@ -14,7 +23,7 @@ export default defineConfig({ browser: { enabled: true, headless: true, - instances: [{ browser: "chromium" }], + instances: [{ browser: configuredBrowser }], provider: playwright(), ui: false, }, From 91f6bd1538e370bb1588a5674620c9b205860177 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Mon, 27 Jul 2026 13:28:07 +0800 Subject: [PATCH 2/3] docs: link final overhaul pull request --- implementation.md | 1 + 1 file changed, 1 insertion(+) diff --git a/implementation.md b/implementation.md index bb59caa..f88938b 100644 --- a/implementation.md +++ b/implementation.md @@ -33,6 +33,7 @@ columns. ## PR 2: language intelligence and release hardening Status: complete +Delivery: PR #206 The final PR adds the remaining feature methods and provider composition: From e5bee955cef631759bcd6864daaa638ca57e2d58 Mon Sep 17 00:00:00 2001 From: Shahmir Varqha Date: Mon, 27 Jul 2026 19:02:00 +0800 Subject: [PATCH 3/3] docs: remove ADR trailing whitespace --- docs/adr/0007-language-feature-provider-composition.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/adr/0007-language-feature-provider-composition.md b/docs/adr/0007-language-feature-provider-composition.md index b2fdfe5..2b2a20d 100644 --- a/docs/adr/0007-language-feature-provider-composition.md +++ b/docs/adr/0007-language-feature-provider-composition.md @@ -1,6 +1,6 @@ # ADR 0007: Bounded language-feature provider composition -Status: accepted +Status: accepted Date: 2026-07-27 ## Context