diff --git a/bun.lock b/bun.lock index 7883cda5..ee73c279 100644 --- a/bun.lock +++ b/bun.lock @@ -93,6 +93,7 @@ }, "optionalDependencies": { "@soda-gql/formatter": "workspace:*", + "@soda-gql/lsp": "workspace:*", }, }, "packages/codegen": { @@ -156,6 +157,23 @@ "@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/common": "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 +1147,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 +1559,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 +1817,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 +2823,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/builder/src/ast/adapters/swc.ts b/packages/builder/src/ast/adapters/swc.ts index 444166b9..38daa587 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/cli/package.json b/packages/cli/package.json index 782b7dec..fc39a482 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 00000000..ca4aa156 --- /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 5a91471c..842c3e77 100755 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -4,6 +4,7 @@ import { codegenCommand } from "./commands/codegen/index"; import { doctorCommand } from "./commands/doctor"; import { formatCommand } from "./commands/format"; 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"; @@ -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()) { diff --git a/packages/common/src/utils/index.ts b/packages/common/src/utils/index.ts index db32e62f..8d6f6fd4 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 00000000..5bfcc248 --- /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 00000000..f8de1eb3 --- /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/@x-index.ts b/packages/lsp/@x-index.ts new file mode 100644 index 00000000..e910bb06 --- /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 00000000..bbaa4d0e --- /dev/null +++ b/packages/lsp/package.json @@ -0,0 +1,63 @@ +{ + "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/common": "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/document-manager.test.ts b/packages/lsp/src/document-manager.test.ts new file mode 100644 index 00000000..2b364667 --- /dev/null +++ b/packages/lsp/src/document-manager.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +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("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"); + 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 00000000..c459d2cb --- /dev/null +++ b/packages/lsp/src/document-manager.ts @@ -0,0 +1,326 @@ +/** + * Document manager: tracks open documents and extracts tagged templates using SWC. + * @module + */ + +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, + CallExpression, + Expression, + ImportDeclaration, + MemberExpression, + Module, + Node, + 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; +}; + +/** 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); + +/** + * 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, + spanOffset: number, + converter: SwcSpanConverter, +): 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, spanOffset, converter, 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, spanOffset, converter, 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, + spanOffset: number, + converter: SwcSpanConverter, + 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; + + // 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 }, + schemaName, + kind, + content, + }); +}; + +/** + * Walk AST to find gql calls and extract templates. + * Adapted from builder's unwrapMethodChains + visit pattern. + */ +const walkAndExtract = ( + node: Node, + identifiers: ReadonlySet, + spanOffset: number, + converter: SwcSpanConverter, +): ExtractedTemplate[] => { + const templates: ExtractedTemplate[] = []; + + const visit = (n: Node | ReadonlyArray | Record): void => { + if (!n || typeof n !== "object") { + return; + } + + if ("type" in n && n.type === "CallExpression") { + // Check if this is a gql call (possibly wrapped in method chains) + const gqlCall = findGqlCall(identifiers, n as Node); + if (gqlCall) { + const schemaName = getGqlCallSchemaName(identifiers, gqlCall); + if (schemaName) { + const arrow = gqlCall.arguments[0]?.expression as ArrowFunctionExpression; + templates.push(...extractTemplatesFromCallback(arrow, schemaName, spanOffset, converter)); + } + 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 as Node); + } + return; + } + + for (const key of Object.keys(n)) { + if (key === "span" || key === "type") { + continue; + } + const value = (n as Record)[key]; + if (value && typeof value === "object") { + visit(value as Node); + } + } + }; + + visit(node); + return templates; +}; + +/** + * Find the innermost gql call, unwrapping method chains like .attach(). + */ +const findGqlCall = (identifiers: ReadonlySet, node: Node): CallExpression | null => { + if (!node || node.type !== "CallExpression") { + return null; + } + + const call = node as unknown as CallExpression; + if (getGqlCallSchemaName(identifiers, call) !== null) { + return call; + } + + const callee = call.callee; + if (callee.type !== "MemberExpression") { + return null; + } + + return findGqlCall(identifiers, callee.object as unknown as Node); +}; + +/** 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"); + + const program = safeParseSync(source, isTsx); + if (!program || program.type !== "Module") { + return []; + } + + // SWC's BytePos counter accumulates across parseSync calls within the same process. + // 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; + + const gqlIdentifiers = collectGqlIdentifiers(program, filePath, helper); + if (gqlIdentifiers.size === 0) { + return []; + } + + return walkAndExtract(program, gqlIdentifiers, spanOffset, converter); + }; + + 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/errors.test.ts b/packages/lsp/src/errors.test.ts new file mode 100644 index 00000000..a8b6795b --- /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 00000000..d3fc7066 --- /dev/null +++ b/packages/lsp/src/errors.ts @@ -0,0 +1,92 @@ +/** + * 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"; + +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 = + | 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): ConfigLoadFailed => ({ + code: "CONFIG_LOAD_FAILED", + message, + cause, + }), + + 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): SchemaBuildFailed => ({ + code: "SCHEMA_BUILD_FAILED", + message: message ?? `Failed to build schema: ${schemaName}`, + schemaName, + cause, + }), + + 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): ParseFailed => ({ + code: "PARSE_FAILED", + message: message ?? `Failed to parse: ${uri}`, + uri, + cause, + }), + + internalInvariant: (message: string, context?: string, cause?: unknown): InternalInvariant => ({ + code: "INTERNAL_INVARIANT", + message, + context, + cause, + }), +} as const; 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 00000000..a0e617ec --- /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 00000000..81807363 --- /dev/null +++ b/packages/lsp/src/fragment-args-preprocessor.ts @@ -0,0 +1,132 @@ +/** + * 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) { + let backslashes = 0; + for (let j = i - 1; j >= 0 && content[j] === "\\"; j--) { + backslashes++; + } + if (backslashes % 2 === 0) { + 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 }; +}; diff --git a/packages/lsp/src/handlers/completion.test.ts b/packages/lsp/src/handlers/completion.test.ts new file mode 100644 index 00000000..3c17c12e --- /dev/null +++ b/packages/lsp/src/handlers/completion.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { loadSchema } from "@soda-gql/codegen"; +import type { DocumentNode } from "graphql"; +import { buildASTSchema } from "graphql"; +import type { ExtractedTemplate } from "../types"; +import { handleCompletion } from "./completion"; + +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 00000000..7265915f --- /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, type Position, toIPosition } 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 00000000..dc5fc398 --- /dev/null +++ b/packages/lsp/src/handlers/diagnostics.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { loadSchema } from "@soda-gql/codegen"; +import type { DocumentNode } from "graphql"; +import { buildASTSchema } from "graphql"; +import type { ExtractedTemplate } from "../types"; +import { computeTemplateDiagnostics } from "./diagnostics"; + +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 00000000..a75c52bc --- /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 00000000..fb3fbc1f --- /dev/null +++ b/packages/lsp/src/handlers/hover.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { loadSchema } from "@soda-gql/codegen"; +import type { DocumentNode } from "graphql"; +import { buildASTSchema } from "graphql"; +import type { ExtractedTemplate } from "../types"; +import { handleHover } from "./hover"; + +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 00000000..56a87f15 --- /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, type Position, toIPosition } 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/index.ts b/packages/lsp/src/index.ts new file mode 100644 index 00000000..6cb87fbf --- /dev/null +++ b/packages/lsp/src/index.ts @@ -0,0 +1,14 @@ +// @soda-gql/lsp - GraphQL LSP server for soda-gql + +export type { DocumentManager } from "./document-manager"; +export { createDocumentManager } from "./document-manager"; +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 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 new file mode 100644 index 00000000..fd76a4f8 --- /dev/null +++ b/packages/lsp/src/position-mapping.test.ts @@ -0,0 +1,131 @@ +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 = 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).toEqual({ line: 2, character: 4 }); + 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 00000000..47d0e0fb --- /dev/null +++ b/packages/lsp/src/position-mapping.ts @@ -0,0 +1,111 @@ +/** + * 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]! }; +}; + +/** 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; + 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); + }, + }; +}; diff --git a/packages/lsp/src/schema-resolver.test.ts b/packages/lsp/src/schema-resolver.test.ts new file mode 100644 index 00000000..910514c6 --- /dev/null +++ b/packages/lsp/src/schema-resolver.test.ts @@ -0,0 +1,106 @@ +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 => + ({ + 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 00000000..19cee757 --- /dev/null +++ b/packages/lsp/src/schema-resolver.ts @@ -0,0 +1,101 @@ +/** + * Schema resolver: maps schema names to GraphQLSchema objects. + * @module + */ + +import { resolve } from "node:path"; +import { hashSchema, loadSchema } from "@soda-gql/codegen"; +import type { ResolvedSodaGqlConfig } from "@soda-gql/config"; +import { buildASTSchema, type DocumentNode, type GraphQLSchema } from "graphql"; +import { err, ok, type Result } from "neverthrow"; +import type { LspError } from "./errors"; +import { lspErrors } 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; +}; + +/** 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); + 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); + + 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. */ +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/src/server.ts b/packages/lsp/src/server.ts new file mode 100644 index 00000000..9314c60e --- /dev/null +++ b/packages/lsp/src/server.ts @@ -0,0 +1,236 @@ +/** + * LSP server: wires all components together via vscode-languageserver. + * @module + */ + +import { fileURLToPath } from "node:url"; +import { createGraphqlSystemIdentifyHelper } from "@soda-gql/builder"; +import { loadConfigFrom } from "@soda-gql/config"; +import { + type Connection, + createConnection, + DidChangeWatchedFilesNotification, + FileChangeType, + type InitializeResult, + ProposedFeatures, + type TextDocumentChangeEvent, + TextDocumentSyncKind, + TextDocuments, +} from "vscode-languageserver/node"; +import { TextDocument } from "vscode-languageserver-textdocument"; +import type { DocumentManager } from "./document-manager"; +import { createDocumentManager } from "./document-manager"; +import { handleCompletion } from "./handlers/completion"; +import { computeTemplateDiagnostics } from "./handlers/diagnostics"; +import { handleHover } from "./handlers/hover"; +import type { SchemaResolver } from "./schema-resolver"; +import { createSchemaResolver } from "./schema-resolver"; + +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://") ? fileURLToPath(rootUri) : 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(); + } else { + connection.window.showErrorMessage(`soda-gql LSP: schema reload failed: ${result.error.message}`); + } + } + }); + + 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/src/types.ts b/packages/lsp/src/types.ts new file mode 100644 index 00000000..79363652 --- /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[]; +}; diff --git a/packages/lsp/test/fixtures/block-body.ts b/packages/lsp/test/fixtures/block-body.ts new file mode 100644 index 00000000..23202252 --- /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 00000000..8fdeafc7 --- /dev/null +++ b/packages/lsp/test/fixtures/fragment-with-args.ts @@ -0,0 +1,9 @@ +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 00000000..516a06aa --- /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 00000000..c5c14570 --- /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 00000000..939ceb29 --- /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/schemas/admin.graphql b/packages/lsp/test/fixtures/schemas/admin.graphql new file mode 100644 index 00000000..dd8c7452 --- /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 00000000..61320f31 --- /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! +} diff --git a/packages/lsp/test/fixtures/simple-query.ts b/packages/lsp/test/fixtures/simple-query.ts new file mode 100644 index 00000000..f5822d39 --- /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 } }`); diff --git a/packages/lsp/test/fixtures/unicode-comments.ts b/packages/lsp/test/fixtures/unicode-comments.ts new file mode 100644 index 00000000..ecd07cc4 --- /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 } }`); 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 00000000..e61c98f5 --- /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 { describe, expect, test } from "bun:test"; +import { readFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { createGraphqlSystemIdentifyHelper } from "@soda-gql/builder"; +import type { ResolvedSodaGqlConfig } from "@soda-gql/config"; +import { createDocumentManager } from "../../src/document-manager"; +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"); + +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"); + }); +}); diff --git a/packages/lsp/tsconfig.editor.json b/packages/lsp/tsconfig.editor.json new file mode 100644 index 00000000..25faf7af --- /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", "test/fixtures/*.ts"], + "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 45d9d4af..6d696666 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/tsconfig.json b/tsconfig.json index 5bbf5264..56994e86 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" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index d2e4f9c8..6c3f7e07 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"),