From bdd42c899ecaaa8f7e310fc4077cc4b01f2a3caf Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 15:51:20 +0900 Subject: [PATCH 01/16] chore: scaffold @soda-gql/lsp package Set up the new LSP package with package.json, tsconfig, and build config. Dependencies include graphql-language-service, vscode-languageserver, and @swc/core. Co-Authored-By: Claude Opus 4.5 --- bun.lock | 32 ++++++++++++++++ packages/lsp/@x-index.ts | 1 + packages/lsp/package.json | 62 +++++++++++++++++++++++++++++++ packages/lsp/src/index.ts | 4 ++ packages/lsp/tsconfig.editor.json | 24 ++++++++++++ tsconfig.editor.json | 1 + tsdown.config.ts | 17 +++++++++ 7 files changed, 141 insertions(+) create mode 100644 packages/lsp/@x-index.ts create mode 100644 packages/lsp/package.json create mode 100644 packages/lsp/src/index.ts create mode 100644 packages/lsp/tsconfig.editor.json diff --git a/bun.lock b/bun.lock index 7883cda5e..6209f7793 100644 --- a/bun.lock +++ b/bun.lock @@ -156,6 +156,22 @@ "@swc/core": "^1.0.0", }, }, + "packages/lsp": { + "name": "@soda-gql/lsp", + "version": "0.11.26", + "dependencies": { + "@soda-gql/builder": "workspace:*", + "@soda-gql/codegen": "workspace:*", + "@soda-gql/config": "workspace:*", + "@swc/core": "^1.6.3", + "@swc/types": "^0.1.6", + "graphql": "^16.8.1", + "graphql-language-service": "^5.3.0", + "neverthrow": "^8.1.1", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-textdocument": "^1.0.11", + }, + }, "packages/metro-plugin": { "name": "@soda-gql/metro-plugin", "version": "0.11.26", @@ -1129,6 +1145,8 @@ "@soda-gql/formatter": ["@soda-gql/formatter@workspace:packages/formatter"], + "@soda-gql/lsp": ["@soda-gql/lsp@workspace:packages/lsp"], + "@soda-gql/metro-plugin": ["@soda-gql/metro-plugin@workspace:packages/metro-plugin"], "@soda-gql/runtime": ["@soda-gql/runtime@workspace:packages/runtime"], @@ -1539,6 +1557,8 @@ "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + "debounce-promise": ["debounce-promise@3.1.2", "", {}, "sha512-rZHcgBkbYavBeD9ej6sP56XfG53d51CD4dnaw989YX/nZ/ZJfgRx/9ePKmTNiUiyQvh4mtrMoS3OAWW+yoYtpg=="], + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], "decode-named-character-reference": ["decode-named-character-reference@1.2.0", "", { "dependencies": { "character-entities": "^2.0.0" } }, "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q=="], @@ -1795,6 +1815,8 @@ "graphql": ["graphql@16.11.0", "", {}, "sha512-mS1lbMsxgQj6hge1XZ6p7GPhbrtFwUFYi3wRzXAC/FmYnyXMTvvI3td3rjmQ2u8ewXueaSvRPWaEcgVVOT9Jnw=="], + "graphql-language-service": ["graphql-language-service@5.5.0", "", { "dependencies": { "debounce-promise": "^3.1.2", "nullthrows": "^1.0.0", "vscode-languageserver-types": "^3.17.1" }, "peerDependencies": { "graphql": "^15.5.0 || ^16.0.0 || ^17.0.0" }, "bin": { "graphql": "dist/temp-bin.js" } }, "sha512-9EvWrLLkF6Y5e29/2cmFoAO6hBPPAZlCyjznmpR11iFtRydfkss+9m6x+htA8h7YznGam+TtJwS6JuwoWWgb2Q=="], + "gray-matter": ["gray-matter@4.0.3", "", { "dependencies": { "js-yaml": "^3.13.1", "kind-of": "^6.0.2", "section-matter": "^1.0.0", "strip-bom-string": "^1.0.0" } }, "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q=="], "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], @@ -2799,6 +2821,16 @@ "vlq": ["vlq@1.0.1", "", {}, "sha512-gQpnTgkubC6hQgdIcRdYGDSDc+SaujOdyesZQMv6JlfQee/9Mp0Qhnys6WxDWvQnL5WZdT7o2Ul187aSt0Rq+w=="], + "vscode-jsonrpc": ["vscode-jsonrpc@8.2.0", "", {}, "sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA=="], + + "vscode-languageserver": ["vscode-languageserver@9.0.1", "", { "dependencies": { "vscode-languageserver-protocol": "3.17.5" }, "bin": { "installServerIntoExtension": "bin/installServerIntoExtension" } }, "sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g=="], + + "vscode-languageserver-protocol": ["vscode-languageserver-protocol@3.17.5", "", { "dependencies": { "vscode-jsonrpc": "8.2.0", "vscode-languageserver-types": "3.17.5" } }, "sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg=="], + + "vscode-languageserver-textdocument": ["vscode-languageserver-textdocument@1.0.12", "", {}, "sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA=="], + + "vscode-languageserver-types": ["vscode-languageserver-types@3.17.5", "", {}, "sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg=="], + "walker": ["walker@1.0.8", "", { "dependencies": { "makeerror": "1.0.12" } }, "sha512-ts/8E8l5b7kY0vlWLewOkDXMmPdLcVV4GmOQLyxuSswIJsweeFZtAsMF7k1Nszz+TYBQrlYRmzOnr398y1JemQ=="], "watchpack": ["watchpack@2.4.4", "", { "dependencies": { "glob-to-regexp": "^0.4.1", "graceful-fs": "^4.1.2" } }, "sha512-c5EGNOiyxxV5qmTtAB7rbiXxi1ooX1pQKMLX/MIabJjRA0SJBQOjKF+KSVfHkr9U1cADPon0mRiVe/riyaiDUA=="], diff --git a/packages/lsp/@x-index.ts b/packages/lsp/@x-index.ts new file mode 100644 index 000000000..e910bb060 --- /dev/null +++ b/packages/lsp/@x-index.ts @@ -0,0 +1 @@ +export * from "./src/index"; diff --git a/packages/lsp/package.json b/packages/lsp/package.json new file mode 100644 index 000000000..ac1cf5ba6 --- /dev/null +++ b/packages/lsp/package.json @@ -0,0 +1,62 @@ +{ + "name": "@soda-gql/lsp", + "version": "0.11.26", + "description": "GraphQL Language Server Protocol implementation for soda-gql", + "type": "module", + "private": false, + "license": "MIT", + "files": [ + "dist", + "index.d.ts", + "index.js" + ], + "author": { + "name": "Shota Hatada", + "email": "shota.hatada@whatasoda.me", + "url": "https://github.com/whatasoda" + }, + "keywords": [ + "graphql", + "lsp", + "language-server", + "soda-gql", + "typescript" + ], + "repository": { + "type": "git", + "url": "https://github.com/whatasoda/soda-gql.git", + "directory": "packages/lsp" + }, + "homepage": "https://github.com/whatasoda/soda-gql#readme", + "bugs": { + "url": "https://github.com/whatasoda/soda-gql/issues" + }, + "engines": { + "node": ">=18" + }, + "main": "./dist/index.mjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.mts", + "exports": { + ".": { + "@soda-gql": "./@x-index.ts", + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs", + "require": "./dist/index.cjs", + "default": "./dist/index.mjs" + }, + "./package.json": "./package.json" + }, + "dependencies": { + "@soda-gql/builder": "workspace:*", + "@soda-gql/codegen": "workspace:*", + "@soda-gql/config": "workspace:*", + "@swc/core": "^1.6.3", + "@swc/types": "^0.1.6", + "graphql": "^16.8.1", + "graphql-language-service": "^5.3.0", + "neverthrow": "^8.1.1", + "vscode-languageserver": "^9.0.1", + "vscode-languageserver-textdocument": "^1.0.11" + } +} diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts new file mode 100644 index 000000000..f8a6e186f --- /dev/null +++ b/packages/lsp/src/index.ts @@ -0,0 +1,4 @@ +// @soda-gql/lsp - GraphQL LSP server for soda-gql +// Public API exports will be added as components are implemented. + +export {}; diff --git a/packages/lsp/tsconfig.editor.json b/packages/lsp/tsconfig.editor.json new file mode 100644 index 000000000..4447577ea --- /dev/null +++ b/packages/lsp/tsconfig.editor.json @@ -0,0 +1,24 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "customConditions": ["@soda-gql"], + "outDir": "../../node_modules/.soda-gql/.typecheck/lsp", + "tsBuildInfoFile": "../../node_modules/.soda-gql/.typecheck/lsp/tsconfig.tsbuildinfo", + "rootDir": ".", + "paths": {} + }, + "include": ["src/**/*", "test/**/*", "@x-*.ts", "@devx-*.ts"], + "exclude": ["node_modules", "dist"], + "references": [ + { + "path": "../builder/tsconfig.editor.json" + }, + { + "path": "../codegen/tsconfig.editor.json" + }, + { + "path": "../config/tsconfig.editor.json" + } + ] +} diff --git a/tsconfig.editor.json b/tsconfig.editor.json index 45d9d4af1..6d696666c 100644 --- a/tsconfig.editor.json +++ b/tsconfig.editor.json @@ -28,6 +28,7 @@ { "path": "./packages/config/tsconfig.editor.json" }, { "path": "./packages/core/tsconfig.editor.json" }, { "path": "./packages/formatter/tsconfig.editor.json" }, + { "path": "./packages/lsp/tsconfig.editor.json" }, { "path": "./packages/metro-plugin/tsconfig.editor.json" }, { "path": "./packages/runtime/tsconfig.editor.json" }, { "path": "./packages/sdk/tsconfig.editor.json" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index d2e4f9c8d..6c3f7e074 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -220,6 +220,23 @@ export default defineConfig([ clean: true, }, + // LSP package + { + ...configure("@soda-gql/lsp", { + externals: [ + "vscode-languageserver", + "vscode-languageserver/node", + "vscode-languageserver-textdocument", + "graphql-language-service", + ], + }), + format: ["esm", "cjs"], + platform: "node", + target: "node18", + treeshake: false, + clean: true, + }, + // Transformer packages { ...configure("@soda-gql/babel"), From bf76623dff6de9808b5ef2d21cb0332f26c68ac6 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 15:52:09 +0900 Subject: [PATCH 02/16] feat(lsp): add core types and error types Define ExtractedTemplate, DocumentState, OperationKind types and LspError discriminated union with constructor helpers. Co-Authored-By: Claude Opus 4.5 --- packages/lsp/src/errors.test.ts | 59 ++++++++++++++++++++ packages/lsp/src/errors.ts | 98 +++++++++++++++++++++++++++++++++ packages/lsp/src/index.ts | 5 +- packages/lsp/src/types.ts | 27 +++++++++ 4 files changed, 187 insertions(+), 2 deletions(-) create mode 100644 packages/lsp/src/errors.test.ts create mode 100644 packages/lsp/src/errors.ts create mode 100644 packages/lsp/src/types.ts diff --git a/packages/lsp/src/errors.test.ts b/packages/lsp/src/errors.test.ts new file mode 100644 index 000000000..a8b6795b3 --- /dev/null +++ b/packages/lsp/src/errors.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { lspErrors } from "./errors"; + +describe("lspErrors", () => { + test("configLoadFailed creates correct error shape", () => { + const error = lspErrors.configLoadFailed("Config not found"); + expect(error.code).toBe("CONFIG_LOAD_FAILED"); + expect(error.message).toBe("Config not found"); + expect(error.cause).toBeUndefined(); + }); + + test("configLoadFailed preserves cause", () => { + const cause = new Error("ENOENT"); + const error = lspErrors.configLoadFailed("Config not found", cause); + expect(error.code).toBe("CONFIG_LOAD_FAILED"); + expect(error.cause).toBe(cause); + }); + + test("schemaLoadFailed creates correct error shape", () => { + const error = lspErrors.schemaLoadFailed("admin"); + expect(error.code).toBe("SCHEMA_LOAD_FAILED"); + expect(error.message).toBe("Failed to load schema: admin"); + expect(error.schemaName).toBe("admin"); + }); + + test("schemaLoadFailed accepts custom message", () => { + const error = lspErrors.schemaLoadFailed("admin", "File not found"); + expect(error.message).toBe("File not found"); + expect(error.schemaName).toBe("admin"); + }); + + test("schemaBuildFailed creates correct error shape", () => { + const error = lspErrors.schemaBuildFailed("default"); + expect(error.code).toBe("SCHEMA_BUILD_FAILED"); + expect(error.message).toBe("Failed to build schema: default"); + expect(error.schemaName).toBe("default"); + }); + + test("schemaNotConfigured creates correct error shape", () => { + const error = lspErrors.schemaNotConfigured("unknown"); + expect(error.code).toBe("SCHEMA_NOT_CONFIGURED"); + expect(error.message).toBe('Schema "unknown" is not configured in soda-gql.config'); + expect(error.schemaName).toBe("unknown"); + }); + + test("parseFailed creates correct error shape", () => { + const error = lspErrors.parseFailed("file:///test.ts"); + expect(error.code).toBe("PARSE_FAILED"); + expect(error.message).toBe("Failed to parse: file:///test.ts"); + expect(error.uri).toBe("file:///test.ts"); + }); + + test("internalInvariant creates correct error shape", () => { + const error = lspErrors.internalInvariant("Unexpected state", "server.init"); + expect(error.code).toBe("INTERNAL_INVARIANT"); + expect(error.message).toBe("Unexpected state"); + expect(error.context).toBe("server.init"); + }); +}); diff --git a/packages/lsp/src/errors.ts b/packages/lsp/src/errors.ts new file mode 100644 index 000000000..611129445 --- /dev/null +++ b/packages/lsp/src/errors.ts @@ -0,0 +1,98 @@ +/** + * Structured error types for the LSP server. + * @module + */ + +import type { Result } from "neverthrow"; + +/** Error code taxonomy for LSP operations. */ +export type LspErrorCode = + | "CONFIG_LOAD_FAILED" + | "SCHEMA_LOAD_FAILED" + | "SCHEMA_BUILD_FAILED" + | "SCHEMA_NOT_CONFIGURED" + | "PARSE_FAILED" + | "INTERNAL_INVARIANT"; + +/** Structured error type for all LSP operations. */ +export type LspError = + | { + readonly code: "CONFIG_LOAD_FAILED"; + readonly message: string; + readonly cause?: unknown; + } + | { + readonly code: "SCHEMA_LOAD_FAILED"; + readonly message: string; + readonly schemaName: string; + readonly cause?: unknown; + } + | { + readonly code: "SCHEMA_BUILD_FAILED"; + readonly message: string; + readonly schemaName: string; + readonly cause?: unknown; + } + | { + readonly code: "SCHEMA_NOT_CONFIGURED"; + readonly message: string; + readonly schemaName: string; + } + | { + readonly code: "PARSE_FAILED"; + readonly message: string; + readonly uri: string; + readonly cause?: unknown; + } + | { + readonly code: "INTERNAL_INVARIANT"; + readonly message: string; + readonly context?: string; + readonly cause?: unknown; + }; + +/** Helper type for LSP operation results. */ +export type LspResult = Result; + +/** Error constructor helpers for concise error creation. */ +export const lspErrors = { + configLoadFailed: (message: string, cause?: unknown): LspError => ({ + code: "CONFIG_LOAD_FAILED", + message, + cause, + }), + + schemaLoadFailed: (schemaName: string, message?: string, cause?: unknown): LspError => ({ + code: "SCHEMA_LOAD_FAILED", + message: message ?? `Failed to load schema: ${schemaName}`, + schemaName, + cause, + }), + + schemaBuildFailed: (schemaName: string, message?: string, cause?: unknown): LspError => ({ + code: "SCHEMA_BUILD_FAILED", + message: message ?? `Failed to build schema: ${schemaName}`, + schemaName, + cause, + }), + + schemaNotConfigured: (schemaName: string): LspError => ({ + code: "SCHEMA_NOT_CONFIGURED", + message: `Schema "${schemaName}" is not configured in soda-gql.config`, + schemaName, + }), + + parseFailed: (uri: string, message?: string, cause?: unknown): LspError => ({ + code: "PARSE_FAILED", + message: message ?? `Failed to parse: ${uri}`, + uri, + cause, + }), + + internalInvariant: (message: string, context?: string, cause?: unknown): LspError => ({ + code: "INTERNAL_INVARIANT", + message, + context, + cause, + }), +} as const; diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts index f8a6e186f..bb009bd07 100644 --- a/packages/lsp/src/index.ts +++ b/packages/lsp/src/index.ts @@ -1,4 +1,5 @@ // @soda-gql/lsp - GraphQL LSP server for soda-gql -// Public API exports will be added as components are implemented. -export {}; +export type { LspError, LspErrorCode, LspResult } from "./errors"; +export { lspErrors } from "./errors"; +export type { DocumentState, ExtractedTemplate, OperationKind } from "./types"; diff --git a/packages/lsp/src/types.ts b/packages/lsp/src/types.ts new file mode 100644 index 000000000..793636528 --- /dev/null +++ b/packages/lsp/src/types.ts @@ -0,0 +1,27 @@ +/** + * Core type definitions for the LSP server. + * @module + */ + +/** Operation kind extracted from tagged template tag name. */ +export type OperationKind = "query" | "mutation" | "subscription" | "fragment"; + +/** A single tagged template extracted from a TypeScript file. */ +export type ExtractedTemplate = { + /** Byte offset range of GraphQL content within TS source (excludes backticks). */ + readonly contentRange: { readonly start: number; readonly end: number }; + /** Resolved schema name from gql.{schemaName}. */ + readonly schemaName: string; + /** Operation kind from tag name. */ + readonly kind: OperationKind; + /** Raw GraphQL content between backticks. */ + readonly content: string; +}; + +/** Per-document state maintained by the document manager. */ +export type DocumentState = { + readonly uri: string; + readonly version: number; + readonly source: string; + readonly templates: readonly ExtractedTemplate[]; +}; From 002a85d0acf5f5126bb6ab0c5f99667a04657ab8 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 15:56:46 +0900 Subject: [PATCH 03/16] feat(lsp): add bidirectional position mapping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement TS file ↔ GraphQL content position conversion with computeLineOffsets, positionToOffset, offsetToPosition utilities. Co-Authored-By: Claude Opus 4.5 --- packages/lsp/src/position-mapping.test.ts | 136 ++++++++++++++++++++++ packages/lsp/src/position-mapping.ts | 87 ++++++++++++++ 2 files changed, 223 insertions(+) create mode 100644 packages/lsp/src/position-mapping.test.ts create mode 100644 packages/lsp/src/position-mapping.ts diff --git a/packages/lsp/src/position-mapping.test.ts b/packages/lsp/src/position-mapping.test.ts new file mode 100644 index 000000000..4c9760ff7 --- /dev/null +++ b/packages/lsp/src/position-mapping.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test"; +import { + computeLineOffsets, + createPositionMapper, + offsetToPosition, + positionToOffset, +} from "./position-mapping"; + +describe("computeLineOffsets", () => { + test("single line returns [0]", () => { + expect(computeLineOffsets("hello")).toEqual([0]); + }); + + test("multi-line computes correct offsets", () => { + expect(computeLineOffsets("ab\ncd\nef")).toEqual([0, 3, 6]); + }); + + test("empty string returns [0]", () => { + expect(computeLineOffsets("")).toEqual([0]); + }); +}); + +describe("positionToOffset / offsetToPosition", () => { + const source = "ab\ncd\nef"; + const offsets = computeLineOffsets(source); + + test("line 0, char 0 -> offset 0", () => { + expect(positionToOffset(offsets, { line: 0, character: 0 })).toBe(0); + }); + + test("line 1, char 1 -> offset 4", () => { + expect(positionToOffset(offsets, { line: 1, character: 1 })).toBe(4); + }); + + test("offset 4 -> line 1, char 1", () => { + expect(offsetToPosition(offsets, 4)).toEqual({ line: 1, character: 1 }); + }); + + test("offset 0 -> line 0, char 0", () => { + expect(offsetToPosition(offsets, 0)).toEqual({ line: 0, character: 0 }); + }); + + test("out-of-bounds line returns -1", () => { + expect(positionToOffset(offsets, { line: 10, character: 0 })).toBe(-1); + }); +}); + +describe("createPositionMapper", () => { + test("maps TS position to GraphQL position for multi-line template", () => { + // Simulates: + // line 0: const q = gql.default(({ query }) => query` + // line 1: query GetUser { + // line 2: user { id } + // line 3: } + // line 4: `); + const tsSource = 'const q = gql.default(({ query }) => query`\n query GetUser {\n user { id }\n }\n`);'; + const contentStartOffset = 43; // position after the backtick on line 0 + const graphqlContent = "\n query GetUser {\n user { id }\n }\n"; + + const mapper = createPositionMapper({ tsSource, contentStartOffset, graphqlContent }); + + // TS line 1, char 2 ("query") -> GraphQL line 1, char 2 + const gqlPos = mapper.tsToGraphql({ line: 1, character: 2 }); + expect(gqlPos).toEqual({ line: 1, character: 2 }); + + // TS line 2, char 4 ("user") -> GraphQL line 2, char 4 + const gqlPos2 = mapper.tsToGraphql({ line: 2, character: 4 }); + expect(gqlPos2).toEqual({ line: 2, character: 4 }); + }); + + test("maps GraphQL position back to TS position", () => { + const tsSource = 'const q = gql.default(({ query }) => query`\n query GetUser {\n user { id }\n }\n`);'; + const contentStartOffset = 43; + const graphqlContent = "\n query GetUser {\n user { id }\n }\n"; + + const mapper = createPositionMapper({ tsSource, contentStartOffset, graphqlContent }); + + // GraphQL line 1, char 2 -> TS line 1, char 2 + const tsPos = mapper.graphqlToTs({ line: 1, character: 2 }); + expect(tsPos).toEqual({ line: 1, character: 2 }); + }); + + test("round-trip: tsToGraphql -> graphqlToTs preserves position", () => { + const tsSource = 'const q = gql.default(({ query }) => query`\n query GetUser {\n user { id }\n }\n`);'; + const contentStartOffset = 44; + const graphqlContent = "\n query GetUser {\n user { id }\n }\n"; + + const mapper = createPositionMapper({ tsSource, contentStartOffset, graphqlContent }); + + const original = { line: 2, character: 4 }; + const gql = mapper.tsToGraphql(original); + expect(gql).not.toBeNull(); + const roundTrip = mapper.graphqlToTs(gql!); + expect(roundTrip).toEqual(original); + }); + + test("returns null for position before template", () => { + const tsSource = 'const q = gql.default(({ query }) => query`\nquery { user }\n`);'; + const contentStartOffset = 43; + const graphqlContent = "\nquery { user }\n"; + + const mapper = createPositionMapper({ tsSource, contentStartOffset, graphqlContent }); + + // Line 0, char 0 is before the template + const result = mapper.tsToGraphql({ line: 0, character: 0 }); + expect(result).toBeNull(); + }); + + test("returns null for position after template", () => { + const tsSource = 'const q = gql.default(({ query }) => query`\nquery { user }\n`);'; + const contentStartOffset = 43; + const graphqlContent = "\nquery { user }\n"; + + const mapper = createPositionMapper({ tsSource, contentStartOffset, graphqlContent }); + + // Position well past end of template + const result = mapper.tsToGraphql({ line: 0, character: 100 }); + expect(result).toBeNull(); + }); + + test("single-line template mapping", () => { + const tsSource = 'const q = gql.default(({ query }) => query`query { user { id } }`);'; + const contentStartOffset = 43; + const graphqlContent = "query { user { id } }"; + + const mapper = createPositionMapper({ tsSource, contentStartOffset, graphqlContent }); + + // TS line 0, char 43 -> GraphQL line 0, char 0 + const gqlPos = mapper.tsToGraphql({ line: 0, character: 43 }); + expect(gqlPos).toEqual({ line: 0, character: 0 }); + + // TS line 0, char 49 -> GraphQL line 0, char 6 ("{ user") + const gqlPos2 = mapper.tsToGraphql({ line: 0, character: 49 }); + expect(gqlPos2).toEqual({ line: 0, character: 6 }); + }); +}); diff --git a/packages/lsp/src/position-mapping.ts b/packages/lsp/src/position-mapping.ts new file mode 100644 index 000000000..57af4893f --- /dev/null +++ b/packages/lsp/src/position-mapping.ts @@ -0,0 +1,87 @@ +/** + * Bidirectional position mapping between TypeScript file and GraphQL content. + * @module + */ + +/** 0-indexed line and character position. */ +export type Position = { + readonly line: number; + readonly character: number; +}; + +export type PositionMapper = { + /** Map TS file position to GraphQL content position. Returns null if outside template. */ + readonly tsToGraphql: (tsPosition: Position) => Position | null; + /** Map GraphQL content position back to TS file position. */ + readonly graphqlToTs: (gqlPosition: Position) => Position; +}; + +export type PositionMapperInput = { + readonly tsSource: string; + /** Byte offset of the first character of GraphQL content (after opening backtick). */ + readonly contentStartOffset: number; + readonly graphqlContent: string; +}; + +/** Compute byte offsets for the start of each line in the source text. */ +export const computeLineOffsets = (source: string): readonly number[] => { + const offsets: number[] = [0]; + for (let i = 0; i < source.length; i++) { + if (source.charCodeAt(i) === 10) { + // newline + offsets.push(i + 1); + } + } + return offsets; +}; + +/** Convert a Position to a byte offset within the source text. */ +export const positionToOffset = (lineOffsets: readonly number[], position: Position): number => { + if (position.line < 0 || position.line >= lineOffsets.length) { + return -1; + } + return lineOffsets[position.line]! + position.character; +}; + +/** Convert a byte offset to a Position within the source text. */ +export const offsetToPosition = (lineOffsets: readonly number[], offset: number): Position => { + // Binary search for the line containing the offset + let low = 0; + let high = lineOffsets.length - 1; + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (lineOffsets[mid]! <= offset) { + low = mid; + } else { + high = mid - 1; + } + } + return { line: low, character: offset - lineOffsets[low]! }; +}; + +/** Create a bidirectional position mapper between TS file and GraphQL content. */ +export const createPositionMapper = (input: PositionMapperInput): PositionMapper => { + const { tsSource, contentStartOffset, graphqlContent } = input; + const tsLineOffsets = computeLineOffsets(tsSource); + const gqlLineOffsets = computeLineOffsets(graphqlContent); + + return { + tsToGraphql: (tsPosition) => { + const tsOffset = positionToOffset(tsLineOffsets, tsPosition); + if (tsOffset < 0) { + return null; + } + const gqlOffset = tsOffset - contentStartOffset; + if (gqlOffset < 0 || gqlOffset > graphqlContent.length) { + return null; + } + return offsetToPosition(gqlLineOffsets, gqlOffset); + }, + + graphqlToTs: (gqlPosition) => { + const gqlOffset = positionToOffset(gqlLineOffsets, gqlPosition); + const tsOffset = gqlOffset + contentStartOffset; + return offsetToPosition(tsLineOffsets, tsOffset); + }, + }; +}; From d8d1963cbaabcf1a322d9d35bf0e23fbe670f14d Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 15:57:29 +0900 Subject: [PATCH 04/16] feat(lsp): add Fragment Arguments preprocessor Strip Fragment Arguments RFC syntax by replacing argument lists with equal-length whitespace, preserving line/column alignment for graphql-js compatibility. Co-Authored-By: Claude Opus 4.5 --- .../src/fragment-args-preprocessor.test.ts | 115 ++++++++++++++++ .../lsp/src/fragment-args-preprocessor.ts | 126 ++++++++++++++++++ 2 files changed, 241 insertions(+) create mode 100644 packages/lsp/src/fragment-args-preprocessor.test.ts create mode 100644 packages/lsp/src/fragment-args-preprocessor.ts diff --git a/packages/lsp/src/fragment-args-preprocessor.test.ts b/packages/lsp/src/fragment-args-preprocessor.test.ts new file mode 100644 index 000000000..cf5dc7a9f --- /dev/null +++ b/packages/lsp/src/fragment-args-preprocessor.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from "bun:test"; +import { preprocessFragmentArgs } from "./fragment-args-preprocessor"; + +describe("preprocessFragmentArgs", () => { + test("no-op for standard GraphQL without Fragment Arguments", () => { + const content = ` + fragment UserFields on User { + id + name + } +`; + const result = preprocessFragmentArgs(content); + expect(result.preprocessed).toBe(content); + expect(result.modified).toBe(false); + }); + + test("strips fragment definition arguments", () => { + const content = `fragment UserProfile($showEmail: Boolean = false) on User { + id + name + email @include(if: $showEmail) +}`; + const result = preprocessFragmentArgs(content); + expect(result.modified).toBe(true); + // Arguments replaced with spaces, "on User" preserved + expect(result.preprocessed).toContain("fragment UserProfile"); + expect(result.preprocessed).toContain("on User"); + expect(result.preprocessed).not.toContain("$showEmail: Boolean"); + // Length preserved + expect(result.preprocessed.length).toBe(content.length); + }); + + test("strips fragment spread arguments", () => { + const content = `query GetUser($id: ID!) { + user(id: $id) { + ...UserProfile(showEmail: true) + } +}`; + const result = preprocessFragmentArgs(content); + expect(result.modified).toBe(true); + expect(result.preprocessed).toContain("...UserProfile"); + expect(result.preprocessed).not.toContain("showEmail: true"); + expect(result.preprocessed.length).toBe(content.length); + }); + + test("preserves line/column alignment", () => { + const content = `fragment Foo($x: Boolean) on Bar { + id +}`; + const result = preprocessFragmentArgs(content); + // Split into lines and verify line count is preserved + const originalLines = content.split("\n"); + const processedLines = result.preprocessed.split("\n"); + expect(processedLines.length).toBe(originalLines.length); + // Each line has the same length + for (let i = 0; i < originalLines.length; i++) { + expect(processedLines[i]!.length).toBe(originalLines[i]!.length); + } + }); + + test("handles nested parens in default values", () => { + const content = 'fragment Foo($items: [String!]! = ["a", "b"]) on Bar {\n id\n}'; + const result = preprocessFragmentArgs(content); + expect(result.modified).toBe(true); + expect(result.preprocessed).toContain("on Bar"); + expect(result.preprocessed).not.toContain("[String!]!"); + expect(result.preprocessed.length).toBe(content.length); + }); + + test("handles multiple fragments", () => { + const content = `fragment A($x: Int) on Foo { + id +} + +fragment B($y: String = "hi") on Bar { + name +}`; + const result = preprocessFragmentArgs(content); + expect(result.modified).toBe(true); + expect(result.preprocessed).toContain("on Foo"); + expect(result.preprocessed).toContain("on Bar"); + expect(result.preprocessed).not.toContain("$x: Int"); + expect(result.preprocessed).not.toContain("$y: String"); + expect(result.preprocessed.length).toBe(content.length); + }); + + test("handles fragment with multiple arguments", () => { + const content = "fragment F($a: Int!, $b: String = \"default\") on T {\n f\n}"; + const result = preprocessFragmentArgs(content); + expect(result.modified).toBe(true); + expect(result.preprocessed).toContain("on T"); + expect(result.preprocessed).not.toContain("$a: Int!"); + expect(result.preprocessed.length).toBe(content.length); + }); + + test("does not strip field arguments", () => { + const content = `query { + user(id: "123") { + id + } +}`; + const result = preprocessFragmentArgs(content); + expect(result.preprocessed).toBe(content); + expect(result.modified).toBe(false); + }); + + test("does not strip directive arguments", () => { + const content = `fragment F on User { + email @include(if: $show) +}`; + const result = preprocessFragmentArgs(content); + expect(result.preprocessed).toBe(content); + expect(result.modified).toBe(false); + }); +}); diff --git a/packages/lsp/src/fragment-args-preprocessor.ts b/packages/lsp/src/fragment-args-preprocessor.ts new file mode 100644 index 000000000..c3c981be0 --- /dev/null +++ b/packages/lsp/src/fragment-args-preprocessor.ts @@ -0,0 +1,126 @@ +/** + * Preprocessor for Fragment Arguments RFC syntax. + * + * Strips fragment argument declarations and spread arguments by replacing + * them with equal-length whitespace to preserve line/column alignment. + * + * @module + */ + +/** Result of fragment arguments preprocessing. */ +export type PreprocessResult = { + /** Content with Fragment Arguments syntax replaced by whitespace. */ + readonly preprocessed: string; + /** Whether any preprocessing was applied. */ + readonly modified: boolean; +}; + +/** + * Find the matching closing parenthesis for a balanced group. + * Handles nested parentheses, string literals, and comments. + * Returns the index of the closing ')' or -1 if not found. + */ +const findMatchingParen = (content: string, openIndex: number): number => { + let depth = 1; + let inString: false | '"' | "'" = false; + + for (let i = openIndex + 1; i < content.length; i++) { + const ch = content[i]!; + + if (inString) { + if (ch === inString && content[i - 1] !== "\\") { + inString = false; + } + continue; + } + + if (ch === '"' || ch === "'") { + inString = ch; + continue; + } + + if (ch === "(") { + depth++; + } else if (ch === ")") { + depth--; + if (depth === 0) { + return i; + } + } + } + + return -1; +}; + +/** + * Replace a range [start, end] (inclusive) with spaces, preserving newlines. + */ +const replaceWithSpaces = (content: string, start: number, end: number): string => { + let result = content.slice(0, start); + for (let i = start; i <= end; i++) { + result += content[i] === "\n" ? "\n" : " "; + } + result += content.slice(end + 1); + return result; +}; + +// Pattern: fragment Name( at word boundary, not followed by "on" +const FRAGMENT_DEF_PATTERN = /\bfragment\s+(\w+)\s*\(/g; + +// Pattern: ...FragmentName( +const FRAGMENT_SPREAD_PATTERN = /\.\.\.(\w+)\s*\(/g; + +/** + * Preprocess Fragment Arguments RFC syntax by replacing argument lists with spaces. + * + * Transforms: + * - `fragment UserProfile($showEmail: Boolean = false) on User` → `fragment UserProfile on User` + * - `...UserProfile(showEmail: true)` → `...UserProfile ` + */ +export const preprocessFragmentArgs = (content: string): PreprocessResult => { + let result = content; + let modified = false; + + // Pass 1: Fragment definition arguments + // Match "fragment Name(" and find the matching ")" to strip + let match: RegExpExecArray | null; + FRAGMENT_DEF_PATTERN.lastIndex = 0; + // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop + while ((match = FRAGMENT_DEF_PATTERN.exec(result)) !== null) { + const openParenIndex = match.index + match[0].length - 1; + + // Check that the next non-whitespace after ")" is "on" (to distinguish from non-fragment-args parens) + const closeParenIndex = findMatchingParen(result, openParenIndex); + if (closeParenIndex === -1) { + continue; + } + + // Verify this is a fragment definition (followed by "on") + const afterParen = result.slice(closeParenIndex + 1).trimStart(); + if (!afterParen.startsWith("on")) { + continue; + } + + result = replaceWithSpaces(result, openParenIndex, closeParenIndex); + modified = true; + // Reset regex since we modified the string (positions may shift) + FRAGMENT_DEF_PATTERN.lastIndex = 0; + } + + // Pass 2: Fragment spread arguments + FRAGMENT_SPREAD_PATTERN.lastIndex = 0; + // biome-ignore lint/suspicious/noAssignInExpressions: standard regex exec loop + while ((match = FRAGMENT_SPREAD_PATTERN.exec(result)) !== null) { + const openParenIndex = match.index + match[0].length - 1; + const closeParenIndex = findMatchingParen(result, openParenIndex); + if (closeParenIndex === -1) { + continue; + } + + result = replaceWithSpaces(result, openParenIndex, closeParenIndex); + modified = true; + FRAGMENT_SPREAD_PATTERN.lastIndex = 0; + } + + return { preprocessed: result, modified }; +}; From 7ca93ca7dfc0904a050dd06a6db2e0ed52520628 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 16:00:31 +0900 Subject: [PATCH 05/16] feat(lsp): add schema resolver with caching and reload Implements SchemaResolver that maps schema names to GraphQLSchema objects using loadSchema/hashSchema from @soda-gql/codegen. Supports eager loading, per-schema reload, and full reload for file watcher integration. Co-Authored-By: Claude Opus 4.5 --- packages/lsp/src/schema-resolver.test.ts | 108 ++++++++++++++++++ packages/lsp/src/schema-resolver.ts | 103 +++++++++++++++++ .../lsp/test/fixtures/schemas/admin.graphql | 17 +++ .../lsp/test/fixtures/schemas/default.graphql | 18 +++ 4 files changed, 246 insertions(+) create mode 100644 packages/lsp/src/schema-resolver.test.ts create mode 100644 packages/lsp/src/schema-resolver.ts create mode 100644 packages/lsp/test/fixtures/schemas/admin.graphql create mode 100644 packages/lsp/test/fixtures/schemas/default.graphql diff --git a/packages/lsp/src/schema-resolver.test.ts b/packages/lsp/src/schema-resolver.test.ts new file mode 100644 index 000000000..3af4ea680 --- /dev/null +++ b/packages/lsp/src/schema-resolver.test.ts @@ -0,0 +1,108 @@ +import { resolve } from "node:path"; +import { describe, expect, test } from "bun:test"; +import type { ResolvedSodaGqlConfig } from "@soda-gql/config"; +import { createSchemaResolver } from "./schema-resolver"; + +const fixturesDir = resolve(import.meta.dir, "../test/fixtures/schemas"); + +const createTestConfig = ( + schemas: Record, +): ResolvedSodaGqlConfig => + ({ + analyzer: "swc" as const, + baseDir: fixturesDir, + outdir: resolve(fixturesDir, "graphql-system"), + graphqlSystemAliases: ["@/graphql-system"], + include: ["src/**/*.ts"], + exclude: [], + schemas: Object.fromEntries( + Object.entries(schemas).map(([name, config]) => [ + name, + { + schema: config.schema, + inject: { scalars: resolve(fixturesDir, "scalars.ts") }, + defaultInputDepth: 3, + inputDepthOverrides: {}, + }, + ]), + ), + styles: { importExtension: false }, + codegen: { chunkSize: 100 }, + plugins: {}, + }) as ResolvedSodaGqlConfig; + +describe("createSchemaResolver", () => { + test("loads a single schema successfully", () => { + const config = createTestConfig({ + default: { schema: [resolve(fixturesDir, "default.graphql")] }, + }); + const result = createSchemaResolver(config); + expect(result.isOk()).toBe(true); + + const resolver = result._unsafeUnwrap(); + const entry = resolver.getSchema("default"); + expect(entry).toBeDefined(); + expect(entry!.name).toBe("default"); + expect(entry!.hash).toBeTruthy(); + expect(entry!.schema).toBeDefined(); + }); + + test("loads multiple schemas", () => { + const config = createTestConfig({ + default: { schema: [resolve(fixturesDir, "default.graphql")] }, + admin: { schema: [resolve(fixturesDir, "admin.graphql")] }, + }); + const result = createSchemaResolver(config); + expect(result.isOk()).toBe(true); + + const resolver = result._unsafeUnwrap(); + expect(resolver.getSchemaNames()).toEqual(["default", "admin"]); + expect(resolver.getSchema("default")).toBeDefined(); + expect(resolver.getSchema("admin")).toBeDefined(); + }); + + test("returns undefined for unknown schema name", () => { + const config = createTestConfig({ + default: { schema: [resolve(fixturesDir, "default.graphql")] }, + }); + const resolver = createSchemaResolver(config)._unsafeUnwrap(); + expect(resolver.getSchema("unknown")).toBeUndefined(); + }); + + test("reloadAll re-reads schemas from disk", () => { + const config = createTestConfig({ + default: { schema: [resolve(fixturesDir, "default.graphql")] }, + }); + const resolver = createSchemaResolver(config)._unsafeUnwrap(); + const hashBefore = resolver.getSchema("default")!.hash; + + const reloadResult = resolver.reloadAll(); + expect(reloadResult.isOk()).toBe(true); + + const hashAfter = resolver.getSchema("default")!.hash; + expect(hashAfter).toBe(hashBefore); + }); + + test("reloadSchema returns error for unknown schema", () => { + const config = createTestConfig({ + default: { schema: [resolve(fixturesDir, "default.graphql")] }, + }); + const resolver = createSchemaResolver(config)._unsafeUnwrap(); + const result = resolver.reloadSchema("nonexistent"); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.code).toBe("SCHEMA_NOT_CONFIGURED"); + } + }); + + test("returns error when schema file does not exist", () => { + const config = createTestConfig({ + broken: { schema: [resolve(fixturesDir, "nonexistent.graphql")] }, + }); + const result = createSchemaResolver(config); + expect(result.isErr()).toBe(true); + if (result.isErr()) { + expect(result.error.code).toBe("SCHEMA_LOAD_FAILED"); + } + }); +}); diff --git a/packages/lsp/src/schema-resolver.ts b/packages/lsp/src/schema-resolver.ts new file mode 100644 index 000000000..834e8cb19 --- /dev/null +++ b/packages/lsp/src/schema-resolver.ts @@ -0,0 +1,103 @@ +/** + * Schema resolver: maps schema names to GraphQLSchema objects. + * @module + */ + +import { resolve } from "node:path"; +import { loadSchema, hashSchema } from "@soda-gql/codegen"; +import type { ResolvedSodaGqlConfig } from "@soda-gql/config"; +import { buildASTSchema, type DocumentNode } from "graphql"; +import { err, ok, type Result } from "neverthrow"; +import { lspErrors } from "./errors"; +import type { LspError } from "./errors"; + +/** Cached schema entry. */ +export type SchemaEntry = { + readonly name: string; + readonly schema: import("graphql").GraphQLSchema; + readonly documentNode: DocumentNode; + readonly hash: string; +}; + +export type SchemaResolver = { + readonly getSchema: (schemaName: string) => SchemaEntry | undefined; + readonly getSchemaNames: () => readonly string[]; + readonly reloadSchema: (schemaName: string) => Result; + readonly reloadAll: () => Result; +}; + +const loadAndBuildSchema = ( + schemaName: string, + schemaPaths: readonly string[], +): Result => { + const resolvedPaths = schemaPaths.map((s) => resolve(s)); + const loadResult = loadSchema(resolvedPaths); + if (loadResult.isErr()) { + return err(lspErrors.schemaLoadFailed(schemaName, loadResult.error.message)); + } + + // Cast needed because codegen may use a different graphql version's DocumentNode + const documentNode = loadResult.value as unknown as DocumentNode; + const hash = hashSchema(loadResult.value); + + try { + const schema = buildASTSchema(documentNode); + return ok({ name: schemaName, schema, documentNode, hash }); + } catch (e) { + return err( + lspErrors.schemaBuildFailed( + schemaName, + e instanceof Error ? e.message : String(e), + e, + ), + ); + } +}; + +/** Create a schema resolver from config. Loads all schemas eagerly. */ +export const createSchemaResolver = ( + config: ResolvedSodaGqlConfig, +): Result => { + const cache = new Map(); + + // Load all schemas from config + for (const [name, schemaConfig] of Object.entries(config.schemas)) { + const result = loadAndBuildSchema(name, schemaConfig.schema); + if (result.isErr()) { + return err(result.error); + } + cache.set(name, result.value); + } + + const resolver: SchemaResolver = { + getSchema: (schemaName) => cache.get(schemaName), + + getSchemaNames: () => [...cache.keys()], + + reloadSchema: (schemaName) => { + const schemaConfig = config.schemas[schemaName]; + if (!schemaConfig) { + return err(lspErrors.schemaNotConfigured(schemaName)); + } + const result = loadAndBuildSchema(schemaName, schemaConfig.schema); + if (result.isErr()) { + return err(result.error); + } + cache.set(schemaName, result.value); + return ok(result.value); + }, + + reloadAll: () => { + for (const [name, schemaConfig] of Object.entries(config.schemas)) { + const result = loadAndBuildSchema(name, schemaConfig.schema); + if (result.isErr()) { + return err(result.error); + } + cache.set(name, result.value); + } + return ok(undefined); + }, + }; + + return ok(resolver); +}; diff --git a/packages/lsp/test/fixtures/schemas/admin.graphql b/packages/lsp/test/fixtures/schemas/admin.graphql new file mode 100644 index 000000000..dd8c74522 --- /dev/null +++ b/packages/lsp/test/fixtures/schemas/admin.graphql @@ -0,0 +1,17 @@ +type Query { + auditLogs(limit: Int): [AuditLog!]! + adminUser(id: ID!): AdminUser +} + +type AdminUser { + id: ID! + username: String! + role: String! +} + +type AuditLog { + id: ID! + action: String! + timestamp: String! + actor: AdminUser! +} diff --git a/packages/lsp/test/fixtures/schemas/default.graphql b/packages/lsp/test/fixtures/schemas/default.graphql new file mode 100644 index 000000000..61320f31c --- /dev/null +++ b/packages/lsp/test/fixtures/schemas/default.graphql @@ -0,0 +1,18 @@ +type Query { + user(id: ID!): User + users: [User!]! +} + +type User { + id: ID! + name: String! + email: String + posts: [Post!]! +} + +type Post { + id: ID! + title: String! + body: String! + author: User! +} From 0326d032ecd37bfae01f68e7b4d9b1e6d5ac2453 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 16:02:23 +0900 Subject: [PATCH 06/16] feat(lsp): add document manager with SWC-based template extraction Implements DocumentManager that parses TypeScript files with SWC and extracts GraphQL tagged templates from gql.{schemaName} callback bodies. Handles expression bodies, block bodies, and metadata chaining patterns. Co-Authored-By: Claude Opus 4.5 --- packages/lsp/src/document-manager.test.ts | 160 +++++++++ packages/lsp/src/document-manager.ts | 322 ++++++++++++++++++ packages/lsp/src/index.ts | 7 + packages/lsp/test/fixtures/block-body.ts | 5 + .../lsp/test/fixtures/fragment-with-args.ts | 7 + .../lsp/test/fixtures/metadata-chaining.ts | 3 + packages/lsp/test/fixtures/multi-schema.ts | 5 + packages/lsp/test/fixtures/no-templates.ts | 4 + packages/lsp/test/fixtures/simple-query.ts | 3 + 9 files changed, 516 insertions(+) create mode 100644 packages/lsp/src/document-manager.test.ts create mode 100644 packages/lsp/src/document-manager.ts create mode 100644 packages/lsp/test/fixtures/block-body.ts create mode 100644 packages/lsp/test/fixtures/fragment-with-args.ts create mode 100644 packages/lsp/test/fixtures/metadata-chaining.ts create mode 100644 packages/lsp/test/fixtures/multi-schema.ts create mode 100644 packages/lsp/test/fixtures/no-templates.ts create mode 100644 packages/lsp/test/fixtures/simple-query.ts diff --git a/packages/lsp/src/document-manager.test.ts b/packages/lsp/src/document-manager.test.ts new file mode 100644 index 000000000..e277e179e --- /dev/null +++ b/packages/lsp/src/document-manager.test.ts @@ -0,0 +1,160 @@ +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { createGraphqlSystemIdentifyHelper } from "@soda-gql/builder"; +import type { ResolvedSodaGqlConfig } from "@soda-gql/config"; +import { createDocumentManager } from "./document-manager"; + +const fixturesDir = resolve(import.meta.dir, "../test/fixtures"); + +const createTestConfig = (): ResolvedSodaGqlConfig => + ({ + analyzer: "swc" as const, + baseDir: fixturesDir, + outdir: resolve(fixturesDir, "graphql-system"), + graphqlSystemAliases: ["@/graphql-system"], + include: ["**/*.ts"], + exclude: [], + schemas: { + default: { + schema: [resolve(fixturesDir, "schemas/default.graphql")], + inject: { scalars: resolve(fixturesDir, "scalars.ts") }, + defaultInputDepth: 3, + inputDepthOverrides: {}, + }, + admin: { + schema: [resolve(fixturesDir, "schemas/admin.graphql")], + inject: { scalars: resolve(fixturesDir, "scalars.ts") }, + defaultInputDepth: 3, + inputDepthOverrides: {}, + }, + }, + styles: { importExtension: false }, + codegen: { chunkSize: 100 }, + plugins: {}, + }) as ResolvedSodaGqlConfig; + +const readFixture = (name: string): string => readFileSync(resolve(fixturesDir, name), "utf-8"); + +describe("createDocumentManager", () => { + const config = createTestConfig(); + const helper = createGraphqlSystemIdentifyHelper(config); + + test("extracts single query template", () => { + const dm = createDocumentManager(helper); + const source = readFixture("simple-query.ts"); + const state = dm.update(resolve(fixturesDir, "simple-query.ts"), 1, source); + + expect(state.templates).toHaveLength(1); + const t = state.templates[0]!; + expect(t.schemaName).toBe("default"); + expect(t.kind).toBe("query"); + expect(t.content).toContain("query GetUser"); + expect(t.content).toContain("user(id: $id)"); + }); + + test("extracts multi-schema templates", () => { + const dm = createDocumentManager(helper); + const source = readFixture("multi-schema.ts"); + const state = dm.update(resolve(fixturesDir, "multi-schema.ts"), 1, source); + + expect(state.templates).toHaveLength(2); + expect(state.templates[0]!.schemaName).toBe("default"); + expect(state.templates[0]!.kind).toBe("query"); + expect(state.templates[1]!.schemaName).toBe("admin"); + expect(state.templates[1]!.kind).toBe("query"); + }); + + test("extracts fragment template", () => { + const dm = createDocumentManager(helper); + const source = readFixture("fragment-with-args.ts"); + const state = dm.update(resolve(fixturesDir, "fragment-with-args.ts"), 1, source); + + expect(state.templates).toHaveLength(1); + const t = state.templates[0]!; + expect(t.schemaName).toBe("default"); + expect(t.kind).toBe("fragment"); + expect(t.content).toContain("fragment UserFields"); + }); + + test("handles metadata chaining", () => { + const dm = createDocumentManager(helper); + const source = readFixture("metadata-chaining.ts"); + const state = dm.update(resolve(fixturesDir, "metadata-chaining.ts"), 1, source); + + expect(state.templates).toHaveLength(1); + const t = state.templates[0]!; + expect(t.schemaName).toBe("default"); + expect(t.kind).toBe("query"); + expect(t.content).toContain("query GetUser"); + }); + + test("handles block body with return", () => { + const dm = createDocumentManager(helper); + const source = readFixture("block-body.ts"); + const state = dm.update(resolve(fixturesDir, "block-body.ts"), 1, source); + + expect(state.templates).toHaveLength(1); + const t = state.templates[0]!; + expect(t.schemaName).toBe("default"); + expect(t.kind).toBe("query"); + expect(t.content).toContain("query GetUser"); + }); + + test("returns empty templates for file without templates", () => { + const dm = createDocumentManager(helper); + const source = readFixture("no-templates.ts"); + const state = dm.update(resolve(fixturesDir, "no-templates.ts"), 1, source); + + expect(state.templates).toHaveLength(0); + }); + + test("findTemplateAtOffset returns correct template", () => { + const dm = createDocumentManager(helper); + const source = readFixture("simple-query.ts"); + const uri = resolve(fixturesDir, "simple-query.ts"); + dm.update(uri, 1, source); + + const state = dm.get(uri)!; + const t = state.templates[0]!; + + // Offset in the middle of the template content + const midOffset = Math.floor((t.contentRange.start + t.contentRange.end) / 2); + const found = dm.findTemplateAtOffset(uri, midOffset); + expect(found).toBeDefined(); + expect(found!.content).toBe(t.content); + }); + + test("findTemplateAtOffset returns undefined outside template", () => { + const dm = createDocumentManager(helper); + const source = readFixture("simple-query.ts"); + const uri = resolve(fixturesDir, "simple-query.ts"); + dm.update(uri, 1, source); + + // Offset 0 is before any template + const found = dm.findTemplateAtOffset(uri, 0); + expect(found).toBeUndefined(); + }); + + test("contentRange correctly maps back to source", () => { + const dm = createDocumentManager(helper); + const source = readFixture("simple-query.ts"); + const uri = resolve(fixturesDir, "simple-query.ts"); + const state = dm.update(uri, 1, source); + + const t = state.templates[0]!; + const extracted = source.slice(t.contentRange.start, t.contentRange.end); + expect(extracted).toBe(t.content); + }); + + test("remove clears document state", () => { + const dm = createDocumentManager(helper); + const source = readFixture("simple-query.ts"); + const uri = resolve(fixturesDir, "simple-query.ts"); + dm.update(uri, 1, source); + + expect(dm.get(uri)).toBeDefined(); + dm.remove(uri); + expect(dm.get(uri)).toBeUndefined(); + }); +}); diff --git a/packages/lsp/src/document-manager.ts b/packages/lsp/src/document-manager.ts new file mode 100644 index 000000000..1ac075b18 --- /dev/null +++ b/packages/lsp/src/document-manager.ts @@ -0,0 +1,322 @@ +/** + * Document manager: tracks open documents and extracts tagged templates using SWC. + * @module + */ + +import type { GraphqlSystemIdentifyHelper } from "@soda-gql/builder"; +import { parseSync } from "@swc/core"; +import type { + ArrowFunctionExpression, + CallExpression, + Expression, + ImportDeclaration, + MemberExpression, + Module, + TaggedTemplateExpression, +} from "@swc/types"; +import type { DocumentState, ExtractedTemplate, OperationKind } from "./types"; + +export type DocumentManager = { + readonly update: (uri: string, version: number, source: string) => DocumentState; + readonly get: (uri: string) => DocumentState | undefined; + readonly remove: (uri: string) => void; + readonly findTemplateAtOffset: (uri: string, offset: number) => ExtractedTemplate | undefined; +}; + +const OPERATION_KINDS = new Set(["query", "mutation", "subscription", "fragment"]); + +const isOperationKind = (value: string): value is OperationKind => OPERATION_KINDS.has(value); + +/** + * Collect gql identifiers from import declarations. + * Adapted from builder's collectGqlIdentifiers pattern. + */ +const collectGqlIdentifiers = (module: Module, filePath: string, helper: GraphqlSystemIdentifyHelper): ReadonlySet => { + const identifiers = new Set(); + + for (const item of module.body) { + let declaration: ImportDeclaration | null = null; + + if (item.type === "ImportDeclaration") { + declaration = item; + } else if ( + "declaration" in item && + item.declaration && + // biome-ignore lint/suspicious/noExplicitAny: SWC AST type checking + (item.declaration as any).type === "ImportDeclaration" + ) { + declaration = item.declaration as unknown as ImportDeclaration; + } + + if (!declaration) { + continue; + } + + if (!helper.isGraphqlSystemImportSpecifier({ filePath, specifier: declaration.source.value })) { + continue; + } + + for (const specifier of declaration.specifiers ?? []) { + if (specifier.type === "ImportSpecifier") { + const imported = specifier.imported ? specifier.imported.value : specifier.local.value; + if (imported === "gql" && !specifier.imported) { + identifiers.add(specifier.local.value); + } + } + } + } + + return identifiers; +}; + +/** + * Check if a call expression is a gql.{schemaName}(...) call. + * Returns the schema name if it is, null otherwise. + */ +const getGqlCallSchemaName = (identifiers: ReadonlySet, call: CallExpression): string | null => { + const callee = call.callee; + if (callee.type !== "MemberExpression") { + return null; + } + + const member = callee as MemberExpression; + if (member.object.type !== "Identifier" || !identifiers.has(member.object.value)) { + return null; + } + + if (member.property.type !== "Identifier") { + return null; + } + + const firstArg = call.arguments[0]; + if (!firstArg?.expression || firstArg.expression.type !== "ArrowFunctionExpression") { + return null; + } + + return member.property.value; +}; + +/** + * Extract templates from a gql callback's arrow function body. + * Handles both expression bodies and block bodies with return statements. + */ +const extractTemplatesFromCallback = ( + arrow: ArrowFunctionExpression, + schemaName: string, + source: string, + spanOffset: number, +): ExtractedTemplate[] => { + const templates: ExtractedTemplate[] = []; + + const processExpression = (expr: Expression): void => { + // Direct tagged template: query`...` + if (expr.type === "TaggedTemplateExpression") { + const tagged = expr as TaggedTemplateExpression; + extractFromTaggedTemplate(tagged, schemaName, source, spanOffset, templates); + return; + } + + // Metadata chaining: query`...`({ metadata: {} }) + if (expr.type === "CallExpression") { + const call = expr as CallExpression; + if (call.callee.type === "TaggedTemplateExpression") { + extractFromTaggedTemplate(call.callee as TaggedTemplateExpression, schemaName, source, spanOffset, templates); + } + } + }; + + // Expression body: ({ query }) => query`...` + if (arrow.body.type !== "BlockStatement") { + processExpression(arrow.body as Expression); + return templates; + } + + // Block body: ({ query }) => { return query`...`; } + for (const stmt of arrow.body.stmts) { + if (stmt.type === "ReturnStatement" && stmt.argument) { + processExpression(stmt.argument); + } + } + + return templates; +}; + +const extractFromTaggedTemplate = ( + tagged: TaggedTemplateExpression, + schemaName: string, + source: string, + spanOffset: number, + templates: ExtractedTemplate[], +): void => { + // Tag must be an identifier matching an operation kind + if (tagged.tag.type !== "Identifier") { + return; + } + + const kind = tagged.tag.value; + if (!isOperationKind(kind)) { + return; + } + + // Skip templates with expressions (interpolation) + if (tagged.template.expressions.length > 0) { + return; + } + + const quasi = tagged.template.quasis[0]; + if (!quasi) { + return; + } + + const content = quasi.cooked ?? quasi.raw; + + // Compute content range from quasi span + // The quasi span includes the backticks, so we need the inner content offset + const quasiStart = quasi.span.start - spanOffset; + const quasiEnd = quasi.span.end - spanOffset; + + // The quasi span in SWC points to the content between backticks + // Verify by checking source alignment + const contentStart = quasiStart; + const contentEnd = quasiEnd; + + templates.push({ + contentRange: { start: contentStart, end: contentEnd }, + schemaName, + kind, + content, + }); +}; + +/** + * Walk AST to find gql calls and extract templates. + * Adapted from builder's unwrapMethodChains + visit pattern. + */ +// biome-ignore lint/suspicious/noExplicitAny: SWC AST traversal +const walkAndExtract = (node: any, identifiers: ReadonlySet, source: string, spanOffset: number): ExtractedTemplate[] => { + const templates: ExtractedTemplate[] = []; + + // biome-ignore lint/suspicious/noExplicitAny: SWC AST traversal + const visit = (n: any): void => { + if (!n || typeof n !== "object") { + return; + } + + if (n.type === "CallExpression") { + // Check if this is a gql call (possibly wrapped in method chains) + const gqlCall = findGqlCall(identifiers, n); + if (gqlCall) { + const schemaName = getGqlCallSchemaName(identifiers, gqlCall); + if (schemaName) { + const arrow = gqlCall.arguments[0]?.expression as ArrowFunctionExpression; + templates.push(...extractTemplatesFromCallback(arrow, schemaName, source, spanOffset)); + } + return; // Don't recurse into gql calls + } + } + + // Recurse into all array and object properties + if (Array.isArray(n)) { + for (const item of n) { + visit(item); + } + return; + } + + for (const key of Object.keys(n)) { + if (key === "span" || key === "type") { + continue; + } + const value = n[key]; + if (value && typeof value === "object") { + visit(value); + } + } + }; + + visit(node); + return templates; +}; + +/** + * Find the innermost gql call, unwrapping method chains like .attach(). + */ +// biome-ignore lint/suspicious/noExplicitAny: SWC AST type +const findGqlCall = (identifiers: ReadonlySet, node: any): CallExpression | null => { + if (!node || node.type !== "CallExpression") { + return null; + } + + if (getGqlCallSchemaName(identifiers, node) !== null) { + return node; + } + + const callee = node.callee; + if (callee.type !== "MemberExpression") { + return null; + } + + return findGqlCall(identifiers, callee.object); +}; + +/** Create a document manager that tracks open documents and extracts templates. */ +export const createDocumentManager = (helper: GraphqlSystemIdentifyHelper): DocumentManager => { + const cache = new Map(); + + const extractTemplates = (uri: string, source: string): readonly ExtractedTemplate[] => { + const isTsx = uri.endsWith(".tsx"); + + let program: ReturnType; + try { + program = parseSync(source, { + syntax: "typescript", + tsx: isTsx, + decorators: false, + dynamicImport: true, + }); + } catch { + // Parse failure — return no templates + return []; + } + + if (program.type !== "Module") { + return []; + } + + // SWC's BytePos counter accumulates across parseSync calls within the same process. + const spanOffset = program.span.end - source.length + 1; + + // Convert URI to a file path for the helper + const filePath = uri.startsWith("file://") ? decodeURIComponent(uri.slice(7)) : uri; + + const gqlIdentifiers = collectGqlIdentifiers(program, filePath, helper); + if (gqlIdentifiers.size === 0) { + return []; + } + + return walkAndExtract(program, gqlIdentifiers, source, spanOffset); + }; + + return { + update: (uri, version, source) => { + const templates = extractTemplates(uri, source); + const state: DocumentState = { uri, version, source, templates }; + cache.set(uri, state); + return state; + }, + + get: (uri) => cache.get(uri), + + remove: (uri) => { + cache.delete(uri); + }, + + findTemplateAtOffset: (uri, offset) => { + const state = cache.get(uri); + if (!state) { + return undefined; + } + return state.templates.find((t) => offset >= t.contentRange.start && offset <= t.contentRange.end); + }, + }; +}; diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts index bb009bd07..6e080eefd 100644 --- a/packages/lsp/src/index.ts +++ b/packages/lsp/src/index.ts @@ -3,3 +3,10 @@ export type { LspError, LspErrorCode, LspResult } from "./errors"; export { lspErrors } from "./errors"; export type { DocumentState, ExtractedTemplate, OperationKind } from "./types"; +export type { DocumentManager } from "./document-manager"; +export { createDocumentManager } from "./document-manager"; +export type { SchemaResolver, SchemaEntry } from "./schema-resolver"; +export { createSchemaResolver } from "./schema-resolver"; +export { preprocessFragmentArgs } from "./fragment-args-preprocessor"; +export type { PositionMapper } from "./position-mapping"; +export { createPositionMapper } from "./position-mapping"; diff --git a/packages/lsp/test/fixtures/block-body.ts b/packages/lsp/test/fixtures/block-body.ts new file mode 100644 index 000000000..232022522 --- /dev/null +++ b/packages/lsp/test/fixtures/block-body.ts @@ -0,0 +1,5 @@ +import { gql } from "@/graphql-system"; + +export const GetUser = gql.default(({ query }) => { + return query`query GetUser { user(id: "1") { id name } }`; +}); diff --git a/packages/lsp/test/fixtures/fragment-with-args.ts b/packages/lsp/test/fixtures/fragment-with-args.ts new file mode 100644 index 000000000..90b464d61 --- /dev/null +++ b/packages/lsp/test/fixtures/fragment-with-args.ts @@ -0,0 +1,7 @@ +import { gql } from "@/graphql-system"; + +export const UserFields = gql.default(({ fragment }) => fragment`fragment UserFields($showEmail: Boolean = false) on User { + id + name + email @include(if: $showEmail) +}`); diff --git a/packages/lsp/test/fixtures/metadata-chaining.ts b/packages/lsp/test/fixtures/metadata-chaining.ts new file mode 100644 index 000000000..516a06aab --- /dev/null +++ b/packages/lsp/test/fixtures/metadata-chaining.ts @@ -0,0 +1,3 @@ +import { gql } from "@/graphql-system"; + +export const GetUser = gql.default(({ query }) => query`query GetUser { user(id: "1") { id } }`({ metadata: {} })); diff --git a/packages/lsp/test/fixtures/multi-schema.ts b/packages/lsp/test/fixtures/multi-schema.ts new file mode 100644 index 000000000..c5c14570d --- /dev/null +++ b/packages/lsp/test/fixtures/multi-schema.ts @@ -0,0 +1,5 @@ +import { gql } from "@/graphql-system"; + +export const GetUser = gql.default(({ query }) => query`query GetUser { user(id: "1") { id name } }`); + +export const GetAuditLogs = gql.admin(({ query }) => query`query GetAuditLogs { auditLogs(limit: 10) { id action } }`); diff --git a/packages/lsp/test/fixtures/no-templates.ts b/packages/lsp/test/fixtures/no-templates.ts new file mode 100644 index 000000000..939ceb291 --- /dev/null +++ b/packages/lsp/test/fixtures/no-templates.ts @@ -0,0 +1,4 @@ +import { gql } from "@/graphql-system"; + +// This file has no tagged template usage - just an import +const unused = "no templates here"; diff --git a/packages/lsp/test/fixtures/simple-query.ts b/packages/lsp/test/fixtures/simple-query.ts new file mode 100644 index 000000000..f5822d396 --- /dev/null +++ b/packages/lsp/test/fixtures/simple-query.ts @@ -0,0 +1,3 @@ +import { gql } from "@/graphql-system"; + +export const GetUser = gql.default(({ query }) => query`query GetUser($id: ID!) { user(id: $id) { id name } }`); From 52a562cc5e57b152f4d562b11f93eb4f9f69d7cb Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 16:04:46 +0900 Subject: [PATCH 07/16] feat(lsp): add diagnostics, completion, and hover handlers Implements three LSP feature handlers using graphql-language-service: - computeTemplateDiagnostics: validates GraphQL against schema with TS position mapping - handleCompletion: provides field/argument autocompletion suggestions - handleHover: shows type information on hover All handlers integrate fragment-args preprocessing and position mapping. Co-Authored-By: Claude Opus 4.5 --- packages/lsp/src/handlers/completion.test.ts | 80 ++++++++++++ packages/lsp/src/handlers/completion.ts | 41 +++++++ packages/lsp/src/handlers/diagnostics.test.ts | 116 ++++++++++++++++++ packages/lsp/src/handlers/diagnostics.ts | 54 ++++++++ packages/lsp/src/handlers/hover.test.ts | 74 +++++++++++ packages/lsp/src/handlers/hover.ts | 58 +++++++++ packages/lsp/src/position-mapping.ts | 6 + 7 files changed, 429 insertions(+) create mode 100644 packages/lsp/src/handlers/completion.test.ts create mode 100644 packages/lsp/src/handlers/completion.ts create mode 100644 packages/lsp/src/handlers/diagnostics.test.ts create mode 100644 packages/lsp/src/handlers/diagnostics.ts create mode 100644 packages/lsp/src/handlers/hover.test.ts create mode 100644 packages/lsp/src/handlers/hover.ts diff --git a/packages/lsp/src/handlers/completion.test.ts b/packages/lsp/src/handlers/completion.test.ts new file mode 100644 index 000000000..343cd1e06 --- /dev/null +++ b/packages/lsp/src/handlers/completion.test.ts @@ -0,0 +1,80 @@ +import { resolve } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { buildASTSchema } from "graphql"; +import { loadSchema } from "@soda-gql/codegen"; +import type { DocumentNode } from "graphql"; +import { handleCompletion } from "./completion"; +import type { ExtractedTemplate } from "../types"; + +const fixturesDir = resolve(import.meta.dir, "../../test/fixtures"); + +const loadTestSchema = (name: string) => { + const result = loadSchema([resolve(fixturesDir, `schemas/${name}.graphql`)]); + if (result.isErr()) { + throw new Error(`Failed to load schema: ${result.error.message}`); + } + return buildASTSchema(result.value as unknown as DocumentNode); +}; + +const defaultSchema = loadTestSchema("default"); + +describe("handleCompletion", () => { + test("returns field suggestions inside selection set", () => { + // Cursor after "{ users { " — should suggest User fields + const content = "query { users { } }"; + const tsSource = `import { gql } from "@/graphql-system";\n\ngql.default(({ query }) => query\`${content}\`);`; + const contentStart = tsSource.indexOf(content); + + const template: ExtractedTemplate = { + contentRange: { start: contentStart, end: contentStart + content.length }, + schemaName: "default", + kind: "query", + content, + }; + + // Position cursor after "{ users { " (inside the inner selection set) + // In the content: "query { users { } }" the cursor is at position 16 (the space before }) + const cursorInContent = content.indexOf("{ } }") + 2; // After "{ " + const cursorInTs = contentStart + cursorInContent; + + // Convert byte offset to line/character + const lines = tsSource.slice(0, cursorInTs).split("\n"); + const tsPosition = { line: lines.length - 1, character: lines[lines.length - 1]!.length }; + + const items = handleCompletion({ + template, + schema: defaultSchema, + tsSource, + tsPosition, + }); + + expect(items.length).toBeGreaterThan(0); + // Should suggest User fields like id, name, email, posts + const labels = items.map((item) => item.label); + expect(labels).toContain("id"); + expect(labels).toContain("name"); + }); + + test("returns empty for position outside template", () => { + const content = "query { users { id } }"; + const tsSource = `import { gql } from "@/graphql-system";\n\ngql.default(({ query }) => query\`${content}\`);`; + const contentStart = tsSource.indexOf(content); + + const template: ExtractedTemplate = { + contentRange: { start: contentStart, end: contentStart + content.length }, + schemaName: "default", + kind: "query", + content, + }; + + // Position at line 0, char 0 — outside the template + const items = handleCompletion({ + template, + schema: defaultSchema, + tsSource, + tsPosition: { line: 0, character: 0 }, + }); + + expect(items).toHaveLength(0); + }); +}); diff --git a/packages/lsp/src/handlers/completion.ts b/packages/lsp/src/handlers/completion.ts new file mode 100644 index 000000000..d765f84c0 --- /dev/null +++ b/packages/lsp/src/handlers/completion.ts @@ -0,0 +1,41 @@ +/** + * Completion handler: provides GraphQL autocompletion in templates. + * @module + */ + +import type { GraphQLSchema } from "graphql"; +import { getAutocompleteSuggestions } from "graphql-language-service"; +import type { CompletionItem } from "vscode-languageserver-types"; +import { preprocessFragmentArgs } from "../fragment-args-preprocessor"; +import { createPositionMapper, toIPosition, type Position } from "../position-mapping"; +import type { ExtractedTemplate } from "../types"; + +export type HandleCompletionInput = { + readonly template: ExtractedTemplate; + readonly schema: GraphQLSchema; + readonly tsSource: string; + /** LSP Position (0-indexed line, 0-indexed character) in the TS file. */ + readonly tsPosition: Position; +}; + +/** Handle a completion request for a GraphQL template. */ +export const handleCompletion = (input: HandleCompletionInput): CompletionItem[] => { + const { template, schema, tsSource, tsPosition } = input; + const { preprocessed } = preprocessFragmentArgs(template.content); + + const mapper = createPositionMapper({ + tsSource, + contentStartOffset: template.contentRange.start, + graphqlContent: template.content, + }); + + const gqlPosition = mapper.tsToGraphql(tsPosition); + if (!gqlPosition) { + return []; + } + + // graphql-language-service expects IPosition with line/character (0-indexed) + const suggestions = getAutocompleteSuggestions(schema, preprocessed, toIPosition(gqlPosition)); + + return suggestions as CompletionItem[]; +}; diff --git a/packages/lsp/src/handlers/diagnostics.test.ts b/packages/lsp/src/handlers/diagnostics.test.ts new file mode 100644 index 000000000..633dcec42 --- /dev/null +++ b/packages/lsp/src/handlers/diagnostics.test.ts @@ -0,0 +1,116 @@ +import { resolve } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { buildASTSchema } from "graphql"; +import { loadSchema } from "@soda-gql/codegen"; +import type { DocumentNode } from "graphql"; +import { computeTemplateDiagnostics } from "./diagnostics"; +import type { ExtractedTemplate } from "../types"; + +const fixturesDir = resolve(import.meta.dir, "../../test/fixtures"); + +const loadTestSchema = (name: string) => { + const result = loadSchema([resolve(fixturesDir, `schemas/${name}.graphql`)]); + if (result.isErr()) { + throw new Error(`Failed to load schema: ${result.error.message}`); + } + return buildASTSchema(result.value as unknown as DocumentNode); +}; + +const defaultSchema = loadTestSchema("default"); + +describe("computeTemplateDiagnostics", () => { + test("no diagnostics for valid query", () => { + const tsSource = 'import { gql } from "@/graphql-system";\n\ngql.default(({ query }) => query`query { users { id name } }`);'; + const content = "query { users { id name } }"; + const contentStart = tsSource.indexOf(content); + + const template: ExtractedTemplate = { + contentRange: { start: contentStart, end: contentStart + content.length }, + schemaName: "default", + kind: "query", + content, + }; + + const diagnostics = computeTemplateDiagnostics({ + template, + schema: defaultSchema, + tsSource, + }); + + expect(diagnostics).toHaveLength(0); + }); + + test("reports validation error for unknown field", () => { + const tsSource = 'import { gql } from "@/graphql-system";\n\ngql.default(({ query }) => query`query { users { id unknownField } }`);'; + const content = "query { users { id unknownField } }"; + const contentStart = tsSource.indexOf(content); + + const template: ExtractedTemplate = { + contentRange: { start: contentStart, end: contentStart + content.length }, + schemaName: "default", + kind: "query", + content, + }; + + const diagnostics = computeTemplateDiagnostics({ + template, + schema: defaultSchema, + tsSource, + }); + + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics.some((d) => d.message.includes("unknownField"))).toBe(true); + // Verify source is set + expect(diagnostics[0]!.source).toBe("soda-gql"); + }); + + test("diagnostic positions are in TS file coordinates", () => { + // Put the template on line 2 (0-indexed) so we can verify position mapping + const tsSource = 'import { gql } from "@/graphql-system";\n\ngql.default(({ query }) => query`query { users { id unknownField } }`);'; + const content = "query { users { id unknownField } }"; + const contentStart = tsSource.indexOf(content); + + const template: ExtractedTemplate = { + contentRange: { start: contentStart, end: contentStart + content.length }, + schemaName: "default", + kind: "query", + content, + }; + + const diagnostics = computeTemplateDiagnostics({ + template, + schema: defaultSchema, + tsSource, + }); + + expect(diagnostics.length).toBeGreaterThan(0); + // The diagnostic should be on line 2 (0-indexed) since the template is on line 2 + const diag = diagnostics.find((d) => d.message.includes("unknownField")); + expect(diag).toBeDefined(); + expect(diag!.range.start.line).toBe(2); + }); + + test("handles Fragment Arguments without false positives", () => { + const content = "fragment UserFields($showEmail: Boolean = false) on User {\n id\n name\n}"; + const tsSource = `import { gql } from "@/graphql-system";\n\ngql.default(({ fragment }) => fragment\`${content}\`);`; + const contentStart = tsSource.indexOf(content); + + const template: ExtractedTemplate = { + contentRange: { start: contentStart, end: contentStart + content.length }, + schemaName: "default", + kind: "fragment", + content, + }; + + const diagnostics = computeTemplateDiagnostics({ + template, + schema: defaultSchema, + tsSource, + }); + + // Fragment args syntax should be stripped before validation, so no false positives + // about the parenthesized args + const argErrors = diagnostics.filter((d) => d.message.includes("$showEmail")); + expect(argErrors).toHaveLength(0); + }); +}); diff --git a/packages/lsp/src/handlers/diagnostics.ts b/packages/lsp/src/handlers/diagnostics.ts new file mode 100644 index 000000000..a75c52bc2 --- /dev/null +++ b/packages/lsp/src/handlers/diagnostics.ts @@ -0,0 +1,54 @@ +/** + * Diagnostics handler: validates GraphQL templates against schema. + * @module + */ + +import type { GraphQLSchema } from "graphql"; +import { getDiagnostics } from "graphql-language-service"; +import type { Diagnostic } from "vscode-languageserver-types"; +import { preprocessFragmentArgs } from "../fragment-args-preprocessor"; +import { createPositionMapper } from "../position-mapping"; +import type { ExtractedTemplate } from "../types"; + +export type ComputeDiagnosticsInput = { + readonly template: ExtractedTemplate; + readonly schema: GraphQLSchema; + readonly tsSource: string; +}; + +/** Compute LSP diagnostics for a single GraphQL template. */ +export const computeTemplateDiagnostics = (input: ComputeDiagnosticsInput): readonly Diagnostic[] => { + const { template, schema, tsSource } = input; + const { preprocessed } = preprocessFragmentArgs(template.content); + + const mapper = createPositionMapper({ + tsSource, + contentStartOffset: template.contentRange.start, + graphqlContent: template.content, + }); + + // getDiagnostics returns Diagnostic[] with graphql-content-relative positions + const gqlDiagnostics = getDiagnostics(preprocessed, schema); + + return gqlDiagnostics.map((diag): Diagnostic => { + // Map GraphQL positions to TS file positions + const startTs = mapper.graphqlToTs({ + line: diag.range.start.line, + character: diag.range.start.character, + }); + const endTs = mapper.graphqlToTs({ + line: diag.range.end.line, + character: diag.range.end.character, + }); + + return { + range: { + start: { line: startTs.line, character: startTs.character }, + end: { line: endTs.line, character: endTs.character }, + }, + message: diag.message, + severity: diag.severity, + source: "soda-gql", + }; + }); +}; diff --git a/packages/lsp/src/handlers/hover.test.ts b/packages/lsp/src/handlers/hover.test.ts new file mode 100644 index 000000000..edfef6cab --- /dev/null +++ b/packages/lsp/src/handlers/hover.test.ts @@ -0,0 +1,74 @@ +import { resolve } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { buildASTSchema } from "graphql"; +import { loadSchema } from "@soda-gql/codegen"; +import type { DocumentNode } from "graphql"; +import { handleHover } from "./hover"; +import type { ExtractedTemplate } from "../types"; + +const fixturesDir = resolve(import.meta.dir, "../../test/fixtures"); + +const loadTestSchema = (name: string) => { + const result = loadSchema([resolve(fixturesDir, `schemas/${name}.graphql`)]); + if (result.isErr()) { + throw new Error(`Failed to load schema: ${result.error.message}`); + } + return buildASTSchema(result.value as unknown as DocumentNode); +}; + +const defaultSchema = loadTestSchema("default"); + +describe("handleHover", () => { + test("returns type info on field", () => { + const content = "query { users { id name } }"; + const tsSource = `import { gql } from "@/graphql-system";\n\ngql.default(({ query }) => query\`${content}\`);`; + const contentStart = tsSource.indexOf(content); + + const template: ExtractedTemplate = { + contentRange: { start: contentStart, end: contentStart + content.length }, + schemaName: "default", + kind: "query", + content, + }; + + // Position cursor in the middle of "users" field (offset by 2 to be inside the name) + const usersIdx = content.indexOf("users") + 2; + const cursorInTs = contentStart + usersIdx; + const lines = tsSource.slice(0, cursorInTs).split("\n"); + const tsPosition = { line: lines.length - 1, character: lines[lines.length - 1]!.length }; + + const hover = handleHover({ + template, + schema: defaultSchema, + tsSource, + tsPosition, + }); + + expect(hover).not.toBeNull(); + // The hover should contain type information about the users field + const value = typeof hover!.contents === "string" ? hover!.contents : (hover!.contents as { value: string }).value; + expect(value).toBeTruthy(); + }); + + test("returns null outside template", () => { + const content = "query { users { id } }"; + const tsSource = `import { gql } from "@/graphql-system";\n\ngql.default(({ query }) => query\`${content}\`);`; + const contentStart = tsSource.indexOf(content); + + const template: ExtractedTemplate = { + contentRange: { start: contentStart, end: contentStart + content.length }, + schemaName: "default", + kind: "query", + content, + }; + + const hover = handleHover({ + template, + schema: defaultSchema, + tsSource, + tsPosition: { line: 0, character: 0 }, + }); + + expect(hover).toBeNull(); + }); +}); diff --git a/packages/lsp/src/handlers/hover.ts b/packages/lsp/src/handlers/hover.ts new file mode 100644 index 000000000..0aabc94a0 --- /dev/null +++ b/packages/lsp/src/handlers/hover.ts @@ -0,0 +1,58 @@ +/** + * Hover handler: provides type information on hover in GraphQL templates. + * @module + */ + +import type { GraphQLSchema } from "graphql"; +import { getHoverInformation } from "graphql-language-service"; +import type { Hover, MarkupContent } from "vscode-languageserver-types"; +import { preprocessFragmentArgs } from "../fragment-args-preprocessor"; +import { createPositionMapper, toIPosition, type Position } from "../position-mapping"; +import type { ExtractedTemplate } from "../types"; + +export type HandleHoverInput = { + readonly template: ExtractedTemplate; + readonly schema: GraphQLSchema; + readonly tsSource: string; + /** LSP Position (0-indexed line, 0-indexed character) in the TS file. */ + readonly tsPosition: Position; +}; + +/** Handle a hover request for a GraphQL template. */ +export const handleHover = (input: HandleHoverInput): Hover | null => { + const { template, schema, tsSource, tsPosition } = input; + const { preprocessed } = preprocessFragmentArgs(template.content); + + const mapper = createPositionMapper({ + tsSource, + contentStartOffset: template.contentRange.start, + graphqlContent: template.content, + }); + + const gqlPosition = mapper.tsToGraphql(tsPosition); + if (!gqlPosition) { + return null; + } + + const hoverInfo = getHoverInformation(schema, preprocessed, toIPosition(gqlPosition), undefined, { + useMarkdown: true, + }); + + // getHoverInformation returns Hover['contents'] which can be string, MarkupContent, or MarkedString[] + if (!hoverInfo || hoverInfo === "" || (Array.isArray(hoverInfo) && hoverInfo.length === 0)) { + return null; + } + + // Normalize to MarkupContent + let contents: MarkupContent; + if (typeof hoverInfo === "string") { + contents = { kind: "markdown", value: hoverInfo }; + } else if (Array.isArray(hoverInfo)) { + const parts = hoverInfo.map((item) => (typeof item === "string" ? item : item.value)); + contents = { kind: "markdown", value: parts.join("\n\n") }; + } else { + contents = hoverInfo as MarkupContent; + } + + return { contents }; +}; diff --git a/packages/lsp/src/position-mapping.ts b/packages/lsp/src/position-mapping.ts index 57af4893f..8703cc366 100644 --- a/packages/lsp/src/position-mapping.ts +++ b/packages/lsp/src/position-mapping.ts @@ -59,6 +59,12 @@ export const offsetToPosition = (lineOffsets: readonly number[], offset: number) return { line: low, character: offset - lineOffsets[low]! }; }; +/** Convert a Position to an IPosition compatible with graphql-language-service. */ +export const toIPosition = (pos: Position): { line: number; character: number; setLine: (l: number) => void; setCharacter: (c: number) => void; lessThanOrEqualTo: (other: Position) => boolean } => { + const p = { line: pos.line, character: pos.character, setLine: (l: number) => { p.line = l; }, setCharacter: (c: number) => { p.character = c; }, lessThanOrEqualTo: (other: Position) => p.line < other.line || (p.line === other.line && p.character <= other.character) }; + return p; +}; + /** Create a bidirectional position mapper between TS file and GraphQL content. */ export const createPositionMapper = (input: PositionMapperInput): PositionMapper => { const { tsSource, contentStartOffset, graphqlContent } = input; From 9ea945805d3debc7a7c6e2593748ebfacb2ed12d Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 16:06:24 +0900 Subject: [PATCH 08/16] feat(lsp): add LSP server wiring and integration tests Implements createLspServer that wires all components together via vscode-languageserver: config loading, schema resolution, document management, and feature handlers (diagnostics, completion, hover). Includes end-to-end integration tests validating the full flow. Co-Authored-By: Claude Opus 4.5 --- packages/lsp/src/index.ts | 2 + packages/lsp/src/server.ts | 232 ++++++++++++++++++ .../lsp/test/integration/end-to-end.test.ts | 157 ++++++++++++ 3 files changed, 391 insertions(+) create mode 100644 packages/lsp/src/server.ts create mode 100644 packages/lsp/test/integration/end-to-end.test.ts diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts index 6e080eefd..32a983ef8 100644 --- a/packages/lsp/src/index.ts +++ b/packages/lsp/src/index.ts @@ -10,3 +10,5 @@ export { createSchemaResolver } from "./schema-resolver"; export { preprocessFragmentArgs } from "./fragment-args-preprocessor"; export type { PositionMapper } from "./position-mapping"; export { createPositionMapper } from "./position-mapping"; +export { createLspServer } from "./server"; +export type { LspServerOptions } from "./server"; diff --git a/packages/lsp/src/server.ts b/packages/lsp/src/server.ts new file mode 100644 index 000000000..27ba62f2b --- /dev/null +++ b/packages/lsp/src/server.ts @@ -0,0 +1,232 @@ +/** + * LSP server: wires all components together via vscode-languageserver. + * @module + */ + +import { + type Connection, + type InitializeResult, + TextDocumentSyncKind, + createConnection, + ProposedFeatures, + DidChangeWatchedFilesNotification, + FileChangeType, + type TextDocumentChangeEvent, +} from "vscode-languageserver/node"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import { TextDocuments } from "vscode-languageserver/node"; +import { loadConfigFrom } from "@soda-gql/config"; +import { createGraphqlSystemIdentifyHelper } from "@soda-gql/builder"; +import { createSchemaResolver } from "./schema-resolver"; +import { createDocumentManager } from "./document-manager"; +import { computeTemplateDiagnostics } from "./handlers/diagnostics"; +import { handleCompletion } from "./handlers/completion"; +import { handleHover } from "./handlers/hover"; +import type { SchemaResolver } from "./schema-resolver"; +import type { DocumentManager } from "./document-manager"; + +export type LspServerOptions = { + readonly connection?: Connection; +}; + +export const createLspServer = (options?: LspServerOptions) => { + const connection = options?.connection ?? createConnection(ProposedFeatures.all); + const documents = new TextDocuments(TextDocument); + + let schemaResolver: SchemaResolver | undefined; + let documentManager: DocumentManager | undefined; + + const publishDiagnosticsForDocument = (uri: string) => { + if (!schemaResolver || !documentManager) { + return; + } + + const state = documentManager.get(uri); + if (!state) { + connection.sendDiagnostics({ uri, diagnostics: [] }); + return; + } + + const allDiagnostics = state.templates.flatMap((template) => { + const entry = schemaResolver!.getSchema(template.schemaName); + if (!entry) { + return []; + } + return [...computeTemplateDiagnostics({ template, schema: entry.schema, tsSource: state.source })]; + }); + + connection.sendDiagnostics({ uri, diagnostics: allDiagnostics }); + }; + + const publishDiagnosticsForAllOpen = () => { + for (const doc of documents.all()) { + publishDiagnosticsForDocument(doc.uri); + } + }; + + connection.onInitialize((params): InitializeResult => { + const rootUri = params.rootUri ?? params.rootPath; + if (!rootUri) { + connection.window.showErrorMessage("soda-gql LSP: no workspace root provided"); + return { capabilities: {} }; + } + + // Convert URI to path + const rootPath = rootUri.startsWith("file://") ? decodeURIComponent(rootUri.slice(7)) : rootUri; + + const configResult = loadConfigFrom(rootPath); + if (configResult.isErr()) { + connection.window.showErrorMessage(`soda-gql LSP: failed to load config: ${configResult.error.message}`); + return { capabilities: {} }; + } + + const config = configResult.value; + const helper = createGraphqlSystemIdentifyHelper(config); + + const resolverResult = createSchemaResolver(config); + if (resolverResult.isErr()) { + connection.window.showErrorMessage(`soda-gql LSP: ${resolverResult.error.message}`); + return { capabilities: {} }; + } + + schemaResolver = resolverResult.value; + documentManager = createDocumentManager(helper); + + return { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Full, + completionProvider: { + triggerCharacters: ["{", "(", ":", "@", "$", " ", "\n", "."], + }, + hoverProvider: true, + }, + }; + }); + + connection.onInitialized(() => { + // Register for file watcher on .graphql files + connection.client.register(DidChangeWatchedFilesNotification.type, { + watchers: [{ globPattern: "**/*.graphql" }], + }); + }); + + documents.onDidChangeContent((change: TextDocumentChangeEvent) => { + if (!documentManager) { + return; + } + documentManager.update(change.document.uri, change.document.version, change.document.getText()); + publishDiagnosticsForDocument(change.document.uri); + }); + + documents.onDidClose((change: TextDocumentChangeEvent) => { + if (!documentManager) { + return; + } + documentManager.remove(change.document.uri); + connection.sendDiagnostics({ uri: change.document.uri, diagnostics: [] }); + }); + + connection.onCompletion((params) => { + if (!documentManager || !schemaResolver) { + return []; + } + + const template = documentManager.findTemplateAtOffset( + params.textDocument.uri, + // We need to convert LSP position to offset + positionToOffset(documents.get(params.textDocument.uri)?.getText() ?? "", params.position), + ); + + if (!template) { + return []; + } + + const entry = schemaResolver.getSchema(template.schemaName); + if (!entry) { + return []; + } + + const doc = documents.get(params.textDocument.uri); + if (!doc) { + return []; + } + + return handleCompletion({ + template, + schema: entry.schema, + tsSource: doc.getText(), + tsPosition: { line: params.position.line, character: params.position.character }, + }); + }); + + connection.onHover((params) => { + if (!documentManager || !schemaResolver) { + return null; + } + + const doc = documents.get(params.textDocument.uri); + if (!doc) { + return null; + } + + const template = documentManager.findTemplateAtOffset( + params.textDocument.uri, + positionToOffset(doc.getText(), params.position), + ); + + if (!template) { + return null; + } + + const entry = schemaResolver.getSchema(template.schemaName); + if (!entry) { + return null; + } + + return handleHover({ + template, + schema: entry.schema, + tsSource: doc.getText(), + tsPosition: { line: params.position.line, character: params.position.character }, + }); + }); + + connection.onDidChangeWatchedFiles((_params) => { + if (!schemaResolver) { + return; + } + + // Check if any .graphql files changed + const graphqlChanged = _params.changes.some( + (change) => change.uri.endsWith(".graphql") && (change.type === FileChangeType.Changed || change.type === FileChangeType.Created), + ); + + if (graphqlChanged) { + const result = schemaResolver.reloadAll(); + if (result.isOk()) { + publishDiagnosticsForAllOpen(); + } + } + }); + + documents.listen(connection); + + return { + start: () => { + connection.listen(); + }, + }; +}; + +/** Convert LSP Position to byte offset in source text. */ +const positionToOffset = (source: string, position: { line: number; character: number }): number => { + let line = 0; + let offset = 0; + while (line < position.line && offset < source.length) { + if (source.charCodeAt(offset) === 10) { + line++; + } + offset++; + } + return offset + position.character; +}; diff --git a/packages/lsp/test/integration/end-to-end.test.ts b/packages/lsp/test/integration/end-to-end.test.ts new file mode 100644 index 000000000..eb8089156 --- /dev/null +++ b/packages/lsp/test/integration/end-to-end.test.ts @@ -0,0 +1,157 @@ +/** + * Integration test: exercises the full flow from document parsing through + * diagnostics, completion, and hover using real schemas and TypeScript sources. + */ + +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { describe, expect, test } from "bun:test"; +import { createGraphqlSystemIdentifyHelper } from "@soda-gql/builder"; +import type { ResolvedSodaGqlConfig } from "@soda-gql/config"; +import { createDocumentManager } from "../../src/document-manager"; +import { createSchemaResolver } from "../../src/schema-resolver"; +import { computeTemplateDiagnostics } from "../../src/handlers/diagnostics"; +import { handleCompletion } from "../../src/handlers/completion"; +import { handleHover } from "../../src/handlers/hover"; + +const fixturesDir = resolve(import.meta.dir, "../fixtures"); + +const createTestConfig = (): ResolvedSodaGqlConfig => + ({ + analyzer: "swc" as const, + baseDir: fixturesDir, + outdir: resolve(fixturesDir, "graphql-system"), + graphqlSystemAliases: ["@/graphql-system"], + include: ["**/*.ts"], + exclude: [], + schemas: { + default: { + schema: [resolve(fixturesDir, "schemas/default.graphql")], + inject: { scalars: resolve(fixturesDir, "scalars.ts") }, + defaultInputDepth: 3, + inputDepthOverrides: {}, + }, + admin: { + schema: [resolve(fixturesDir, "schemas/admin.graphql")], + inject: { scalars: resolve(fixturesDir, "scalars.ts") }, + defaultInputDepth: 3, + inputDepthOverrides: {}, + }, + }, + styles: { importExtension: false }, + codegen: { chunkSize: 100 }, + plugins: {}, + }) as ResolvedSodaGqlConfig; + +describe("end-to-end LSP flow", () => { + const config = createTestConfig(); + const helper = createGraphqlSystemIdentifyHelper(config); + const schemaResolver = createSchemaResolver(config)._unsafeUnwrap(); + + test("full diagnostics flow: valid document produces no errors", () => { + const dm = createDocumentManager(helper); + const source = readFileSync(resolve(fixturesDir, "simple-query.ts"), "utf-8"); + const uri = resolve(fixturesDir, "simple-query.ts"); + const state = dm.update(uri, 1, source); + + const allDiags = state.templates.flatMap((template) => { + const entry = schemaResolver.getSchema(template.schemaName); + if (!entry) return []; + return [...computeTemplateDiagnostics({ template, schema: entry.schema, tsSource: state.source })]; + }); + + expect(allDiags).toHaveLength(0); + }); + + test("full diagnostics flow: invalid field produces error", () => { + const dm = createDocumentManager(helper); + const source = `import { gql } from "@/graphql-system"; + +export const Bad = gql.default(({ query }) => query\`query { users { id badField } }\`);`; + const uri = resolve(fixturesDir, "bad-query.ts"); + const state = dm.update(uri, 1, source); + + expect(state.templates).toHaveLength(1); + + const entry = schemaResolver.getSchema("default")!; + const diagnostics = computeTemplateDiagnostics({ + template: state.templates[0]!, + schema: entry.schema, + tsSource: source, + }); + + expect(diagnostics.length).toBeGreaterThan(0); + expect(diagnostics.some((d) => d.message.includes("badField"))).toBe(true); + }); + + test("full completion flow: suggests fields for multi-schema document", () => { + const dm = createDocumentManager(helper); + const source = readFileSync(resolve(fixturesDir, "multi-schema.ts"), "utf-8"); + const uri = resolve(fixturesDir, "multi-schema.ts"); + const state = dm.update(uri, 1, source); + + // Get the admin template + const adminTemplate = state.templates.find((t) => t.schemaName === "admin"); + expect(adminTemplate).toBeDefined(); + + const entry = schemaResolver.getSchema("admin")!; + + // Position cursor inside the selection set of auditLogs + const content = adminTemplate!.content; + const cursorInContent = content.indexOf("{ id") + 2; + const cursorInSource = adminTemplate!.contentRange.start + cursorInContent; + const lines = source.slice(0, cursorInSource).split("\n"); + const tsPosition = { line: lines.length - 1, character: lines[lines.length - 1]!.length }; + + const items = handleCompletion({ + template: adminTemplate!, + schema: entry.schema, + tsSource: source, + tsPosition, + }); + + expect(items.length).toBeGreaterThan(0); + // Should suggest AuditLog fields + const labels = items.map((i) => i.label); + expect(labels).toContain("id"); + expect(labels).toContain("action"); + }); + + test("full hover flow: shows type info", () => { + const dm = createDocumentManager(helper); + const source = readFileSync(resolve(fixturesDir, "simple-query.ts"), "utf-8"); + const uri = resolve(fixturesDir, "simple-query.ts"); + const state = dm.update(uri, 1, source); + + const template = state.templates[0]!; + const entry = schemaResolver.getSchema("default")!; + + // Position cursor on "user" inside the content (offset by 1 to be inside field name) + const content = template.content; + const userIdx = content.indexOf("user(") + 1; + const cursorInSource = template.contentRange.start + userIdx; + const lines = source.slice(0, cursorInSource).split("\n"); + const tsPosition = { line: lines.length - 1, character: lines[lines.length - 1]!.length }; + + const hover = handleHover({ + template, + schema: entry.schema, + tsSource: source, + tsPosition, + }); + + expect(hover).not.toBeNull(); + }); + + test("schema resolver provides correct schema for each schema name", () => { + expect(schemaResolver.getSchemaNames()).toEqual(["default", "admin"]); + + const defaultEntry = schemaResolver.getSchema("default"); + expect(defaultEntry).toBeDefined(); + expect(defaultEntry!.name).toBe("default"); + + const adminEntry = schemaResolver.getSchema("admin"); + expect(adminEntry).toBeDefined(); + expect(adminEntry!.name).toBe("admin"); + }); +}); From 5437d22fe41509ec52d96a36fc0035000ff40181 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 16:07:19 +0900 Subject: [PATCH 09/16] feat(cli): add lsp command for GraphQL language server Adds 'soda-gql lsp' command that starts the GraphQL LSP server over stdio. Uses dynamic import to avoid loading LSP dependencies for other commands. Co-Authored-By: Claude Opus 4.5 --- bun.lock | 1 + packages/cli/package.json | 3 ++- packages/cli/src/commands/lsp.ts | 31 +++++++++++++++++++++++++++++++ packages/cli/src/index.ts | 7 +++++++ 4 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/commands/lsp.ts diff --git a/bun.lock b/bun.lock index 6209f7793..da78f9240 100644 --- a/bun.lock +++ b/bun.lock @@ -93,6 +93,7 @@ }, "optionalDependencies": { "@soda-gql/formatter": "workspace:*", + "@soda-gql/lsp": "workspace:*", }, }, "packages/codegen": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 782b7dece..fc39a482e 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -60,6 +60,7 @@ "zod": "^4.1.11" }, "optionalDependencies": { - "@soda-gql/formatter": "workspace:*" + "@soda-gql/formatter": "workspace:*", + "@soda-gql/lsp": "workspace:*" } } diff --git a/packages/cli/src/commands/lsp.ts b/packages/cli/src/commands/lsp.ts new file mode 100644 index 000000000..ca4aa156d --- /dev/null +++ b/packages/cli/src/commands/lsp.ts @@ -0,0 +1,31 @@ +const LSP_HELP = `Usage: soda-gql lsp [options] + +Start the GraphQL Language Server Protocol server. + +The LSP server communicates over stdio and provides: + - Diagnostics (validation errors in GraphQL templates) + - Autocompletion (field, argument, type suggestions) + - Hover information (type details on hover) + +Options: + --help, -h Show this help message + +The server is typically started by an editor extension, not directly by users. +Configure your editor to use 'soda-gql lsp' as the GraphQL language server command.`; + +export const lspCommand = async (argv: readonly string[]): Promise => { + if (argv.includes("--help") || argv.includes("-h")) { + process.stdout.write(`${LSP_HELP}\n`); + process.exit(0); + } + + // Dynamic import to avoid loading LSP deps for other commands + const { createLspServer } = await import("@soda-gql/lsp"); + const server = createLspServer(); + server.start(); + + // Server runs indefinitely via stdio; this promise never resolves + await new Promise(() => {}); + // TypeScript needs this for the `never` return type + throw new Error("unreachable"); +}; diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 5a91471c3..abaa21d6e 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,6 +3,7 @@ import { artifactCommand } from "./commands/artifact"; import { codegenCommand } from "./commands/codegen/index"; import { doctorCommand } from "./commands/doctor"; import { formatCommand } from "./commands/format"; +import { lspCommand } from "./commands/lsp"; import { initCommand } from "./commands/init"; import { typegenCommand } from "./commands/typegen"; import { cliErrors } from "./errors"; @@ -18,6 +19,7 @@ Commands: format Format soda-gql field selections artifact Manage soda-gql artifacts doctor Run diagnostic checks + lsp Start the GraphQL language server Run 'soda-gql --help' for more information on a specific command. `; @@ -73,6 +75,11 @@ const dispatch = async (argv: readonly string[]): Promise => { return artifactCommand(rest); } + if (command === "lsp") { + await lspCommand(rest); + return ok({ message: "" }); // unreachable, lsp runs forever + } + if (command === "doctor") { const result = doctorCommand(rest); if (result.isOk()) { From 8a808690c4f3905062c82b2bcdffcccdd0215151 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 16:08:22 +0900 Subject: [PATCH 10/16] fix(lsp): resolve typecheck errors in errors.ts and exclude fixtures Use specific return types for error constructors instead of the union type, and exclude test fixtures from typecheck (they reference @/graphql-system which is a runtime alias). Co-Authored-By: Claude Opus 4.5 --- packages/lsp/src/errors.ts | 55 ++++++++----------------------- packages/lsp/tsconfig.editor.json | 2 +- 2 files changed, 15 insertions(+), 42 deletions(-) diff --git a/packages/lsp/src/errors.ts b/packages/lsp/src/errors.ts index 611129445..5f9063865 100644 --- a/packages/lsp/src/errors.ts +++ b/packages/lsp/src/errors.ts @@ -14,82 +14,55 @@ export type LspErrorCode = | "PARSE_FAILED" | "INTERNAL_INVARIANT"; +type ConfigLoadFailed = { readonly code: "CONFIG_LOAD_FAILED"; readonly message: string; readonly cause?: unknown }; +type SchemaLoadFailed = { readonly code: "SCHEMA_LOAD_FAILED"; readonly message: string; readonly schemaName: string; readonly cause?: unknown }; +type SchemaBuildFailed = { readonly code: "SCHEMA_BUILD_FAILED"; readonly message: string; readonly schemaName: string; readonly cause?: unknown }; +type SchemaNotConfigured = { readonly code: "SCHEMA_NOT_CONFIGURED"; readonly message: string; readonly schemaName: string }; +type ParseFailed = { readonly code: "PARSE_FAILED"; readonly message: string; readonly uri: string; readonly cause?: unknown }; +type InternalInvariant = { readonly code: "INTERNAL_INVARIANT"; readonly message: string; readonly context?: string; readonly cause?: unknown }; + /** Structured error type for all LSP operations. */ -export type LspError = - | { - readonly code: "CONFIG_LOAD_FAILED"; - readonly message: string; - readonly cause?: unknown; - } - | { - readonly code: "SCHEMA_LOAD_FAILED"; - readonly message: string; - readonly schemaName: string; - readonly cause?: unknown; - } - | { - readonly code: "SCHEMA_BUILD_FAILED"; - readonly message: string; - readonly schemaName: string; - readonly cause?: unknown; - } - | { - readonly code: "SCHEMA_NOT_CONFIGURED"; - readonly message: string; - readonly schemaName: string; - } - | { - readonly code: "PARSE_FAILED"; - readonly message: string; - readonly uri: string; - readonly cause?: unknown; - } - | { - readonly code: "INTERNAL_INVARIANT"; - readonly message: string; - readonly context?: string; - readonly cause?: unknown; - }; +export type LspError = ConfigLoadFailed | SchemaLoadFailed | SchemaBuildFailed | SchemaNotConfigured | ParseFailed | InternalInvariant; /** Helper type for LSP operation results. */ export type LspResult = Result; /** Error constructor helpers for concise error creation. */ export const lspErrors = { - configLoadFailed: (message: string, cause?: unknown): LspError => ({ + configLoadFailed: (message: string, cause?: unknown): ConfigLoadFailed => ({ code: "CONFIG_LOAD_FAILED", message, cause, }), - schemaLoadFailed: (schemaName: string, message?: string, cause?: unknown): LspError => ({ + schemaLoadFailed: (schemaName: string, message?: string, cause?: unknown): SchemaLoadFailed => ({ code: "SCHEMA_LOAD_FAILED", message: message ?? `Failed to load schema: ${schemaName}`, schemaName, cause, }), - schemaBuildFailed: (schemaName: string, message?: string, cause?: unknown): LspError => ({ + schemaBuildFailed: (schemaName: string, message?: string, cause?: unknown): SchemaBuildFailed => ({ code: "SCHEMA_BUILD_FAILED", message: message ?? `Failed to build schema: ${schemaName}`, schemaName, cause, }), - schemaNotConfigured: (schemaName: string): LspError => ({ + schemaNotConfigured: (schemaName: string): SchemaNotConfigured => ({ code: "SCHEMA_NOT_CONFIGURED", message: `Schema "${schemaName}" is not configured in soda-gql.config`, schemaName, }), - parseFailed: (uri: string, message?: string, cause?: unknown): LspError => ({ + parseFailed: (uri: string, message?: string, cause?: unknown): ParseFailed => ({ code: "PARSE_FAILED", message: message ?? `Failed to parse: ${uri}`, uri, cause, }), - internalInvariant: (message: string, context?: string, cause?: unknown): LspError => ({ + internalInvariant: (message: string, context?: string, cause?: unknown): InternalInvariant => ({ code: "INTERNAL_INVARIANT", message, context, diff --git a/packages/lsp/tsconfig.editor.json b/packages/lsp/tsconfig.editor.json index 4447577ea..25faf7afb 100644 --- a/packages/lsp/tsconfig.editor.json +++ b/packages/lsp/tsconfig.editor.json @@ -9,7 +9,7 @@ "paths": {} }, "include": ["src/**/*", "test/**/*", "@x-*.ts", "@devx-*.ts"], - "exclude": ["node_modules", "dist"], + "exclude": ["node_modules", "dist", "test/fixtures/*.ts"], "references": [ { "path": "../builder/tsconfig.editor.json" From 940d27186b41471bd34ed510880fc27b94f5b171 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 16:16:51 +0900 Subject: [PATCH 11/16] fix(lsp): add tsconfig.json reference and apply linter fixes Add missing LSP package reference to root tsconfig.json (required for bun quality to pass). Apply auto-formatter fixes across LSP package. Co-Authored-By: Claude Opus 4.5 --- packages/cli/src/index.ts | 2 +- packages/lsp/src/document-manager.test.ts | 2 +- packages/lsp/src/errors.ts | 29 ++++++++++++++++--- .../src/fragment-args-preprocessor.test.ts | 2 +- packages/lsp/src/handlers/completion.test.ts | 6 ++-- packages/lsp/src/handlers/completion.ts | 2 +- packages/lsp/src/handlers/diagnostics.test.ts | 12 ++++---- packages/lsp/src/handlers/hover.test.ts | 6 ++-- packages/lsp/src/handlers/hover.ts | 2 +- packages/lsp/src/index.ts | 12 ++++---- packages/lsp/src/position-mapping.test.ts | 19 +++++------- packages/lsp/src/position-mapping.ts | 22 ++++++++++++-- packages/lsp/src/schema-resolver.test.ts | 6 ++-- packages/lsp/src/schema-resolver.ts | 21 ++++---------- packages/lsp/src/server.ts | 21 +++++++------- .../lsp/test/fixtures/fragment-with-args.ts | 6 ++-- .../lsp/test/integration/end-to-end.test.ts | 6 ++-- tsconfig.json | 1 + 18 files changed, 102 insertions(+), 75 deletions(-) diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index abaa21d6e..842c3e77f 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -3,8 +3,8 @@ import { artifactCommand } from "./commands/artifact"; import { codegenCommand } from "./commands/codegen/index"; import { doctorCommand } from "./commands/doctor"; import { formatCommand } from "./commands/format"; -import { lspCommand } from "./commands/lsp"; import { initCommand } from "./commands/init"; +import { lspCommand } from "./commands/lsp"; import { typegenCommand } from "./commands/typegen"; import { cliErrors } from "./errors"; import type { CommandResult, CommandSuccess, OutputFormat } from "./types"; diff --git a/packages/lsp/src/document-manager.test.ts b/packages/lsp/src/document-manager.test.ts index e277e179e..e0761713e 100644 --- a/packages/lsp/src/document-manager.test.ts +++ b/packages/lsp/src/document-manager.test.ts @@ -1,6 +1,6 @@ +import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { describe, expect, test } from "bun:test"; import { createGraphqlSystemIdentifyHelper } from "@soda-gql/builder"; import type { ResolvedSodaGqlConfig } from "@soda-gql/config"; import { createDocumentManager } from "./document-manager"; diff --git a/packages/lsp/src/errors.ts b/packages/lsp/src/errors.ts index 5f9063865..d3fc7066d 100644 --- a/packages/lsp/src/errors.ts +++ b/packages/lsp/src/errors.ts @@ -15,14 +15,35 @@ export type LspErrorCode = | "INTERNAL_INVARIANT"; type ConfigLoadFailed = { readonly code: "CONFIG_LOAD_FAILED"; readonly message: string; readonly cause?: unknown }; -type SchemaLoadFailed = { readonly code: "SCHEMA_LOAD_FAILED"; readonly message: string; readonly schemaName: string; readonly cause?: unknown }; -type SchemaBuildFailed = { readonly code: "SCHEMA_BUILD_FAILED"; readonly message: string; readonly schemaName: string; readonly cause?: unknown }; +type SchemaLoadFailed = { + readonly code: "SCHEMA_LOAD_FAILED"; + readonly message: string; + readonly schemaName: string; + readonly cause?: unknown; +}; +type SchemaBuildFailed = { + readonly code: "SCHEMA_BUILD_FAILED"; + readonly message: string; + readonly schemaName: string; + readonly cause?: unknown; +}; type SchemaNotConfigured = { readonly code: "SCHEMA_NOT_CONFIGURED"; readonly message: string; readonly schemaName: string }; type ParseFailed = { readonly code: "PARSE_FAILED"; readonly message: string; readonly uri: string; readonly cause?: unknown }; -type InternalInvariant = { readonly code: "INTERNAL_INVARIANT"; readonly message: string; readonly context?: string; readonly cause?: unknown }; +type InternalInvariant = { + readonly code: "INTERNAL_INVARIANT"; + readonly message: string; + readonly context?: string; + readonly cause?: unknown; +}; /** Structured error type for all LSP operations. */ -export type LspError = ConfigLoadFailed | SchemaLoadFailed | SchemaBuildFailed | SchemaNotConfigured | ParseFailed | InternalInvariant; +export type LspError = + | ConfigLoadFailed + | SchemaLoadFailed + | SchemaBuildFailed + | SchemaNotConfigured + | ParseFailed + | InternalInvariant; /** Helper type for LSP operation results. */ export type LspResult = Result; diff --git a/packages/lsp/src/fragment-args-preprocessor.test.ts b/packages/lsp/src/fragment-args-preprocessor.test.ts index cf5dc7a9f..a0e617ec3 100644 --- a/packages/lsp/src/fragment-args-preprocessor.test.ts +++ b/packages/lsp/src/fragment-args-preprocessor.test.ts @@ -85,7 +85,7 @@ fragment B($y: String = "hi") on Bar { }); test("handles fragment with multiple arguments", () => { - const content = "fragment F($a: Int!, $b: String = \"default\") on T {\n f\n}"; + const content = 'fragment F($a: Int!, $b: String = "default") on T {\n f\n}'; const result = preprocessFragmentArgs(content); expect(result.modified).toBe(true); expect(result.preprocessed).toContain("on T"); diff --git a/packages/lsp/src/handlers/completion.test.ts b/packages/lsp/src/handlers/completion.test.ts index 343cd1e06..3c17c12e9 100644 --- a/packages/lsp/src/handlers/completion.test.ts +++ b/packages/lsp/src/handlers/completion.test.ts @@ -1,10 +1,10 @@ -import { resolve } from "node:path"; import { describe, expect, test } from "bun:test"; -import { buildASTSchema } from "graphql"; +import { resolve } from "node:path"; import { loadSchema } from "@soda-gql/codegen"; import type { DocumentNode } from "graphql"; -import { handleCompletion } from "./completion"; +import { buildASTSchema } from "graphql"; import type { ExtractedTemplate } from "../types"; +import { handleCompletion } from "./completion"; const fixturesDir = resolve(import.meta.dir, "../../test/fixtures"); diff --git a/packages/lsp/src/handlers/completion.ts b/packages/lsp/src/handlers/completion.ts index d765f84c0..7265915f5 100644 --- a/packages/lsp/src/handlers/completion.ts +++ b/packages/lsp/src/handlers/completion.ts @@ -7,7 +7,7 @@ import type { GraphQLSchema } from "graphql"; import { getAutocompleteSuggestions } from "graphql-language-service"; import type { CompletionItem } from "vscode-languageserver-types"; import { preprocessFragmentArgs } from "../fragment-args-preprocessor"; -import { createPositionMapper, toIPosition, type Position } from "../position-mapping"; +import { createPositionMapper, type Position, toIPosition } from "../position-mapping"; import type { ExtractedTemplate } from "../types"; export type HandleCompletionInput = { diff --git a/packages/lsp/src/handlers/diagnostics.test.ts b/packages/lsp/src/handlers/diagnostics.test.ts index 633dcec42..dc5fc3980 100644 --- a/packages/lsp/src/handlers/diagnostics.test.ts +++ b/packages/lsp/src/handlers/diagnostics.test.ts @@ -1,10 +1,10 @@ -import { resolve } from "node:path"; import { describe, expect, test } from "bun:test"; -import { buildASTSchema } from "graphql"; +import { resolve } from "node:path"; import { loadSchema } from "@soda-gql/codegen"; import type { DocumentNode } from "graphql"; -import { computeTemplateDiagnostics } from "./diagnostics"; +import { buildASTSchema } from "graphql"; import type { ExtractedTemplate } from "../types"; +import { computeTemplateDiagnostics } from "./diagnostics"; const fixturesDir = resolve(import.meta.dir, "../../test/fixtures"); @@ -41,7 +41,8 @@ describe("computeTemplateDiagnostics", () => { }); test("reports validation error for unknown field", () => { - const tsSource = 'import { gql } from "@/graphql-system";\n\ngql.default(({ query }) => query`query { users { id unknownField } }`);'; + const tsSource = + 'import { gql } from "@/graphql-system";\n\ngql.default(({ query }) => query`query { users { id unknownField } }`);'; const content = "query { users { id unknownField } }"; const contentStart = tsSource.indexOf(content); @@ -66,7 +67,8 @@ describe("computeTemplateDiagnostics", () => { test("diagnostic positions are in TS file coordinates", () => { // Put the template on line 2 (0-indexed) so we can verify position mapping - const tsSource = 'import { gql } from "@/graphql-system";\n\ngql.default(({ query }) => query`query { users { id unknownField } }`);'; + const tsSource = + 'import { gql } from "@/graphql-system";\n\ngql.default(({ query }) => query`query { users { id unknownField } }`);'; const content = "query { users { id unknownField } }"; const contentStart = tsSource.indexOf(content); diff --git a/packages/lsp/src/handlers/hover.test.ts b/packages/lsp/src/handlers/hover.test.ts index edfef6cab..fb3fbc1f9 100644 --- a/packages/lsp/src/handlers/hover.test.ts +++ b/packages/lsp/src/handlers/hover.test.ts @@ -1,10 +1,10 @@ -import { resolve } from "node:path"; import { describe, expect, test } from "bun:test"; -import { buildASTSchema } from "graphql"; +import { resolve } from "node:path"; import { loadSchema } from "@soda-gql/codegen"; import type { DocumentNode } from "graphql"; -import { handleHover } from "./hover"; +import { buildASTSchema } from "graphql"; import type { ExtractedTemplate } from "../types"; +import { handleHover } from "./hover"; const fixturesDir = resolve(import.meta.dir, "../../test/fixtures"); diff --git a/packages/lsp/src/handlers/hover.ts b/packages/lsp/src/handlers/hover.ts index 0aabc94a0..56a87f156 100644 --- a/packages/lsp/src/handlers/hover.ts +++ b/packages/lsp/src/handlers/hover.ts @@ -7,7 +7,7 @@ import type { GraphQLSchema } from "graphql"; import { getHoverInformation } from "graphql-language-service"; import type { Hover, MarkupContent } from "vscode-languageserver-types"; import { preprocessFragmentArgs } from "../fragment-args-preprocessor"; -import { createPositionMapper, toIPosition, type Position } from "../position-mapping"; +import { createPositionMapper, type Position, toIPosition } from "../position-mapping"; import type { ExtractedTemplate } from "../types"; export type HandleHoverInput = { diff --git a/packages/lsp/src/index.ts b/packages/lsp/src/index.ts index 32a983ef8..6cb87fbf0 100644 --- a/packages/lsp/src/index.ts +++ b/packages/lsp/src/index.ts @@ -1,14 +1,14 @@ // @soda-gql/lsp - GraphQL LSP server for soda-gql -export type { LspError, LspErrorCode, LspResult } from "./errors"; -export { lspErrors } from "./errors"; -export type { DocumentState, ExtractedTemplate, OperationKind } from "./types"; export type { DocumentManager } from "./document-manager"; export { createDocumentManager } from "./document-manager"; -export type { SchemaResolver, SchemaEntry } from "./schema-resolver"; -export { createSchemaResolver } from "./schema-resolver"; +export type { LspError, LspErrorCode, LspResult } from "./errors"; +export { lspErrors } from "./errors"; export { preprocessFragmentArgs } from "./fragment-args-preprocessor"; export type { PositionMapper } from "./position-mapping"; export { createPositionMapper } from "./position-mapping"; -export { createLspServer } from "./server"; +export type { SchemaEntry, SchemaResolver } from "./schema-resolver"; +export { createSchemaResolver } from "./schema-resolver"; export type { LspServerOptions } from "./server"; +export { createLspServer } from "./server"; +export type { DocumentState, ExtractedTemplate, OperationKind } from "./types"; diff --git a/packages/lsp/src/position-mapping.test.ts b/packages/lsp/src/position-mapping.test.ts index 4c9760ff7..bb748c6c7 100644 --- a/packages/lsp/src/position-mapping.test.ts +++ b/packages/lsp/src/position-mapping.test.ts @@ -1,10 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { - computeLineOffsets, - createPositionMapper, - offsetToPosition, - positionToOffset, -} from "./position-mapping"; +import { computeLineOffsets, createPositionMapper, offsetToPosition, positionToOffset } from "./position-mapping"; describe("computeLineOffsets", () => { test("single line returns [0]", () => { @@ -53,7 +48,7 @@ describe("createPositionMapper", () => { // line 2: user { id } // line 3: } // line 4: `); - const tsSource = 'const q = gql.default(({ query }) => query`\n query GetUser {\n user { id }\n }\n`);'; + const tsSource = "const q = gql.default(({ query }) => query`\n query GetUser {\n user { id }\n }\n`);"; const contentStartOffset = 43; // position after the backtick on line 0 const graphqlContent = "\n query GetUser {\n user { id }\n }\n"; @@ -69,7 +64,7 @@ describe("createPositionMapper", () => { }); test("maps GraphQL position back to TS position", () => { - const tsSource = 'const q = gql.default(({ query }) => query`\n query GetUser {\n user { id }\n }\n`);'; + const tsSource = "const q = gql.default(({ query }) => query`\n query GetUser {\n user { id }\n }\n`);"; const contentStartOffset = 43; const graphqlContent = "\n query GetUser {\n user { id }\n }\n"; @@ -81,7 +76,7 @@ describe("createPositionMapper", () => { }); test("round-trip: tsToGraphql -> graphqlToTs preserves position", () => { - const tsSource = 'const q = gql.default(({ query }) => query`\n query GetUser {\n user { id }\n }\n`);'; + const tsSource = "const q = gql.default(({ query }) => query`\n query GetUser {\n user { id }\n }\n`);"; const contentStartOffset = 44; const graphqlContent = "\n query GetUser {\n user { id }\n }\n"; @@ -95,7 +90,7 @@ describe("createPositionMapper", () => { }); test("returns null for position before template", () => { - const tsSource = 'const q = gql.default(({ query }) => query`\nquery { user }\n`);'; + const tsSource = "const q = gql.default(({ query }) => query`\nquery { user }\n`);"; const contentStartOffset = 43; const graphqlContent = "\nquery { user }\n"; @@ -107,7 +102,7 @@ describe("createPositionMapper", () => { }); test("returns null for position after template", () => { - const tsSource = 'const q = gql.default(({ query }) => query`\nquery { user }\n`);'; + const tsSource = "const q = gql.default(({ query }) => query`\nquery { user }\n`);"; const contentStartOffset = 43; const graphqlContent = "\nquery { user }\n"; @@ -119,7 +114,7 @@ describe("createPositionMapper", () => { }); test("single-line template mapping", () => { - const tsSource = 'const q = gql.default(({ query }) => query`query { user { id } }`);'; + const tsSource = "const q = gql.default(({ query }) => query`query { user { id } }`);"; const contentStartOffset = 43; const graphqlContent = "query { user { id } }"; diff --git a/packages/lsp/src/position-mapping.ts b/packages/lsp/src/position-mapping.ts index 8703cc366..47d0e0fba 100644 --- a/packages/lsp/src/position-mapping.ts +++ b/packages/lsp/src/position-mapping.ts @@ -60,8 +60,26 @@ export const offsetToPosition = (lineOffsets: readonly number[], offset: number) }; /** Convert a Position to an IPosition compatible with graphql-language-service. */ -export const toIPosition = (pos: Position): { line: number; character: number; setLine: (l: number) => void; setCharacter: (c: number) => void; lessThanOrEqualTo: (other: Position) => boolean } => { - const p = { line: pos.line, character: pos.character, setLine: (l: number) => { p.line = l; }, setCharacter: (c: number) => { p.character = c; }, lessThanOrEqualTo: (other: Position) => p.line < other.line || (p.line === other.line && p.character <= other.character) }; +export const toIPosition = ( + pos: Position, +): { + line: number; + character: number; + setLine: (l: number) => void; + setCharacter: (c: number) => void; + lessThanOrEqualTo: (other: Position) => boolean; +} => { + const p = { + line: pos.line, + character: pos.character, + setLine: (l: number) => { + p.line = l; + }, + setCharacter: (c: number) => { + p.character = c; + }, + lessThanOrEqualTo: (other: Position) => p.line < other.line || (p.line === other.line && p.character <= other.character), + }; return p; }; diff --git a/packages/lsp/src/schema-resolver.test.ts b/packages/lsp/src/schema-resolver.test.ts index 3af4ea680..910514c6e 100644 --- a/packages/lsp/src/schema-resolver.test.ts +++ b/packages/lsp/src/schema-resolver.test.ts @@ -1,13 +1,11 @@ -import { resolve } from "node:path"; import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; import type { ResolvedSodaGqlConfig } from "@soda-gql/config"; import { createSchemaResolver } from "./schema-resolver"; const fixturesDir = resolve(import.meta.dir, "../test/fixtures/schemas"); -const createTestConfig = ( - schemas: Record, -): ResolvedSodaGqlConfig => +const createTestConfig = (schemas: Record): ResolvedSodaGqlConfig => ({ analyzer: "swc" as const, baseDir: fixturesDir, diff --git a/packages/lsp/src/schema-resolver.ts b/packages/lsp/src/schema-resolver.ts index 834e8cb19..03f465857 100644 --- a/packages/lsp/src/schema-resolver.ts +++ b/packages/lsp/src/schema-resolver.ts @@ -4,12 +4,12 @@ */ import { resolve } from "node:path"; -import { loadSchema, hashSchema } from "@soda-gql/codegen"; +import { hashSchema, loadSchema } from "@soda-gql/codegen"; import type { ResolvedSodaGqlConfig } from "@soda-gql/config"; import { buildASTSchema, type DocumentNode } from "graphql"; import { err, ok, type Result } from "neverthrow"; -import { lspErrors } from "./errors"; import type { LspError } from "./errors"; +import { lspErrors } from "./errors"; /** Cached schema entry. */ export type SchemaEntry = { @@ -26,10 +26,7 @@ export type SchemaResolver = { readonly reloadAll: () => Result; }; -const loadAndBuildSchema = ( - schemaName: string, - schemaPaths: readonly string[], -): Result => { +const loadAndBuildSchema = (schemaName: string, schemaPaths: readonly string[]): Result => { const resolvedPaths = schemaPaths.map((s) => resolve(s)); const loadResult = loadSchema(resolvedPaths); if (loadResult.isErr()) { @@ -44,20 +41,12 @@ const loadAndBuildSchema = ( const schema = buildASTSchema(documentNode); return ok({ name: schemaName, schema, documentNode, hash }); } catch (e) { - return err( - lspErrors.schemaBuildFailed( - schemaName, - e instanceof Error ? e.message : String(e), - e, - ), - ); + return err(lspErrors.schemaBuildFailed(schemaName, e instanceof Error ? e.message : String(e), e)); } }; /** Create a schema resolver from config. Loads all schemas eagerly. */ -export const createSchemaResolver = ( - config: ResolvedSodaGqlConfig, -): Result => { +export const createSchemaResolver = (config: ResolvedSodaGqlConfig): Result => { const cache = new Map(); // Load all schemas from config diff --git a/packages/lsp/src/server.ts b/packages/lsp/src/server.ts index 27ba62f2b..cff8109eb 100644 --- a/packages/lsp/src/server.ts +++ b/packages/lsp/src/server.ts @@ -3,27 +3,27 @@ * @module */ +import { createGraphqlSystemIdentifyHelper } from "@soda-gql/builder"; +import { loadConfigFrom } from "@soda-gql/config"; import { type Connection, - type InitializeResult, - TextDocumentSyncKind, createConnection, - ProposedFeatures, DidChangeWatchedFilesNotification, FileChangeType, + type InitializeResult, + ProposedFeatures, type TextDocumentChangeEvent, + TextDocumentSyncKind, + TextDocuments, } from "vscode-languageserver/node"; import { TextDocument } from "vscode-languageserver-textdocument"; -import { TextDocuments } from "vscode-languageserver/node"; -import { loadConfigFrom } from "@soda-gql/config"; -import { createGraphqlSystemIdentifyHelper } from "@soda-gql/builder"; -import { createSchemaResolver } from "./schema-resolver"; +import type { DocumentManager } from "./document-manager"; import { createDocumentManager } from "./document-manager"; -import { computeTemplateDiagnostics } from "./handlers/diagnostics"; import { handleCompletion } from "./handlers/completion"; +import { computeTemplateDiagnostics } from "./handlers/diagnostics"; import { handleHover } from "./handlers/hover"; import type { SchemaResolver } from "./schema-resolver"; -import type { DocumentManager } from "./document-manager"; +import { createSchemaResolver } from "./schema-resolver"; export type LspServerOptions = { readonly connection?: Connection; @@ -198,7 +198,8 @@ export const createLspServer = (options?: LspServerOptions) => { // Check if any .graphql files changed const graphqlChanged = _params.changes.some( - (change) => change.uri.endsWith(".graphql") && (change.type === FileChangeType.Changed || change.type === FileChangeType.Created), + (change) => + change.uri.endsWith(".graphql") && (change.type === FileChangeType.Changed || change.type === FileChangeType.Created), ); if (graphqlChanged) { diff --git a/packages/lsp/test/fixtures/fragment-with-args.ts b/packages/lsp/test/fixtures/fragment-with-args.ts index 90b464d61..8fdeafc77 100644 --- a/packages/lsp/test/fixtures/fragment-with-args.ts +++ b/packages/lsp/test/fixtures/fragment-with-args.ts @@ -1,7 +1,9 @@ import { gql } from "@/graphql-system"; -export const UserFields = gql.default(({ fragment }) => fragment`fragment UserFields($showEmail: Boolean = false) on User { +export const UserFields = gql.default( + ({ fragment }) => fragment`fragment UserFields($showEmail: Boolean = false) on User { id name email @include(if: $showEmail) -}`); +}`, +); diff --git a/packages/lsp/test/integration/end-to-end.test.ts b/packages/lsp/test/integration/end-to-end.test.ts index eb8089156..e61c98f5a 100644 --- a/packages/lsp/test/integration/end-to-end.test.ts +++ b/packages/lsp/test/integration/end-to-end.test.ts @@ -3,16 +3,16 @@ * diagnostics, completion, and hover using real schemas and TypeScript sources. */ +import { describe, expect, test } from "bun:test"; import { readFileSync } from "node:fs"; import { resolve } from "node:path"; -import { describe, expect, test } from "bun:test"; import { createGraphqlSystemIdentifyHelper } from "@soda-gql/builder"; import type { ResolvedSodaGqlConfig } from "@soda-gql/config"; import { createDocumentManager } from "../../src/document-manager"; -import { createSchemaResolver } from "../../src/schema-resolver"; -import { computeTemplateDiagnostics } from "../../src/handlers/diagnostics"; import { handleCompletion } from "../../src/handlers/completion"; +import { computeTemplateDiagnostics } from "../../src/handlers/diagnostics"; import { handleHover } from "../../src/handlers/hover"; +import { createSchemaResolver } from "../../src/schema-resolver"; const fixturesDir = resolve(import.meta.dir, "../fixtures"); diff --git a/tsconfig.json b/tsconfig.json index 5bbf52642..56994e869 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -18,6 +18,7 @@ { "path": "./packages/config/tsconfig.editor.json" }, { "path": "./packages/core/tsconfig.editor.json" }, { "path": "./packages/formatter/tsconfig.editor.json" }, + { "path": "./packages/lsp/tsconfig.editor.json" }, { "path": "./packages/metro-plugin/tsconfig.editor.json" }, { "path": "./packages/runtime/tsconfig.editor.json" }, { "path": "./packages/sdk/tsconfig.editor.json" }, From ad56e83493dff55ae084bf38d9ba4c6ea8b3515d Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 16:56:20 +0900 Subject: [PATCH 12/16] fix(lsp): address BugBot findings - Result wrappers, fileURLToPath, typed AST - Replace try-catch with neverthrow Result wrappers in schema-resolver and document-manager - Use Node.js fileURLToPath for cross-platform URI conversion - Replace standalone `any` with @swc/types Node type for AST traversal BugBot comments: - https://github.com/whatasoda/soda-gql/pull/308#discussion_r2750670719 - https://github.com/whatasoda/soda-gql/pull/308#discussion_r2750670724 - https://github.com/whatasoda/soda-gql/pull/308#discussion_r2750670720 Co-Authored-By: Claude Opus 4.5 --- packages/lsp/src/document-manager.ts | 67 +++++++++++++++------------- packages/lsp/src/schema-resolver.ts | 21 ++++++--- packages/lsp/src/server.ts | 3 +- 3 files changed, 54 insertions(+), 37 deletions(-) diff --git a/packages/lsp/src/document-manager.ts b/packages/lsp/src/document-manager.ts index 1ac075b18..870abaf97 100644 --- a/packages/lsp/src/document-manager.ts +++ b/packages/lsp/src/document-manager.ts @@ -3,6 +3,7 @@ * @module */ +import { fileURLToPath } from "node:url"; import type { GraphqlSystemIdentifyHelper } from "@soda-gql/builder"; import { parseSync } from "@swc/core"; import type { @@ -12,6 +13,7 @@ import type { ImportDeclaration, MemberExpression, Module, + Node, TaggedTemplateExpression, } from "@swc/types"; import type { DocumentState, ExtractedTemplate, OperationKind } from "./types"; @@ -23,6 +25,20 @@ export type DocumentManager = { readonly findTemplateAtOffset: (uri: string, offset: number) => ExtractedTemplate | undefined; }; +/** Wrap SWC parseSync (which throws) to return null on failure. */ +const safeParseSync = (source: string, tsx: boolean): ReturnType | null => { + try { + return parseSync(source, { + syntax: "typescript", + tsx, + decorators: false, + dynamicImport: true, + }); + } catch { + return null; + } +}; + const OPERATION_KINDS = new Set(["query", "mutation", "subscription", "fragment"]); const isOperationKind = (value: string): value is OperationKind => OPERATION_KINDS.has(value); @@ -192,19 +208,22 @@ const extractFromTaggedTemplate = ( * Walk AST to find gql calls and extract templates. * Adapted from builder's unwrapMethodChains + visit pattern. */ -// biome-ignore lint/suspicious/noExplicitAny: SWC AST traversal -const walkAndExtract = (node: any, identifiers: ReadonlySet, source: string, spanOffset: number): ExtractedTemplate[] => { +const walkAndExtract = ( + node: Node, + identifiers: ReadonlySet, + source: string, + spanOffset: number, +): ExtractedTemplate[] => { const templates: ExtractedTemplate[] = []; - // biome-ignore lint/suspicious/noExplicitAny: SWC AST traversal - const visit = (n: any): void => { + const visit = (n: Node | ReadonlyArray | Record): void => { if (!n || typeof n !== "object") { return; } - if (n.type === "CallExpression") { + if ("type" in n && n.type === "CallExpression") { // Check if this is a gql call (possibly wrapped in method chains) - const gqlCall = findGqlCall(identifiers, n); + const gqlCall = findGqlCall(identifiers, n as Node); if (gqlCall) { const schemaName = getGqlCallSchemaName(identifiers, gqlCall); if (schemaName) { @@ -218,7 +237,7 @@ const walkAndExtract = (node: any, identifiers: ReadonlySet, source: str // Recurse into all array and object properties if (Array.isArray(n)) { for (const item of n) { - visit(item); + visit(item as Node); } return; } @@ -227,9 +246,9 @@ const walkAndExtract = (node: any, identifiers: ReadonlySet, source: str if (key === "span" || key === "type") { continue; } - const value = n[key]; + const value = (n as Record)[key]; if (value && typeof value === "object") { - visit(value); + visit(value as Node); } } }; @@ -241,22 +260,22 @@ const walkAndExtract = (node: any, identifiers: ReadonlySet, source: str /** * Find the innermost gql call, unwrapping method chains like .attach(). */ -// biome-ignore lint/suspicious/noExplicitAny: SWC AST type -const findGqlCall = (identifiers: ReadonlySet, node: any): CallExpression | null => { +const findGqlCall = (identifiers: ReadonlySet, node: Node): CallExpression | null => { if (!node || node.type !== "CallExpression") { return null; } - if (getGqlCallSchemaName(identifiers, node) !== null) { - return node; + const call = node as unknown as CallExpression; + if (getGqlCallSchemaName(identifiers, call) !== null) { + return call; } - const callee = node.callee; + const callee = call.callee; if (callee.type !== "MemberExpression") { return null; } - return findGqlCall(identifiers, callee.object); + return findGqlCall(identifiers, callee.object as unknown as Node); }; /** Create a document manager that tracks open documents and extracts templates. */ @@ -266,20 +285,8 @@ export const createDocumentManager = (helper: GraphqlSystemIdentifyHelper): Docu const extractTemplates = (uri: string, source: string): readonly ExtractedTemplate[] => { const isTsx = uri.endsWith(".tsx"); - let program: ReturnType; - try { - program = parseSync(source, { - syntax: "typescript", - tsx: isTsx, - decorators: false, - dynamicImport: true, - }); - } catch { - // Parse failure — return no templates - return []; - } - - if (program.type !== "Module") { + const program = safeParseSync(source, isTsx); + if (!program || program.type !== "Module") { return []; } @@ -287,7 +294,7 @@ export const createDocumentManager = (helper: GraphqlSystemIdentifyHelper): Docu const spanOffset = program.span.end - source.length + 1; // Convert URI to a file path for the helper - const filePath = uri.startsWith("file://") ? decodeURIComponent(uri.slice(7)) : uri; + const filePath = uri.startsWith("file://") ? fileURLToPath(uri) : uri; const gqlIdentifiers = collectGqlIdentifiers(program, filePath, helper); if (gqlIdentifiers.size === 0) { diff --git a/packages/lsp/src/schema-resolver.ts b/packages/lsp/src/schema-resolver.ts index 03f465857..19cee7572 100644 --- a/packages/lsp/src/schema-resolver.ts +++ b/packages/lsp/src/schema-resolver.ts @@ -6,7 +6,7 @@ import { resolve } from "node:path"; import { hashSchema, loadSchema } from "@soda-gql/codegen"; import type { ResolvedSodaGqlConfig } from "@soda-gql/config"; -import { buildASTSchema, type DocumentNode } from "graphql"; +import { buildASTSchema, type DocumentNode, type GraphQLSchema } from "graphql"; import { err, ok, type Result } from "neverthrow"; import type { LspError } from "./errors"; import { lspErrors } from "./errors"; @@ -26,6 +26,15 @@ export type SchemaResolver = { readonly reloadAll: () => Result; }; +/** Wrap buildASTSchema (which throws) in a Result. */ +const safeBuildASTSchema = (schemaName: string, documentNode: DocumentNode): Result => { + try { + return ok(buildASTSchema(documentNode)); + } catch (e) { + return err(lspErrors.schemaBuildFailed(schemaName, e instanceof Error ? e.message : String(e), e)); + } +}; + const loadAndBuildSchema = (schemaName: string, schemaPaths: readonly string[]): Result => { const resolvedPaths = schemaPaths.map((s) => resolve(s)); const loadResult = loadSchema(resolvedPaths); @@ -37,12 +46,12 @@ const loadAndBuildSchema = (schemaName: string, schemaPaths: readonly string[]): const documentNode = loadResult.value as unknown as DocumentNode; const hash = hashSchema(loadResult.value); - try { - const schema = buildASTSchema(documentNode); - return ok({ name: schemaName, schema, documentNode, hash }); - } catch (e) { - return err(lspErrors.schemaBuildFailed(schemaName, e instanceof Error ? e.message : String(e), e)); + const buildResult = safeBuildASTSchema(schemaName, documentNode); + if (buildResult.isErr()) { + return err(buildResult.error); } + + return ok({ name: schemaName, schema: buildResult.value, documentNode, hash }); }; /** Create a schema resolver from config. Loads all schemas eagerly. */ diff --git a/packages/lsp/src/server.ts b/packages/lsp/src/server.ts index cff8109eb..e55dd7e81 100644 --- a/packages/lsp/src/server.ts +++ b/packages/lsp/src/server.ts @@ -3,6 +3,7 @@ * @module */ +import { fileURLToPath } from "node:url"; import { createGraphqlSystemIdentifyHelper } from "@soda-gql/builder"; import { loadConfigFrom } from "@soda-gql/config"; import { @@ -72,7 +73,7 @@ export const createLspServer = (options?: LspServerOptions) => { } // Convert URI to path - const rootPath = rootUri.startsWith("file://") ? decodeURIComponent(rootUri.slice(7)) : rootUri; + const rootPath = rootUri.startsWith("file://") ? fileURLToPath(rootUri) : rootUri; const configResult = loadConfigFrom(rootPath); if (configResult.isErr()) { From 589cedfdf63ee5982d6800c5c212fa10f7418658 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 17:31:17 +0900 Subject: [PATCH 13/16] fix(lsp): address BugBot finding - consecutive backslash escape detection Fixed findMatchingParen string escape detection to count consecutive backslashes before a quote character. An even count means the quote is unescaped (closes the string), while an odd count means it's escaped. BugBot comment: https://github.com/whatasoda/soda-gql/pull/308#discussion_r2750720270 Co-Authored-By: Claude Opus 4.5 --- packages/lsp/src/fragment-args-preprocessor.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/lsp/src/fragment-args-preprocessor.ts b/packages/lsp/src/fragment-args-preprocessor.ts index c3c981be0..818073630 100644 --- a/packages/lsp/src/fragment-args-preprocessor.ts +++ b/packages/lsp/src/fragment-args-preprocessor.ts @@ -28,8 +28,14 @@ const findMatchingParen = (content: string, openIndex: number): number => { const ch = content[i]!; if (inString) { - if (ch === inString && content[i - 1] !== "\\") { - inString = false; + if (ch === inString) { + let backslashes = 0; + for (let j = i - 1; j >= 0 && content[j] === "\\"; j--) { + backslashes++; + } + if (backslashes % 2 === 0) { + inString = false; + } } continue; } From c7ed06a44abe84161d081b4758d4f137893ab3bf Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 18:18:55 +0900 Subject: [PATCH 14/16] fix(lsp): correct contentStartOffset in round-trip position mapping test Fixed contentStartOffset from 44 to 43 and added intermediate value assertion to prevent symmetric error cancellation from masking bugs. BugBot comment: https://github.com/whatasoda/soda-gql/pull/308#discussion_r2750789095 Co-Authored-By: Claude Opus 4.5 --- packages/lsp/src/position-mapping.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/lsp/src/position-mapping.test.ts b/packages/lsp/src/position-mapping.test.ts index bb748c6c7..fd76a4f82 100644 --- a/packages/lsp/src/position-mapping.test.ts +++ b/packages/lsp/src/position-mapping.test.ts @@ -77,14 +77,14 @@ describe("createPositionMapper", () => { test("round-trip: tsToGraphql -> graphqlToTs preserves position", () => { const tsSource = "const q = gql.default(({ query }) => query`\n query GetUser {\n user { id }\n }\n`);"; - const contentStartOffset = 44; + const contentStartOffset = 43; const graphqlContent = "\n query GetUser {\n user { id }\n }\n"; const mapper = createPositionMapper({ tsSource, contentStartOffset, graphqlContent }); const original = { line: 2, character: 4 }; const gql = mapper.tsToGraphql(original); - expect(gql).not.toBeNull(); + expect(gql).toEqual({ line: 2, character: 4 }); const roundTrip = mapper.graphqlToTs(gql!); expect(roundTrip).toEqual(original); }); From 1426069eeaf5415e0cef069a050fb2b07effc7e6 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 19:43:31 +0900 Subject: [PATCH 15/16] fix(swc): correct UTF-8 byte to UTF-16 char position conversion SWC returns span positions as UTF-8 byte offsets, but JavaScript string operations use UTF-16 code units. For files with non-ASCII characters (CJK, emoji, accented chars), this caused incorrect contentRange values, diagnostic positions, and template extraction offsets. Added createSwcSpanConverter utility in @soda-gql/common with: - ASCII fast path (zero allocation for ASCII-only files) - Pre-computed Uint32Array lookup for non-ASCII sources - Applied to both LSP document-manager and builder SWC adapter BugBot comment: https://github.com/whatasoda/soda-gql/pull/308#discussion_r2750850557 Co-Authored-By: Claude Opus 4.5 --- bun.lock | 1 + packages/builder/src/ast/adapters/swc.ts | 24 ++-- packages/common/src/utils/index.ts | 1 + packages/common/src/utils/swc-span.test.ts | 105 ++++++++++++++++++ packages/common/src/utils/swc-span.ts | 67 +++++++++++ packages/lsp/package.json | 1 + packages/lsp/src/document-manager.test.ts | 26 +++++ packages/lsp/src/document-manager.ts | 31 +++--- .../lsp/test/fixtures/unicode-comments.ts | 6 + 9 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 packages/common/src/utils/swc-span.test.ts create mode 100644 packages/common/src/utils/swc-span.ts create mode 100644 packages/lsp/test/fixtures/unicode-comments.ts diff --git a/bun.lock b/bun.lock index da78f9240..ee73c2791 100644 --- a/bun.lock +++ b/bun.lock @@ -163,6 +163,7 @@ "dependencies": { "@soda-gql/builder": "workspace:*", "@soda-gql/codegen": "workspace:*", + "@soda-gql/common": "workspace:*", "@soda-gql/config": "workspace:*", "@swc/core": "^1.6.3", "@swc/types": "^0.1.6", diff --git a/packages/builder/src/ast/adapters/swc.ts b/packages/builder/src/ast/adapters/swc.ts index 444166b98..38daa5873 100644 --- a/packages/builder/src/ast/adapters/swc.ts +++ b/packages/builder/src/ast/adapters/swc.ts @@ -3,7 +3,7 @@ * Implements parser-specific logic using the SWC parser. */ -import { createCanonicalId, createCanonicalTracker, type ScopeHandle } from "@soda-gql/common"; +import { createCanonicalId, createCanonicalTracker, createSwcSpanConverter, type ScopeHandle, type SwcSpanConverter } from "@soda-gql/common"; import { parseSync } from "@swc/core"; import type { CallExpression, ImportDeclaration, Module } from "@swc/types"; import type { GraphqlSystemIdentifyHelper } from "../../internal/graphql-system"; @@ -17,6 +17,8 @@ type SwcModule = Module & { __filePath: string; /** Offset to subtract from spans to normalize to 0-based source indices */ __spanOffset: number; + /** Converter for UTF-8 byte offsets to UTF-16 char indices */ + __spanConverter: SwcSpanConverter; }; import { createStandardDiagnostic } from "../common/detection"; @@ -362,10 +364,11 @@ const collectAllDefinitions = ({ }; const expressionFromCall = (call: CallExpression): string => { - // Normalize span by subtracting the module's span offset + // Normalize span by subtracting the module's span offset, then convert byte→char const spanOffset = module.__spanOffset; - let start = call.span.start - spanOffset; - const end = call.span.end - spanOffset; + const converter = module.__spanConverter; + let start = converter.byteOffsetToCharIndex(call.span.start - spanOffset); + const end = converter.byteOffsetToCharIndex(call.span.end - spanOffset); // Adjust when span starts one character after the leading "g" if (start > 0 && source[start] === "q" && source[start - 1] === "g" && source.slice(start, start + 3) === "ql.") { @@ -587,8 +590,9 @@ const collectAllDefinitions = ({ * Get location from an SWC node span */ const getLocation = (module: SwcModule, span: { start: number; end: number }): DiagnosticLocation => { - const start = span.start - module.__spanOffset; - const end = span.end - module.__spanOffset; + const converter = module.__spanConverter; + const start = converter.byteOffsetToCharIndex(span.start - module.__spanOffset); + const end = converter.byteOffsetToCharIndex(span.end - module.__spanOffset); return { start, end }; }; @@ -916,15 +920,15 @@ export const swcAdapter: AnalyzerAdapter = { } // SWC's BytePos counter accumulates across parseSync calls within the same process. - // To convert span positions to 0-indexed source positions, we compute the accumulated - // offset from previous parses: (program.span.end - source.length) gives us the total - // bytes from previously parsed files, and we add 1 because spans are 1-indexed. - const spanOffset = program.span.end - input.source.length + 1; + // Use UTF-8 byte length (not source.length which is UTF-16 code units) for correct offset. + const converter = createSwcSpanConverter(input.source); + const spanOffset = program.span.end - converter.byteLength + 1; // Attach filePath to module (similar to ts.SourceFile.fileName) const swcModule = program as SwcModule; swcModule.__filePath = input.filePath; swcModule.__spanOffset = spanOffset; + swcModule.__spanConverter = converter; // Collect all data in one pass const gqlIdentifiers = collectGqlIdentifiers(swcModule, helper); diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index db32e62f4..8d6f6fd4d 100644 --- a/packages/common/src/utils/index.ts +++ b/packages/common/src/utils/index.ts @@ -1,4 +1,5 @@ export * from "./alias-resolver"; export * from "./cached-fn"; export * from "./path"; +export * from "./swc-span"; export * from "./tsconfig"; diff --git a/packages/common/src/utils/swc-span.test.ts b/packages/common/src/utils/swc-span.test.ts new file mode 100644 index 000000000..5bfcc248b --- /dev/null +++ b/packages/common/src/utils/swc-span.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, test } from "bun:test"; +import { createSwcSpanConverter } from "./swc-span"; + +describe("createSwcSpanConverter", () => { + test("ASCII-only: byteLength equals string length", () => { + const source = "const x = 42;"; + const converter = createSwcSpanConverter(source); + expect(converter.byteLength).toBe(source.length); + }); + + test("ASCII-only: identity conversion", () => { + const source = "hello world"; + const converter = createSwcSpanConverter(source); + for (let i = 0; i <= source.length; i++) { + expect(converter.byteOffsetToCharIndex(i)).toBe(i); + } + }); + + test("empty string", () => { + const converter = createSwcSpanConverter(""); + expect(converter.byteLength).toBe(0); + expect(converter.byteOffsetToCharIndex(0)).toBe(0); + }); + + test("2-byte UTF-8 characters (accented)", () => { + // "\u00E9" = e-acute, 2 bytes in UTF-8, 1 code unit in UTF-16 + const source = "caf\u00E9"; + const converter = createSwcSpanConverter(source); + // "caf" = 3 bytes, "\u00E9" = 2 bytes → total 5 bytes + expect(converter.byteLength).toBe(5); + // byte 0 → char 0 ('c') + expect(converter.byteOffsetToCharIndex(0)).toBe(0); + // byte 3 → char 3 (start of '\u00E9') + expect(converter.byteOffsetToCharIndex(3)).toBe(3); + // byte 5 → char 4 (end sentinel) + expect(converter.byteOffsetToCharIndex(5)).toBe(4); + }); + + test("3-byte UTF-8 characters (CJK)", () => { + // Each Japanese character is 3 bytes in UTF-8, 1 code unit in UTF-16 + const source = "\u3053\u3093\u306B\u3061\u306F"; // konnichiwa + const converter = createSwcSpanConverter(source); + expect(converter.byteLength).toBe(15); // 5 chars * 3 bytes + // byte 0 → char 0 + expect(converter.byteOffsetToCharIndex(0)).toBe(0); + // byte 3 → char 1 + expect(converter.byteOffsetToCharIndex(3)).toBe(1); + // byte 6 → char 2 + expect(converter.byteOffsetToCharIndex(6)).toBe(2); + // byte 15 → char 5 (end sentinel) + expect(converter.byteOffsetToCharIndex(15)).toBe(5); + }); + + test("4-byte UTF-8 / surrogate pair (emoji)", () => { + // "\u{1F600}" = grinning face, 4 bytes UTF-8, 2 code units UTF-16 + const source = "a\u{1F600}b"; + const converter = createSwcSpanConverter(source); + // 'a' = 1 byte, emoji = 4 bytes, 'b' = 1 byte → 6 bytes + expect(converter.byteLength).toBe(6); + // byte 0 → char 0 ('a') + expect(converter.byteOffsetToCharIndex(0)).toBe(0); + // byte 1 → char 1 (start of emoji, first surrogate) + expect(converter.byteOffsetToCharIndex(1)).toBe(1); + // byte 5 → char 3 ('b', after 2 code units for surrogate pair) + expect(converter.byteOffsetToCharIndex(5)).toBe(3); + // byte 6 → char 4 (end sentinel) + expect(converter.byteOffsetToCharIndex(6)).toBe(4); + }); + + test("mixed ASCII and multi-byte", () => { + // "hello \u3053\u3093\u306B\u3061\u306F world" + const source = "hello \u3053\u3093\u306B\u3061\u306F world"; + const converter = createSwcSpanConverter(source); + // "hello " = 6 bytes, 5 CJK chars = 15 bytes, " world" = 6 bytes → 27 bytes + expect(converter.byteLength).toBe(27); + + // "hello " → bytes 0-5, chars 0-5 + expect(converter.byteOffsetToCharIndex(0)).toBe(0); + expect(converter.byteOffsetToCharIndex(5)).toBe(5); + + // First CJK char starts at byte 6 → char 6 + expect(converter.byteOffsetToCharIndex(6)).toBe(6); + + // " world" starts at byte 21 → char 11 + expect(converter.byteOffsetToCharIndex(21)).toBe(11); + + // End sentinel + expect(converter.byteOffsetToCharIndex(27)).toBe(17); + }); + + test("end sentinel: byteOffsetToCharIndex(byteLength) === source.length", () => { + const sources = [ + "", + "ascii", + "caf\u00E9", + "\u3053\u3093\u306B\u3061\u306F", + "a\u{1F600}b", + "hello \u3053\u3093\u306B\u3061\u306F world", + ]; + for (const source of sources) { + const converter = createSwcSpanConverter(source); + expect(converter.byteOffsetToCharIndex(converter.byteLength)).toBe(source.length); + } + }); +}); diff --git a/packages/common/src/utils/swc-span.ts b/packages/common/src/utils/swc-span.ts new file mode 100644 index 000000000..f8de1eb3e --- /dev/null +++ b/packages/common/src/utils/swc-span.ts @@ -0,0 +1,67 @@ +/** + * SWC span position converter: UTF-8 byte offsets → UTF-16 code unit indices. + * + * SWC (Rust-based) returns span positions as UTF-8 byte offsets. + * JavaScript strings use UTF-16 code units for indexing. + * For ASCII-only content these are identical, but for multi-byte + * characters the positions diverge. + */ + +export type SwcSpanConverter = { + /** UTF-8 byte length of the source string */ + readonly byteLength: number; + /** Convert a UTF-8 byte offset (within the source) to a UTF-16 code unit index */ + readonly byteOffsetToCharIndex: (byteOffset: number) => number; +}; + +/** + * Create a converter that maps UTF-8 byte offsets to UTF-16 char indices + * for the given source string. + * + * Includes a fast path for ASCII-only sources (zero allocation). + */ +export const createSwcSpanConverter = (source: string): SwcSpanConverter => { + const byteLength = Buffer.byteLength(source, "utf8"); + + // Fast path: ASCII-only — byte offsets equal char indices + if (byteLength === source.length) { + return { + byteLength, + byteOffsetToCharIndex: (byteOffset: number) => byteOffset, + }; + } + + // Build lookup table: byteOffset → charIndex + const byteToChar = new Uint32Array(byteLength + 1); + let bytePos = 0; + + for (let charIdx = 0; charIdx < source.length; charIdx++) { + const codePoint = source.codePointAt(charIdx)!; + const bytesForCodePoint = + codePoint <= 0x7f + ? 1 + : codePoint <= 0x7ff + ? 2 + : codePoint <= 0xffff + ? 3 + : 4; + + for (let b = 0; b < bytesForCodePoint; b++) { + byteToChar[bytePos + b] = charIdx; + } + bytePos += bytesForCodePoint; + + // Astral code points use a surrogate pair (2 UTF-16 code units) + if (codePoint > 0xffff) { + charIdx++; + } + } + + // Sentinel: end-of-string + byteToChar[byteLength] = source.length; + + return { + byteLength, + byteOffsetToCharIndex: (byteOffset: number) => byteToChar[byteOffset]!, + }; +}; diff --git a/packages/lsp/package.json b/packages/lsp/package.json index ac1cf5ba6..bbaa4d0eb 100644 --- a/packages/lsp/package.json +++ b/packages/lsp/package.json @@ -50,6 +50,7 @@ "dependencies": { "@soda-gql/builder": "workspace:*", "@soda-gql/codegen": "workspace:*", + "@soda-gql/common": "workspace:*", "@soda-gql/config": "workspace:*", "@swc/core": "^1.6.3", "@swc/types": "^0.1.6", diff --git a/packages/lsp/src/document-manager.test.ts b/packages/lsp/src/document-manager.test.ts index e0761713e..2b3646672 100644 --- a/packages/lsp/src/document-manager.test.ts +++ b/packages/lsp/src/document-manager.test.ts @@ -147,6 +147,32 @@ describe("createDocumentManager", () => { expect(extracted).toBe(t.content); }); + test("contentRange correctly maps back to source with non-ASCII content", () => { + const dm = createDocumentManager(helper); + const source = readFixture("unicode-comments.ts"); + const uri = resolve(fixturesDir, "unicode-comments.ts"); + const state = dm.update(uri, 1, source); + + expect(state.templates).toHaveLength(1); + const t = state.templates[0]!; + const extracted = source.slice(t.contentRange.start, t.contentRange.end); + expect(extracted).toBe(t.content); + }); + + test("findTemplateAtOffset works with non-ASCII content before template", () => { + const dm = createDocumentManager(helper); + const source = readFixture("unicode-comments.ts"); + const uri = resolve(fixturesDir, "unicode-comments.ts"); + dm.update(uri, 1, source); + + const state = dm.get(uri)!; + const t = state.templates[0]!; + const midOffset = Math.floor((t.contentRange.start + t.contentRange.end) / 2); + const found = dm.findTemplateAtOffset(uri, midOffset); + expect(found).toBeDefined(); + expect(found!.content).toBe(t.content); + }); + test("remove clears document state", () => { const dm = createDocumentManager(helper); const source = readFixture("simple-query.ts"); diff --git a/packages/lsp/src/document-manager.ts b/packages/lsp/src/document-manager.ts index 870abaf97..c459d2cbd 100644 --- a/packages/lsp/src/document-manager.ts +++ b/packages/lsp/src/document-manager.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import type { GraphqlSystemIdentifyHelper } from "@soda-gql/builder"; +import { type SwcSpanConverter, createSwcSpanConverter } from "@soda-gql/common"; import { parseSync } from "@swc/core"; import type { ArrowFunctionExpression, @@ -119,8 +120,8 @@ const getGqlCallSchemaName = (identifiers: ReadonlySet, call: CallExpres const extractTemplatesFromCallback = ( arrow: ArrowFunctionExpression, schemaName: string, - source: string, spanOffset: number, + converter: SwcSpanConverter, ): ExtractedTemplate[] => { const templates: ExtractedTemplate[] = []; @@ -128,7 +129,7 @@ const extractTemplatesFromCallback = ( // Direct tagged template: query`...` if (expr.type === "TaggedTemplateExpression") { const tagged = expr as TaggedTemplateExpression; - extractFromTaggedTemplate(tagged, schemaName, source, spanOffset, templates); + extractFromTaggedTemplate(tagged, schemaName, spanOffset, converter, templates); return; } @@ -136,7 +137,7 @@ const extractTemplatesFromCallback = ( if (expr.type === "CallExpression") { const call = expr as CallExpression; if (call.callee.type === "TaggedTemplateExpression") { - extractFromTaggedTemplate(call.callee as TaggedTemplateExpression, schemaName, source, spanOffset, templates); + extractFromTaggedTemplate(call.callee as TaggedTemplateExpression, schemaName, spanOffset, converter, templates); } } }; @@ -160,8 +161,8 @@ const extractTemplatesFromCallback = ( const extractFromTaggedTemplate = ( tagged: TaggedTemplateExpression, schemaName: string, - source: string, spanOffset: number, + converter: SwcSpanConverter, templates: ExtractedTemplate[], ): void => { // Tag must be an identifier matching an operation kind @@ -186,15 +187,9 @@ const extractFromTaggedTemplate = ( const content = quasi.cooked ?? quasi.raw; - // Compute content range from quasi span - // The quasi span includes the backticks, so we need the inner content offset - const quasiStart = quasi.span.start - spanOffset; - const quasiEnd = quasi.span.end - spanOffset; - - // The quasi span in SWC points to the content between backticks - // Verify by checking source alignment - const contentStart = quasiStart; - const contentEnd = quasiEnd; + // Convert SWC byte offsets to UTF-16 char indices via the converter + const contentStart = converter.byteOffsetToCharIndex(quasi.span.start - spanOffset); + const contentEnd = converter.byteOffsetToCharIndex(quasi.span.end - spanOffset); templates.push({ contentRange: { start: contentStart, end: contentEnd }, @@ -211,8 +206,8 @@ const extractFromTaggedTemplate = ( const walkAndExtract = ( node: Node, identifiers: ReadonlySet, - source: string, spanOffset: number, + converter: SwcSpanConverter, ): ExtractedTemplate[] => { const templates: ExtractedTemplate[] = []; @@ -228,7 +223,7 @@ const walkAndExtract = ( const schemaName = getGqlCallSchemaName(identifiers, gqlCall); if (schemaName) { const arrow = gqlCall.arguments[0]?.expression as ArrowFunctionExpression; - templates.push(...extractTemplatesFromCallback(arrow, schemaName, source, spanOffset)); + templates.push(...extractTemplatesFromCallback(arrow, schemaName, spanOffset, converter)); } return; // Don't recurse into gql calls } @@ -291,7 +286,9 @@ export const createDocumentManager = (helper: GraphqlSystemIdentifyHelper): Docu } // SWC's BytePos counter accumulates across parseSync calls within the same process. - const spanOffset = program.span.end - source.length + 1; + // Use UTF-8 byte length (not source.length which is UTF-16 code units) for correct offset. + const converter = createSwcSpanConverter(source); + const spanOffset = program.span.end - converter.byteLength + 1; // Convert URI to a file path for the helper const filePath = uri.startsWith("file://") ? fileURLToPath(uri) : uri; @@ -301,7 +298,7 @@ export const createDocumentManager = (helper: GraphqlSystemIdentifyHelper): Docu return []; } - return walkAndExtract(program, gqlIdentifiers, source, spanOffset); + return walkAndExtract(program, gqlIdentifiers, spanOffset, converter); }; return { diff --git a/packages/lsp/test/fixtures/unicode-comments.ts b/packages/lsp/test/fixtures/unicode-comments.ts new file mode 100644 index 000000000..ecd07cc41 --- /dev/null +++ b/packages/lsp/test/fixtures/unicode-comments.ts @@ -0,0 +1,6 @@ +// このファイルはUTF-8マルチバイト文字を含むテストフィクスチャです +// テスト: 非ASCII文字がGraphQLテンプレートの前に存在する場合 +import { gql } from "@/graphql-system"; + +// コメント: ユーザー情報を取得するクエリ 🚀 +export const GetUser = gql.default(({ query }) => query`query GetUser($id: ID!) { user(id: $id) { id name } }`); From b7a4535c8597e44e94570d3b9f7f97116df1fe88 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 20:42:49 +0900 Subject: [PATCH 16/16] fix(lsp): address BugBot finding - show error on schema reload failure When schemaResolver.reloadAll() fails, show an error message via connection.window.showErrorMessage() instead of silently ignoring the error. BugBot comment: https://github.com/whatasoda/soda-gql/pull/308#discussion_r2751061213 Co-Authored-By: Claude Opus 4.5 --- packages/lsp/src/server.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/lsp/src/server.ts b/packages/lsp/src/server.ts index e55dd7e81..9314c60e8 100644 --- a/packages/lsp/src/server.ts +++ b/packages/lsp/src/server.ts @@ -207,6 +207,8 @@ export const createLspServer = (options?: LspServerOptions) => { const result = schemaResolver.reloadAll(); if (result.isOk()) { publishDiagnosticsForAllOpen(); + } else { + connection.window.showErrorMessage(`soda-gql LSP: schema reload failed: ${result.error.message}`); } } });