Skip to content

Commit b4f216b

Browse files
whatasodaclaude
andauthored
feat: GraphQL LSP server with multi-schema support (MVP) (#308)
## Summary - Implement `@soda-gql/lsp` package providing a GraphQL Language Server Protocol server for soda-gql's tagged template API (RFC #307, Phase 0 + Phase 1) - SWC-based template extraction from `gql.{schemaName}(({ query }) => query`...`)` callback patterns with multi-schema resolution - LSP features: diagnostics (validation), completion (field/argument suggestions), hover (type information) - Fragment Arguments RFC syntax preprocessing (strips non-standard syntax before `graphql-language-service` validation) - Bidirectional TS ↔ GraphQL position mapping for accurate diagnostic/completion positions - CLI integration via `soda-gql lsp` command (stdio transport) ### Architecture | Component | File | Role | |-----------|------|------| | Schema resolver | `src/schema-resolver.ts` | Loads/caches GraphQL schemas from config | | Document manager | `src/document-manager.ts` | SWC-based tagged template extraction | | Fragment preprocessor | `src/fragment-args-preprocessor.ts` | Strips Fragment Arguments for validation | | Position mapping | `src/position-mapping.ts` | TS ↔ GraphQL position conversion | | Diagnostics | `src/handlers/diagnostics.ts` | Schema validation via graphql-language-service | | Completion | `src/handlers/completion.ts` | Field/argument autocompletion | | Hover | `src/handlers/hover.ts` | Type information on hover | | Server | `src/server.ts` | LSP protocol wiring (vscode-languageserver) | ### Not included (deferred) - `codegen lsp-config` subcommand (`.graphqlrc.generated.json` generation) — separate PR - Phase 2+ features (definition, cross-file fragments, inlay hints) ## Test plan - [ ] `bun --conditions=@soda-gql test packages/lsp/` — 60 tests pass - [ ] `bun typecheck` — no LSP-introduced errors (pre-existing `packages/core` errors only) - [ ] `bun --conditions=@soda-gql packages/cli/src/index.ts lsp --help` — prints help - [ ] Integration tests verify end-to-end flow: document parsing → diagnostics/completion/hover 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 9fc2c24 commit b4f216b

43 files changed

Lines changed: 2656 additions & 11 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

bun.lock

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/builder/src/ast/adapters/swc.ts

Lines changed: 14 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
* Implements parser-specific logic using the SWC parser.
44
*/
55

6-
import { createCanonicalId, createCanonicalTracker, type ScopeHandle } from "@soda-gql/common";
6+
import { createCanonicalId, createCanonicalTracker, createSwcSpanConverter, type ScopeHandle, type SwcSpanConverter } from "@soda-gql/common";
77
import { parseSync } from "@swc/core";
88
import type { CallExpression, ImportDeclaration, Module } from "@swc/types";
99
import type { GraphqlSystemIdentifyHelper } from "../../internal/graphql-system";
@@ -17,6 +17,8 @@ type SwcModule = Module & {
1717
__filePath: string;
1818
/** Offset to subtract from spans to normalize to 0-based source indices */
1919
__spanOffset: number;
20+
/** Converter for UTF-8 byte offsets to UTF-16 char indices */
21+
__spanConverter: SwcSpanConverter;
2022
};
2123

2224
import { createStandardDiagnostic } from "../common/detection";
@@ -362,10 +364,11 @@ const collectAllDefinitions = ({
362364
};
363365

364366
const expressionFromCall = (call: CallExpression): string => {
365-
// Normalize span by subtracting the module's span offset
367+
// Normalize span by subtracting the module's span offset, then convert byte→char
366368
const spanOffset = module.__spanOffset;
367-
let start = call.span.start - spanOffset;
368-
const end = call.span.end - spanOffset;
369+
const converter = module.__spanConverter;
370+
let start = converter.byteOffsetToCharIndex(call.span.start - spanOffset);
371+
const end = converter.byteOffsetToCharIndex(call.span.end - spanOffset);
369372

370373
// Adjust when span starts one character after the leading "g"
371374
if (start > 0 && source[start] === "q" && source[start - 1] === "g" && source.slice(start, start + 3) === "ql.") {
@@ -587,8 +590,9 @@ const collectAllDefinitions = ({
587590
* Get location from an SWC node span
588591
*/
589592
const getLocation = (module: SwcModule, span: { start: number; end: number }): DiagnosticLocation => {
590-
const start = span.start - module.__spanOffset;
591-
const end = span.end - module.__spanOffset;
593+
const converter = module.__spanConverter;
594+
const start = converter.byteOffsetToCharIndex(span.start - module.__spanOffset);
595+
const end = converter.byteOffsetToCharIndex(span.end - module.__spanOffset);
592596
return { start, end };
593597
};
594598

@@ -916,15 +920,15 @@ export const swcAdapter: AnalyzerAdapter = {
916920
}
917921

918922
// SWC's BytePos counter accumulates across parseSync calls within the same process.
919-
// To convert span positions to 0-indexed source positions, we compute the accumulated
920-
// offset from previous parses: (program.span.end - source.length) gives us the total
921-
// bytes from previously parsed files, and we add 1 because spans are 1-indexed.
922-
const spanOffset = program.span.end - input.source.length + 1;
923+
// Use UTF-8 byte length (not source.length which is UTF-16 code units) for correct offset.
924+
const converter = createSwcSpanConverter(input.source);
925+
const spanOffset = program.span.end - converter.byteLength + 1;
923926

924927
// Attach filePath to module (similar to ts.SourceFile.fileName)
925928
const swcModule = program as SwcModule;
926929
swcModule.__filePath = input.filePath;
927930
swcModule.__spanOffset = spanOffset;
931+
swcModule.__spanConverter = converter;
928932

929933
// Collect all data in one pass
930934
const gqlIdentifiers = collectGqlIdentifiers(swcModule, helper);

packages/cli/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
"zod": "^4.1.11"
6161
},
6262
"optionalDependencies": {
63-
"@soda-gql/formatter": "workspace:*"
63+
"@soda-gql/formatter": "workspace:*",
64+
"@soda-gql/lsp": "workspace:*"
6465
}
6566
}

packages/cli/src/commands/lsp.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
const LSP_HELP = `Usage: soda-gql lsp [options]
2+
3+
Start the GraphQL Language Server Protocol server.
4+
5+
The LSP server communicates over stdio and provides:
6+
- Diagnostics (validation errors in GraphQL templates)
7+
- Autocompletion (field, argument, type suggestions)
8+
- Hover information (type details on hover)
9+
10+
Options:
11+
--help, -h Show this help message
12+
13+
The server is typically started by an editor extension, not directly by users.
14+
Configure your editor to use 'soda-gql lsp' as the GraphQL language server command.`;
15+
16+
export const lspCommand = async (argv: readonly string[]): Promise<never> => {
17+
if (argv.includes("--help") || argv.includes("-h")) {
18+
process.stdout.write(`${LSP_HELP}\n`);
19+
process.exit(0);
20+
}
21+
22+
// Dynamic import to avoid loading LSP deps for other commands
23+
const { createLspServer } = await import("@soda-gql/lsp");
24+
const server = createLspServer();
25+
server.start();
26+
27+
// Server runs indefinitely via stdio; this promise never resolves
28+
await new Promise(() => {});
29+
// TypeScript needs this for the `never` return type
30+
throw new Error("unreachable");
31+
};

packages/cli/src/index.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { codegenCommand } from "./commands/codegen/index";
44
import { doctorCommand } from "./commands/doctor";
55
import { formatCommand } from "./commands/format";
66
import { initCommand } from "./commands/init";
7+
import { lspCommand } from "./commands/lsp";
78
import { typegenCommand } from "./commands/typegen";
89
import { cliErrors } from "./errors";
910
import type { CommandResult, CommandSuccess, OutputFormat } from "./types";
@@ -18,6 +19,7 @@ Commands:
1819
format Format soda-gql field selections
1920
artifact Manage soda-gql artifacts
2021
doctor Run diagnostic checks
22+
lsp Start the GraphQL language server
2123
2224
Run 'soda-gql <command> --help' for more information on a specific command.
2325
`;
@@ -73,6 +75,11 @@ const dispatch = async (argv: readonly string[]): Promise<DispatchResult> => {
7375
return artifactCommand(rest);
7476
}
7577

78+
if (command === "lsp") {
79+
await lspCommand(rest);
80+
return ok({ message: "" }); // unreachable, lsp runs forever
81+
}
82+
7683
if (command === "doctor") {
7784
const result = doctorCommand(rest);
7885
if (result.isOk()) {

packages/common/src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from "./alias-resolver";
22
export * from "./cached-fn";
33
export * from "./path";
4+
export * from "./swc-span";
45
export * from "./tsconfig";
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
import { describe, expect, test } from "bun:test";
2+
import { createSwcSpanConverter } from "./swc-span";
3+
4+
describe("createSwcSpanConverter", () => {
5+
test("ASCII-only: byteLength equals string length", () => {
6+
const source = "const x = 42;";
7+
const converter = createSwcSpanConverter(source);
8+
expect(converter.byteLength).toBe(source.length);
9+
});
10+
11+
test("ASCII-only: identity conversion", () => {
12+
const source = "hello world";
13+
const converter = createSwcSpanConverter(source);
14+
for (let i = 0; i <= source.length; i++) {
15+
expect(converter.byteOffsetToCharIndex(i)).toBe(i);
16+
}
17+
});
18+
19+
test("empty string", () => {
20+
const converter = createSwcSpanConverter("");
21+
expect(converter.byteLength).toBe(0);
22+
expect(converter.byteOffsetToCharIndex(0)).toBe(0);
23+
});
24+
25+
test("2-byte UTF-8 characters (accented)", () => {
26+
// "\u00E9" = e-acute, 2 bytes in UTF-8, 1 code unit in UTF-16
27+
const source = "caf\u00E9";
28+
const converter = createSwcSpanConverter(source);
29+
// "caf" = 3 bytes, "\u00E9" = 2 bytes → total 5 bytes
30+
expect(converter.byteLength).toBe(5);
31+
// byte 0 → char 0 ('c')
32+
expect(converter.byteOffsetToCharIndex(0)).toBe(0);
33+
// byte 3 → char 3 (start of '\u00E9')
34+
expect(converter.byteOffsetToCharIndex(3)).toBe(3);
35+
// byte 5 → char 4 (end sentinel)
36+
expect(converter.byteOffsetToCharIndex(5)).toBe(4);
37+
});
38+
39+
test("3-byte UTF-8 characters (CJK)", () => {
40+
// Each Japanese character is 3 bytes in UTF-8, 1 code unit in UTF-16
41+
const source = "\u3053\u3093\u306B\u3061\u306F"; // konnichiwa
42+
const converter = createSwcSpanConverter(source);
43+
expect(converter.byteLength).toBe(15); // 5 chars * 3 bytes
44+
// byte 0 → char 0
45+
expect(converter.byteOffsetToCharIndex(0)).toBe(0);
46+
// byte 3 → char 1
47+
expect(converter.byteOffsetToCharIndex(3)).toBe(1);
48+
// byte 6 → char 2
49+
expect(converter.byteOffsetToCharIndex(6)).toBe(2);
50+
// byte 15 → char 5 (end sentinel)
51+
expect(converter.byteOffsetToCharIndex(15)).toBe(5);
52+
});
53+
54+
test("4-byte UTF-8 / surrogate pair (emoji)", () => {
55+
// "\u{1F600}" = grinning face, 4 bytes UTF-8, 2 code units UTF-16
56+
const source = "a\u{1F600}b";
57+
const converter = createSwcSpanConverter(source);
58+
// 'a' = 1 byte, emoji = 4 bytes, 'b' = 1 byte → 6 bytes
59+
expect(converter.byteLength).toBe(6);
60+
// byte 0 → char 0 ('a')
61+
expect(converter.byteOffsetToCharIndex(0)).toBe(0);
62+
// byte 1 → char 1 (start of emoji, first surrogate)
63+
expect(converter.byteOffsetToCharIndex(1)).toBe(1);
64+
// byte 5 → char 3 ('b', after 2 code units for surrogate pair)
65+
expect(converter.byteOffsetToCharIndex(5)).toBe(3);
66+
// byte 6 → char 4 (end sentinel)
67+
expect(converter.byteOffsetToCharIndex(6)).toBe(4);
68+
});
69+
70+
test("mixed ASCII and multi-byte", () => {
71+
// "hello \u3053\u3093\u306B\u3061\u306F world"
72+
const source = "hello \u3053\u3093\u306B\u3061\u306F world";
73+
const converter = createSwcSpanConverter(source);
74+
// "hello " = 6 bytes, 5 CJK chars = 15 bytes, " world" = 6 bytes → 27 bytes
75+
expect(converter.byteLength).toBe(27);
76+
77+
// "hello " → bytes 0-5, chars 0-5
78+
expect(converter.byteOffsetToCharIndex(0)).toBe(0);
79+
expect(converter.byteOffsetToCharIndex(5)).toBe(5);
80+
81+
// First CJK char starts at byte 6 → char 6
82+
expect(converter.byteOffsetToCharIndex(6)).toBe(6);
83+
84+
// " world" starts at byte 21 → char 11
85+
expect(converter.byteOffsetToCharIndex(21)).toBe(11);
86+
87+
// End sentinel
88+
expect(converter.byteOffsetToCharIndex(27)).toBe(17);
89+
});
90+
91+
test("end sentinel: byteOffsetToCharIndex(byteLength) === source.length", () => {
92+
const sources = [
93+
"",
94+
"ascii",
95+
"caf\u00E9",
96+
"\u3053\u3093\u306B\u3061\u306F",
97+
"a\u{1F600}b",
98+
"hello \u3053\u3093\u306B\u3061\u306F world",
99+
];
100+
for (const source of sources) {
101+
const converter = createSwcSpanConverter(source);
102+
expect(converter.byteOffsetToCharIndex(converter.byteLength)).toBe(source.length);
103+
}
104+
});
105+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* SWC span position converter: UTF-8 byte offsets → UTF-16 code unit indices.
3+
*
4+
* SWC (Rust-based) returns span positions as UTF-8 byte offsets.
5+
* JavaScript strings use UTF-16 code units for indexing.
6+
* For ASCII-only content these are identical, but for multi-byte
7+
* characters the positions diverge.
8+
*/
9+
10+
export type SwcSpanConverter = {
11+
/** UTF-8 byte length of the source string */
12+
readonly byteLength: number;
13+
/** Convert a UTF-8 byte offset (within the source) to a UTF-16 code unit index */
14+
readonly byteOffsetToCharIndex: (byteOffset: number) => number;
15+
};
16+
17+
/**
18+
* Create a converter that maps UTF-8 byte offsets to UTF-16 char indices
19+
* for the given source string.
20+
*
21+
* Includes a fast path for ASCII-only sources (zero allocation).
22+
*/
23+
export const createSwcSpanConverter = (source: string): SwcSpanConverter => {
24+
const byteLength = Buffer.byteLength(source, "utf8");
25+
26+
// Fast path: ASCII-only — byte offsets equal char indices
27+
if (byteLength === source.length) {
28+
return {
29+
byteLength,
30+
byteOffsetToCharIndex: (byteOffset: number) => byteOffset,
31+
};
32+
}
33+
34+
// Build lookup table: byteOffset → charIndex
35+
const byteToChar = new Uint32Array(byteLength + 1);
36+
let bytePos = 0;
37+
38+
for (let charIdx = 0; charIdx < source.length; charIdx++) {
39+
const codePoint = source.codePointAt(charIdx)!;
40+
const bytesForCodePoint =
41+
codePoint <= 0x7f
42+
? 1
43+
: codePoint <= 0x7ff
44+
? 2
45+
: codePoint <= 0xffff
46+
? 3
47+
: 4;
48+
49+
for (let b = 0; b < bytesForCodePoint; b++) {
50+
byteToChar[bytePos + b] = charIdx;
51+
}
52+
bytePos += bytesForCodePoint;
53+
54+
// Astral code points use a surrogate pair (2 UTF-16 code units)
55+
if (codePoint > 0xffff) {
56+
charIdx++;
57+
}
58+
}
59+
60+
// Sentinel: end-of-string
61+
byteToChar[byteLength] = source.length;
62+
63+
return {
64+
byteLength,
65+
byteOffsetToCharIndex: (byteOffset: number) => byteToChar[byteOffset]!,
66+
};
67+
};

packages/lsp/@x-index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./src/index";

0 commit comments

Comments
 (0)