diff --git a/docs/vnext/session-primitives.md b/docs/vnext/session-primitives.md index 550c5c1..87f6424 100644 --- a/docs/vnext/session-primitives.md +++ b/docs/vnext/session-primitives.md @@ -14,6 +14,47 @@ CodeMirror consumers should use the separate See [source coordinates](./source-coordinates.md) for the shared UTF-16 range contract and the internal immutable source-snapshot model. +Statement boundaries are available as a synchronous structural query: + +```ts +const result = session.statementBoundaryAt({ + affinity: "left", + position: cursorOffset, +}); + +if ( + session.isCurrent(result.revision) && + result.boundary.boundaryQuality === "exact" && + result.boundary.hasCode +) { + executeRange(result.boundary.source); +} +``` + +The immutable result carries the current session revision. Exact boundaries +expose their full extent, source range, optional terminator, lexical end state, +and a nullable range from the first through last SQL code token. Source ranges +retain attached +whitespace and comments; they are factual lexical boundaries, not pre-trimmed +visual selections. The `code` range excludes leading and trailing separator +trivia so presentation layers do not need to re-lex the document. Opaque +procedural, custom-delimiter, and resource-limited +regions expose only their extent and reason, so consumers cannot mistake them +for safely executable SQL. + +Viewport consumers can retrieve every structural boundary in one half-open +range without probing or re-lexing the document: + +```ts +const visible = session.statementBoundariesIntersecting({ + from: viewport.from, + to: viewport.to, +}); +``` + +The query runs in logarithmic lookup time plus the number of intersecting +boundaries and returns a frozen, revision-stamped array. + ## Example ```ts diff --git a/scripts/package-smoke.mjs b/scripts/package-smoke.mjs index a6aa13c..2f7b965 100644 --- a/scripts/package-smoke.mjs +++ b/scripts/package-smoke.mjs @@ -207,6 +207,13 @@ session.update({ baseRevision: session.revision, document: { kind: "replace", text: "SELECT * FROM {next_df}" }, }); +const statement = session.statementBoundaryAt({ affinity: "left", position: 0 }); +if ( + statement.boundary.boundaryQuality !== "exact" || + !statement.boundary.hasCode +) { + throw new Error("The packaged vNext statement boundary was unavailable"); +} service.dispose(); `, ); @@ -229,6 +236,7 @@ import { duckdbDialect, type SqlDocumentContext, type SqlEmbeddedRegion, + type SqlStatementBoundaryAtResult, type SqlTextRange, } from "@marimo-team/codemirror-sql/vnext"; import { sqlEditor } from "@marimo-team/codemirror-sql/vnext/codemirror"; @@ -265,12 +273,17 @@ session.update({ baseRevision: session.revision, document: { kind: "changes", changes: [] }, }); +const statement: SqlStatementBoundaryAtResult = session.statementBoundaryAt({ + affinity: "left", + position: 0, +}); void extensions; void editorSupport.extension; void parser; void range; void session; +void statement; void BigQueryDialect; void DremioDialect; void commonKeywords; @@ -349,6 +362,20 @@ const updatedRevision = session.update({ if (session.isCurrent(originalRevision) || !session.isCurrent(updatedRevision)) { throw new Error("The packaged vNext session violated revision identity"); } +const statement = session.statementBoundaryAt({ affinity: "left", position: 0 }); +const visible = session.statementBoundariesIntersecting({ + from: 0, + to: 23, +}); +if ( + statement.revision !== updatedRevision || + statement.boundary.boundaryQuality !== "exact" || + !statement.boundary.hasCode || + visible.revision !== updatedRevision || + visible.boundaries.length !== 1 +) { + throw new Error("The packaged vNext statement boundary is invalid"); +} service.dispose(); `, ); diff --git a/src/vnext/__tests__/session.test.ts b/src/vnext/__tests__/session.test.ts index 631f003..14c3ad0 100644 --- a/src/vnext/__tests__/session.test.ts +++ b/src/vnext/__tests__/session.test.ts @@ -1394,6 +1394,327 @@ describe("statement-index session cache", () => { }); }); +describe("public statement boundaries", () => { + it("projects exact boundaries with explicit cursor affinity", () => { + const { session } = openSession("SELECT 1;SELECT 2"); + + const left = session.statementBoundaryAt({ + affinity: "left", + position: 9, + }); + const right = session.statementBoundaryAt({ + affinity: "right", + position: 9, + }); + + expect(left).toEqual({ + boundary: { + boundaryQuality: "exact", + code: { from: 0, to: 8 }, + endState: { kind: "normal" }, + extent: { from: 0, to: 9 }, + hasCode: true, + source: { from: 0, to: 8 }, + terminator: { from: 8, to: 9 }, + }, + revision: session.revision, + }); + expect(right.boundary).toEqual({ + boundaryQuality: "exact", + code: { from: 9, to: 17 }, + endState: { kind: "normal" }, + extent: { from: 9, to: 17 }, + hasCode: true, + source: { from: 9, to: 17 }, + terminator: null, + }); + expect(Object.isFrozen(left)).toBe(true); + expect(Object.isFrozen(left.boundary)).toBe(true); + expect(Object.isFrozen(left.boundary.extent)).toBe(true); + if (left.boundary.boundaryQuality === "exact") { + expect(Object.isFrozen(left.boundary.code)).toBe(true); + expect(Object.isFrozen(left.boundary.source)).toBe(true); + expect(Object.isFrozen(left.boundary.terminator)).toBe(true); + expect(Object.isFrozen(left.boundary.endState)).toBe(true); + } + }); + + it("distinguishes empty, consecutive, and terminal boundaries", () => { + const empty = openSession("").session.statementBoundaryAt({ + affinity: "right", + position: 0, + }); + expect(empty.boundary).toMatchObject({ + boundaryQuality: "exact", + extent: { from: 0, to: 0 }, + hasCode: false, + }); + + const { session } = openSession("SELECT 1;;"); + expect(session.statementBoundaryAt({ + affinity: "left", + position: 9, + }).boundary).toMatchObject({ + hasCode: true, + terminator: { from: 8, to: 9 }, + }); + expect(session.statementBoundaryAt({ + affinity: "right", + position: 9, + }).boundary).toMatchObject({ + hasCode: false, + terminator: { from: 9, to: 10 }, + }); + expect(session.statementBoundaryAt({ + affinity: "left", + position: 10, + }).boundary).toMatchObject({ + extent: { from: 9, to: 10 }, + hasCode: false, + }); + expect(session.statementBoundaryAt({ + affinity: "right", + position: 10, + }).boundary).toMatchObject({ + extent: { from: 10, to: 10 }, + hasCode: false, + }); + }); + + it("preserves explicit no-code and unterminated states", () => { + const comment = openSession("/* comment */").session.statementBoundaryAt({ + affinity: "right", + position: 0, + }); + expect(comment.boundary).toMatchObject({ + boundaryQuality: "exact", + hasCode: false, + source: { from: 0, to: 13 }, + }); + + const unterminated = openSession("SELECT '🦆").session.statementBoundaryAt({ + affinity: "left", + position: 10, + }); + expect(unterminated.boundary).toMatchObject({ + boundaryQuality: "exact", + endState: { + construct: "single-quoted-string", + from: 7, + kind: "unterminated", + }, + }); + + const text = " /* lead */ SELECT 1 /* tail */ "; + const trimmed = openSession(text).session.statementBoundaryAt({ + affinity: "right", + position: 0, + }); + expect(trimmed.boundary).toMatchObject({ + code: { + from: text.indexOf("SELECT"), + to: text.indexOf(" /* tail */"), + }, + source: { from: 0, to: text.length }, + }); + }); + + it("reports original coordinates across masked host regions", () => { + const service = createService(); + const text = "SELECT {x;y};SELECT 2"; + const session = service.openDocument({ + context: { dialect: "duckdb", engine: "local" }, + embeddedRegions: [{ + from: 7, + language: "python", + to: 12, + }], + text, + }); + + expect(session.statementBoundaryAt({ + affinity: "left", + position: 13, + }).boundary).toMatchObject({ + extent: { from: 0, to: 13 }, + source: { from: 0, to: 12 }, + terminator: { from: 12, to: 13 }, + }); + expect(session.statementBoundaryAt({ + affinity: "right", + position: 13, + }).boundary).toMatchObject({ + extent: { from: 13, to: text.length }, + source: { from: 13, to: text.length }, + }); + }); + + it("reports opaque boundaries without executable source", () => { + const text = + "CREATE FUNCTION f() RETURNS int LANGUAGE SQL BEGIN ATOMIC SELECT 1; END;"; + const { session } = openSession(text); + session.update({ + baseRevision: session.revision, + context: { dialect: "postgresql", engine: "warehouse" }, + }); + const result = session.statementBoundaryAt({ + affinity: "right", + position: 32, + }); + + expect(result.boundary).toEqual({ + boundaryQuality: "opaque", + extent: { from: 0, to: text.length }, + reason: "procedural-block", + }); + expect("source" in result.boundary).toBe(false); + }); + + it("projects custom-delimiter and resource-limit opacity", () => { + const service = createSqlLanguageService({ + dialects: [bigQueryDialect()], + }); + const custom = service.openDocument({ + context: { dialect: "bigquery", engine: "warehouse" }, + text: "DELIMITER $$\nSELECT 1$$", + }); + expect(custom.statementBoundaryAt({ + affinity: "right", + position: 0, + }).boundary).toMatchObject({ + boundaryQuality: "opaque", + reason: "custom-delimiter", + }); + + const text = ";".repeat(10_001); + const limited = service.openDocument({ + context: { dialect: "bigquery", engine: "warehouse" }, + text, + }); + expect(limited.statementBoundaryAt({ + affinity: "right", + position: text.length, + }).boundary).toMatchObject({ + boundaryQuality: "opaque", + reason: "resource-limit", + }); + }); + + it("returns the current revision after atomic updates", () => { + const { session } = openSession("SELECT 1"); + const previous = session.statementBoundaryAt({ + affinity: "left", + position: 8, + }); + session.update({ + baseRevision: session.revision, + document: { + changes: [{ from: 7, insert: "20", to: 8 }], + kind: "changes", + }, + embeddedRegions: [], + }); + + const current = session.statementBoundaryAt({ + affinity: "left", + position: 9, + }); + expect(current.revision).toBe(session.revision); + expect(current.revision).not.toBe(previous.revision); + expect(current.boundary).toMatchObject({ + source: { from: 0, to: 9 }, + }); + }); + + it("returns every boundary intersecting a viewport range", () => { + const text = ";SELECT 1;/* trailing */"; + const { session } = openSession(text); + const result = session.statementBoundariesIntersecting({ + from: 0, + to: text.length, + }); + + expect(result.revision).toBe(session.revision); + expect(result.boundaries).toHaveLength(3); + expect(result.boundaries.map((boundary) => + boundary.boundaryQuality === "exact" && boundary.hasCode + )).toEqual([false, true, false]); + expect(result.boundaries[1]).toMatchObject({ + code: { from: 1, to: 9 }, + extent: { from: 1, to: 10 }, + source: { from: 1, to: 9 }, + }); + expect(Object.isFrozen(result)).toBe(true); + expect(Object.isFrozen(result.boundaries)).toBe(true); + expect(session.statementBoundariesIntersecting({ + from: 1, + to: 1, + }).boundaries).toEqual([]); + }); + + it("validates requests and rejects disposed sessions", () => { + const { session } = openSession("SELECT 1"); + for (const request of [ + null, + {}, + { affinity: "center", position: 0 }, + { affinity: "left", position: -1 }, + { affinity: "left", position: Number.NaN }, + { affinity: "right", position: 9 }, + ]) { + expectSessionError("invalid-statement-boundary-request", () => { + session.statementBoundaryAt(request as never); + }); + } + let getterCalls = 0; + expectSessionError("invalid-statement-boundary-request", () => { + session.statementBoundaryAt( + Object.defineProperty( + { affinity: "left" }, + "position", + { + get: () => { + getterCalls += 1; + return 0; + }, + }, + ) as never, + ); + }); + expect(getterCalls).toBe(0); + expectSessionError("invalid-statement-boundary-request", () => { + session.statementBoundaryAt( + Object.create({ affinity: "left", position: 0 }) as never, + ); + }); + expectSessionError("invalid-statement-boundary-request", () => { + session.statementBoundaryAt(new Proxy({}, { + getOwnPropertyDescriptor: () => { + throw new Error("host descriptor trap"); + }, + }) as never); + }); + for (const range of [ + null, + {}, + { from: -1, to: 0 }, + { from: 2, to: 1 }, + { from: 0, to: 9 }, + ]) { + expectSessionError("invalid-statement-boundary-request", () => { + session.statementBoundariesIntersecting(range as never); + }); + } + session.dispose(); + expectSessionError("session-disposed", () => { + session.statementBoundaryAt({ affinity: "left", position: 0 }); + }); + expectSessionError("session-disposed", () => { + session.statementBoundariesIntersecting({ from: 0, to: 0 }); + }); + }); +}); + describe("document revisions", () => { it("starts with a frozen current revision", () => { const { session } = openSession(); diff --git a/src/vnext/codemirror/__tests__/sql-editor.test.ts b/src/vnext/codemirror/__tests__/sql-editor.test.ts index a9bfdf7..39ebef4 100644 --- a/src/vnext/codemirror/__tests__/sql-editor.test.ts +++ b/src/vnext/codemirror/__tests__/sql-editor.test.ts @@ -166,6 +166,30 @@ function fakeService( get revision() { return revision; }, + statementBoundaryAt: () => ({ + boundary: { + boundaryQuality: "exact", + code: null, + endState: { kind: "normal" }, + extent: { from: 0, to: 0 }, + hasCode: false, + source: { from: 0, to: 0 }, + terminator: null, + }, + revision, + }), + statementBoundariesIntersecting: () => ({ + boundaries: [{ + boundaryQuality: "exact", + code: null, + endState: { kind: "normal" }, + extent: { from: 0, to: 0 }, + hasCode: false, + source: { from: 0, to: 0 }, + terminator: null, + }], + revision, + }), update: (update) => { updates.push(update); if (rejectUpdates) { diff --git a/src/vnext/index.ts b/src/vnext/index.ts index 7628924..5a98211 100644 --- a/src/vnext/index.ts +++ b/src/vnext/index.ts @@ -28,6 +28,19 @@ export type { SqlTextRange, } from "./types.js"; export { SqlSessionError } from "./types.js"; +export type { + SqlExactStatementBoundary, + SqlOpaqueStatementBoundary, + SqlStatementAffinity, + SqlStatementBoundariesIntersectingRequest, + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundary, + SqlStatementBoundaryAtRequest, + SqlStatementBoundaryAtResult, + SqlStatementLexicalEnd, + SqlStatementOpaqueReason, + SqlStatementUnterminatedConstruct, +} from "./statement-boundary-types.js"; export type { SqlCanonicalRelationPath, SqlCatalogEpoch, diff --git a/src/vnext/session.ts b/src/vnext/session.ts index fa41d79..bb5359d 100644 --- a/src/vnext/session.ts +++ b/src/vnext/session.ts @@ -13,6 +13,7 @@ import type { import { buildSqlStatementIndex, findSqlStatementSlot, + findSqlStatementSlotsIntersecting, type SqlLexicalProfile, type SqlStatementIndex, type SqlStatementSlot, @@ -63,6 +64,7 @@ import { createIdentitySqlSource, createMaskedSqlSource, isSqlSourceError, + mapAnalysisRangeToOriginal, MAX_SQL_SOURCE_LENGTH, normalizeSqlTextRange, type SqlSourceSnapshot, @@ -73,6 +75,14 @@ import { type SqlDialect, SqlSessionError, } from "./types.js"; +import type { + SqlStatementBoundariesIntersectingRequest, + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundary, + SqlStatementBoundaryAtRequest, + SqlStatementBoundaryAtResult, + SqlStatementLexicalEnd, +} from "./statement-boundary-types.js"; const MAX_CONTEXT_DEPTH = 100; const MAX_CONTEXT_NODES = 10_000; @@ -772,6 +782,156 @@ function createCompletionTask( return Object.freeze(Object.assign(result, { refreshToken })); } +function invalidStatementBoundaryRequest(message: string): never { + throw new SqlSessionError( + "invalid-statement-boundary-request", + message, + ); +} + +function statementBoundaryRequest( + request: unknown, +): SqlStatementBoundaryAtRequest { + if (request === null || typeof request !== "object") { + return invalidStatementBoundaryRequest( + "SQL statement boundary request must be an object", + ); + } + let affinity: unknown; + let position: unknown; + try { + affinity = readRequiredDataProperty( + request, + "affinity", + "invalid-statement-boundary-request", + "SQL statement boundary request", + ); + position = readRequiredDataProperty( + request, + "position", + "invalid-statement-boundary-request", + "SQL statement boundary request", + ); + } catch { + return invalidStatementBoundaryRequest( + "SQL statement boundary request requires own data properties", + ); + } + if (affinity !== "left" && affinity !== "right") { + return invalidStatementBoundaryRequest( + "SQL statement affinity must be left or right", + ); + } + if ( + typeof position !== "number" || + !Number.isSafeInteger(position) || + position < 0 + ) { + return invalidStatementBoundaryRequest( + "SQL statement position must be a non-negative safe integer", + ); + } + return Object.freeze({ + affinity, + position, + }); +} + +function statementIntersectionRequest( + request: unknown, +): SqlStatementBoundariesIntersectingRequest { + if (request === null || typeof request !== "object") { + return invalidStatementBoundaryRequest( + "SQL statement intersection request must be an object", + ); + } + let from: unknown; + let to: unknown; + try { + from = readRequiredDataProperty( + request, + "from", + "invalid-statement-boundary-request", + "SQL statement intersection request", + ); + to = readRequiredDataProperty( + request, + "to", + "invalid-statement-boundary-request", + "SQL statement intersection request", + ); + } catch { + return invalidStatementBoundaryRequest( + "SQL statement intersection request requires own data properties", + ); + } + if ( + typeof from !== "number" || + typeof to !== "number" || + !Number.isSafeInteger(from) || + !Number.isSafeInteger(to) || + from < 0 || + to < from + ) { + return invalidStatementBoundaryRequest( + "SQL statement intersection range must be ordered safe integers", + ); + } + return Object.freeze({ from, to }); +} + +function statementRange( + source: SqlSourceSnapshot, + range: SqlTextRange, +): SqlTextRange { + return mapAnalysisRangeToOriginal(source, range); +} + +function statementBoundary( + source: SqlSourceSnapshot, + slot: SqlStatementSlot, +): SqlStatementBoundary { + const extent = statementRange(source, slot.extent); + if (slot.boundaryQuality === "opaque") { + return Object.freeze({ + boundaryQuality: "opaque", + extent, + reason: slot.endState.reason, + }); + } + const endState: SqlStatementLexicalEnd = + slot.endState.kind === "normal" + ? Object.freeze({ kind: "normal" }) + : Object.freeze({ + construct: slot.endState.construct, + from: statementRange(source, { + from: slot.endState.from, + to: slot.endState.from, + }).from, + kind: "unterminated", + }); + const base = { + boundaryQuality: "exact", + endState, + extent, + source: statementRange(source, slot.source), + terminator: slot.terminator === null + ? null + : statementRange(source, slot.terminator), + } as const; + return slot.code === null + ? Object.freeze({ + ...base, + code: null, + hasCode: false, + }) + : Object.freeze({ + ...base, + code: statementRange(source, slot.code), + hasCode: true, + }); +} + export class DefaultSqlDocumentSession implements SqlDocumentSession { @@ -838,7 +998,7 @@ export class DefaultSqlDocumentSession return this.#statementIndexCache?.index ?? null; } - getStatementIndexForTesting(): SqlStatementIndex { + #getStatementIndex(): SqlStatementIndex { if (this.#disposed) { throw new SqlSessionError( "session-disposed", @@ -865,6 +1025,65 @@ export class DefaultSqlDocumentSession return index; } + getStatementIndexForTesting(): SqlStatementIndex { + return this.#getStatementIndex(); + } + + readonly statementBoundaryAt = ( + input: SqlStatementBoundaryAtRequest, + ): SqlStatementBoundaryAtResult => { + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + const request = statementBoundaryRequest(input); + const snapshot = this.#snapshot; + if (request.position > snapshot.source.originalText.length) { + return invalidStatementBoundaryRequest( + "SQL statement position is outside the document", + ); + } + const slot = findSqlStatementSlot( + this.#getStatementIndex(), + request.position, + request.affinity, + ); + return Object.freeze({ + boundary: statementBoundary(snapshot.source, slot), + revision: snapshot.revision, + }); + }; + + readonly statementBoundariesIntersecting = ( + input: SqlStatementBoundariesIntersectingRequest, + ): SqlStatementBoundariesIntersectingResult => { + if (this.#disposed) { + throw new SqlSessionError( + "session-disposed", + "SQL document session is disposed", + ); + } + const request = statementIntersectionRequest(input); + const snapshot = this.#snapshot; + if (request.to > snapshot.source.originalText.length) { + return invalidStatementBoundaryRequest( + "SQL statement intersection range is outside the document", + ); + } + const slots = findSqlStatementSlotsIntersecting( + this.#getStatementIndex(), + request, + ); + return Object.freeze({ + boundaries: Object.freeze( + slots.map((slot) => statementBoundary(snapshot.source, slot)), + ), + revision: snapshot.revision, + }); + }; + #clearTerminalIntent(): void { const intent = this.#terminalRefreshIntent; if (!intent) return; @@ -1281,7 +1500,7 @@ export class DefaultSqlDocumentSession ); } } - const index = this.getStatementIndexForTesting(); + const index = this.#getStatementIndex(); const slot = findSqlStatementSlot(index, position, "left"); const cachedLocal = this.#localRelationStatementCache; const prepared = diff --git a/src/vnext/statement-boundary-types.ts b/src/vnext/statement-boundary-types.ts new file mode 100644 index 0000000..f415bfa --- /dev/null +++ b/src/vnext/statement-boundary-types.ts @@ -0,0 +1,78 @@ +import type { + SqlRevision, + SqlTextRange, +} from "./types.js"; + +export type SqlStatementAffinity = "left" | "right"; + +export type SqlStatementUnterminatedConstruct = + | "backtick-quoted-identifier" + | "block-comment" + | "dollar-quoted-string" + | "double-quoted-identifier" + | "double-quoted-string" + | "single-quoted-string" + | "triple-double-quoted-string" + | "triple-single-quoted-string"; + +export type SqlStatementOpaqueReason = + | "custom-delimiter" + | "procedural-block" + | "resource-limit"; + +export type SqlStatementLexicalEnd = + | { + readonly kind: "normal"; + } + | { + readonly construct: SqlStatementUnterminatedConstruct; + readonly from: number; + readonly kind: "unterminated"; + }; + +interface SqlExactStatementBoundaryBase { + readonly boundaryQuality: "exact"; + readonly endState: SqlStatementLexicalEnd; + readonly extent: SqlTextRange; + readonly source: SqlTextRange; + readonly terminator: SqlTextRange | null; +} + +export type SqlExactStatementBoundary = + SqlExactStatementBoundaryBase & ( + | { + readonly code: SqlTextRange; + readonly hasCode: true; + } + | { + readonly code: null; + readonly hasCode: false; + } + ); + +export interface SqlOpaqueStatementBoundary { + readonly boundaryQuality: "opaque"; + readonly extent: SqlTextRange; + readonly reason: SqlStatementOpaqueReason; +} + +export type SqlStatementBoundary = + | SqlExactStatementBoundary + | SqlOpaqueStatementBoundary; + +export interface SqlStatementBoundaryAtRequest { + readonly affinity: SqlStatementAffinity; + readonly position: number; +} + +export interface SqlStatementBoundaryAtResult { + readonly boundary: SqlStatementBoundary; + readonly revision: SqlRevision; +} + +export type SqlStatementBoundariesIntersectingRequest = SqlTextRange; + +export interface SqlStatementBoundariesIntersectingResult { + readonly boundaries: readonly SqlStatementBoundary[]; + readonly revision: SqlRevision; +} diff --git a/src/vnext/statement-index.ts b/src/vnext/statement-index.ts index bd5c0c0..edf4081 100644 --- a/src/vnext/statement-index.ts +++ b/src/vnext/statement-index.ts @@ -70,6 +70,7 @@ export type SqlLexicalEndState = export interface ExactSqlStatementSlot { readonly boundaryQuality: "exact"; + readonly code: SqlAnalysisRange | null; readonly endState: Exclude; readonly extent: SqlAnalysisRange; readonly hasCode: boolean; @@ -218,14 +219,18 @@ function createExactSlot( from: number, sourceTo: number, extentTo: number, - hasCode: boolean, + codeFrom: number | null, + codeTo: number, endState: ExactSqlStatementSlot["endState"], ): ExactSqlStatementSlot { const slot = Object.freeze({ boundaryQuality: "exact", + code: codeFrom === null + ? null + : createAnalysisRange(codeFrom, codeTo), endState, extent: createAnalysisRange(from, extentTo), - hasCode, + hasCode: codeFrom !== null, source: createAnalysisRange(from, sourceTo), terminator: sourceTo === extentTo ? null : createAnalysisRange(sourceTo, extentTo), @@ -495,9 +500,14 @@ function scanSqlStatementIndex( const slots: SqlStatementSlot[] = [...options.prefixSlots]; const prefixGuard = new SqlPrefixGuard(profile.proceduralGuards); let slotFrom = options.from; - let hasCode = false; + let codeFrom: number | null = null; + let codeTo = options.from; let cursor = options.from; let finalEndState: ExactSqlStatementSlot["endState"] = NORMAL_END_STATE; + const recordCode = (from: number, to: number): void => { + if (codeFrom === null) codeFrom = from; + codeTo = to; + }; const finishOpaque = ( reason: SqlOpaqueBoundaryReason, @@ -567,7 +577,7 @@ function scanSqlStatementIndex( analysisText.length, ); if (dollarQuote) { - hasCode = true; + const codeFrom_ = cursor; prefixGuard.recordNonWord(code); if (dollarQuote.delimiterTooLong) { return finishOpaque("resource-limit", cursor); @@ -579,6 +589,7 @@ function scanSqlStatementIndex( ); } cursor = dollarQuote.to; + recordCode(codeFrom_, cursor); continue; } } @@ -588,7 +599,7 @@ function scanSqlStatementIndex( code === 34 || (profile.backtickQuotedIdentifiers && code === 96) ) { - hasCode = true; + const codeFrom_ = cursor; if (code === 96) { prefixGuard.recordQuotedIdentifier(); prefixGuard.recordNonWord(code); @@ -609,6 +620,7 @@ function scanSqlStatementIndex( ); } cursor = quote.to; + recordCode(codeFrom_, cursor); continue; } @@ -648,6 +660,7 @@ function scanSqlStatementIndex( ); } cursor = quote.to; + recordCode(codeFrom_, cursor); continue; } @@ -679,7 +692,7 @@ function scanSqlStatementIndex( cursor += continueLength; } prefixGuard.recordWord(analysisText, wordFrom, cursor); - hasCode = true; + recordCode(wordFrom, cursor); if (prefixGuard.reason !== null) { return finishOpaque(prefixGuard.reason, prefixGuard.reasonAt); } @@ -699,12 +712,14 @@ function scanSqlStatementIndex( slotFrom, cursor, cursor + 1, - hasCode, + codeFrom, + codeTo, NORMAL_END_STATE, ), ); slotFrom = cursor + 1; - hasCode = false; + codeFrom = null; + codeTo = slotFrom; finalEndState = NORMAL_END_STATE; prefixGuard.reset(); cursor += 1; @@ -715,7 +730,7 @@ function scanSqlStatementIndex( continue; } - hasCode = true; + recordCode(cursor, cursor + 1); cursor += 1; } @@ -724,7 +739,8 @@ function scanSqlStatementIndex( slotFrom, analysisText.length, analysisText.length, - hasCode, + codeFrom, + codeTo, finalEndState, ), ); @@ -831,6 +847,12 @@ function shiftStatementSlot( } const shifted = Object.freeze({ boundaryQuality: "exact", + code: slot.code + ? createAnalysisRange( + slot.code.from + delta, + slot.code.to + delta, + ) + : null, endState: shiftEndState(slot.endState, delta), extent: createAnalysisRange( slot.extent.from + delta, @@ -1047,6 +1069,40 @@ export function findSqlStatementSlot( ); } +export function findSqlStatementSlotsIntersecting( + index: SqlStatementIndex, + range: { readonly from: number; readonly to: number }, +): readonly SqlStatementSlot[] { + const slots = index.slots; + const documentLength = getStatementSlot( + slots, + slots.length - 1, + ).extent.to; + if ( + !Number.isSafeInteger(range.from) || + !Number.isSafeInteger(range.to) || + range.from < 0 || + range.to < range.from || + range.to > documentLength + ) { + throw new RangeError( + "SQL statement intersection range is outside the document", + ); + } + if (range.from === range.to) return Object.freeze([]); + const matches: SqlStatementSlot[] = []; + for ( + let index_ = statementSlotIndexAt(slots, range.from, "right"); + index_ < slots.length; + index_ += 1 + ) { + const slot = getStatementSlot(slots, index_); + if (slot.extent.from >= range.to) break; + if (slot.extent.to > range.from) matches.push(slot); + } + return Object.freeze(matches); +} + function getStatementSlot( slots: readonly SqlStatementSlot[], index: number, diff --git a/src/vnext/types.ts b/src/vnext/types.ts index 1931e7e..075c403 100644 --- a/src/vnext/types.ts +++ b/src/vnext/types.ts @@ -135,6 +135,12 @@ export interface OpenSqlDocument { /** Owns all mutable state for one open SQL document. */ export interface SqlDocumentSession { readonly revision: SqlRevision; + readonly statementBoundaryAt: ( + request: SqlStatementBoundaryAtRequest, + ) => SqlStatementBoundaryAtResult; + readonly statementBoundariesIntersecting: ( + request: SqlStatementBoundariesIntersectingRequest, + ) => SqlStatementBoundariesIntersectingResult; readonly update: (update: SqlDocumentUpdate) => SqlRevision; readonly complete: ( request: SqlCompletionRequest, @@ -170,6 +176,7 @@ export type SqlSessionErrorCode = | "invalid-dialect" | "invalid-document" | "invalid-service-options" + | "invalid-statement-boundary-request" | "invalid-update" | "reentrant-update" | "service-disposed" @@ -192,3 +199,9 @@ import type { SqlRelationCatalogProvider, SqlSessionChangeEvent, } from "./relation-completion-types.js"; +import type { + SqlStatementBoundariesIntersectingRequest, + SqlStatementBoundariesIntersectingResult, + SqlStatementBoundaryAtRequest, + SqlStatementBoundaryAtResult, +} from "./statement-boundary-types.js"; diff --git a/test/vnext-types/session.test-d.ts b/test/vnext-types/session.test-d.ts index 520056c..4c9b8bd 100644 --- a/test/vnext-types/session.test-d.ts +++ b/test/vnext-types/session.test-d.ts @@ -9,6 +9,9 @@ import { type SqlLanguageService, type SqlRelationCatalogProvider, type SqlRevision, + type SqlStatementBoundariesIntersectingResult, + type SqlStatementBoundaryAtResult, + type SqlStatementBoundary, type SqlTextChange, type SqlTextRange, } from "../../src/vnext/index.js"; @@ -196,6 +199,33 @@ const embeddedRegion: SqlEmbeddedRegion = { embeddedRegion.language = "jinja"; // @ts-expect-error session revision is readonly session.revision = revision; +const statement = session.statementBoundaryAt({ + affinity: "left", + position: 0, +}); +const statementResult: SqlStatementBoundaryAtResult = statement; +const statementBoundary: SqlStatementBoundary = statement.boundary; +if (statementBoundary.boundaryQuality === "exact") { + if (statementBoundary.hasCode) { + const codeRange: SqlTextRange = statementBoundary.code; + void codeRange; + } else { + const noCode: null = statementBoundary.code; + void noCode; + } +} +const statements: SqlStatementBoundariesIntersectingResult = + session.statementBoundariesIntersecting({ from: 0, to: 1 }); +// @ts-expect-error statement boundary results are readonly +statement.boundary.extent.from = 1; +// @ts-expect-error affinity is explicit and bounded +session.statementBoundaryAt({ affinity: "nearest", position: 0 }); +// @ts-expect-error statement positions are numeric UTF-16 offsets +session.statementBoundaryAt({ affinity: "right", position: "0" }); +// @ts-expect-error intersection ranges use numeric UTF-16 offsets +session.statementBoundariesIntersecting({ from: 0, to: "1" }); +// @ts-expect-error intersecting boundary arrays are readonly +statements.boundaries.push(statementBoundary); // @ts-expect-error statement indexes remain an internal session detail session.getStatementIndexForTesting(); // @ts-expect-error dialect IDs are readonly @@ -206,6 +236,10 @@ createSqlLanguageService({ dialects: [{ id: "duckdb", displayName: "DuckDB" }] } void objectRevision; void numberRevision; void range; +void statement; +void statementBoundary; +void statementResult; +void statements; void embeddedRegion; void identitySession; void typedDialect;