Skip to content

feat: GraphQL LSP server with multi-schema support (MVP)#308

Merged
whatasoda merged 16 commits into
mainfrom
feat/graphql-lsp-mvp
Feb 1, 2026
Merged

feat: GraphQL LSP server with multi-schema support (MVP)#308
whatasoda merged 16 commits into
mainfrom
feat/graphql-lsp-mvp

Conversation

@whatasoda
Copy link
Copy Markdown
Owner

Summary

  • Implement @soda-gql/lsp package providing a GraphQL Language Server Protocol server for soda-gql's tagged template API (RFC RFC: GraphQL LSP with multi-schema support #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

whatasoda and others added 11 commits February 1, 2026 15:51
Set up the new LSP package with package.json, tsconfig, and build config.
Dependencies include graphql-language-service, vscode-languageserver, and @swc/core.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Define ExtractedTemplate, DocumentState, OperationKind types and
LspError discriminated union with constructor helpers.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implement TS file ↔ GraphQL content position conversion with
computeLineOffsets, positionToOffset, offsetToPosition utilities.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Strip Fragment Arguments RFC syntax by replacing argument lists with
equal-length whitespace, preserving line/column alignment for graphql-js
compatibility.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements SchemaResolver that maps schema names to GraphQLSchema objects
using loadSchema/hashSchema from @soda-gql/codegen. Supports eager loading,
per-schema reload, and full reload for file watcher integration.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements DocumentManager that parses TypeScript files with SWC and
extracts GraphQL tagged templates from gql.{schemaName} callback bodies.
Handles expression bodies, block bodies, and metadata chaining patterns.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements three LSP feature handlers using graphql-language-service:
- computeTemplateDiagnostics: validates GraphQL against schema with TS position mapping
- handleCompletion: provides field/argument autocompletion suggestions
- handleHover: shows type information on hover

All handlers integrate fragment-args preprocessing and position mapping.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Implements createLspServer that wires all components together via
vscode-languageserver: config loading, schema resolution, document
management, and feature handlers (diagnostics, completion, hover).
Includes end-to-end integration tests validating the full flow.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Adds 'soda-gql lsp' command that starts the GraphQL LSP server over stdio.
Uses dynamic import to avoid loading LSP dependencies for other commands.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Use specific return types for error constructors instead of the union
type, and exclude test fixtures from typecheck (they reference
@/graphql-system which is a runtime alias).

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Add missing LSP package reference to root tsconfig.json (required for
bun quality to pass). Apply auto-formatter fixes across LSP package.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@github-actions
Copy link
Copy Markdown

github-actions Bot commented Feb 1, 2026

Test Coverage Report

Overall Coverage: 80.4% (11319/14078 lines)

Package Coverage Lines
packages/babel 62.9% 502/798
packages/builder 87.5% 3481/3980
packages/cli 87.3% 227/260
packages/codegen 92.1% 2354/2556
packages/colocation-tools 77.0% 235/305
packages/common 83.1% 602/724
packages/config 79.1% 292/369
packages/core 94.6% 1622/1714
packages/formatter 90.8% 297/327
packages/lsp 91.5% 460/503
packages/metro-plugin 33.7% 101/300
packages/runtime 100.0% 2/2
packages/sdk 91.7% 66/72
packages/swc 18.1% 76/421
packages/tsc 66.8% 532/796
packages/typegen 80.8% 437/541
packages/webpack-plugin 8.0% 33/410

Comment thread packages/lsp/src/schema-resolver.ts
Comment thread packages/lsp/src/document-manager.ts Outdated
const imported = specifier.imported ? specifier.imported.value : specifier.local.value;
if (imported === "gql" && !specifier.imported) {
identifiers.add(specifier.local.value);
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aliased gql imports not tracked by LSP

Medium Severity

The collectGqlIdentifiers function fails to track aliased imports like import { gql as g }. The condition imported === "gql" && !specifier.imported only matches direct imports, not renamed ones. When a user aliases gql, the local identifier won't be tracked, causing the LSP to miss all templates in that file. The existing builder package handles this case correctly.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't Fix

Reason: By design - intentional behavior. The builder package actively reports renamed gql imports as RENAMED_IMPORT diagnostics. The LSP correctly aligns with this constraint by not tracking aliased identifiers. This is consistent across both SWC and TypeScript adapters in the builder.

graphqlToTs: (gqlPosition) => {
const gqlOffset = positionToOffset(gqlLineOffsets, gqlPosition);
const tsOffset = gqlOffset + contentStartOffset;
return offsetToPosition(tsLineOffsets, tsOffset);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

graphqlToTs may produce incorrect positions for invalid input

Low Severity

The graphqlToTs method doesn't validate that positionToOffset returns a valid result. If gqlPosition has an invalid line number, positionToOffset returns -1, but this value is then used directly: tsOffset = -1 + contentStartOffset. This could produce an incorrect but seemingly valid TS position, leading to misplaced diagnostic highlights.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't Fix

Reason: Low impact - risk too low to address. The graphqlToTs method is only called with positions from getDiagnostics() which always returns valid positions within the GraphQL content. The theoretical scenario of invalid line numbers from the graphql-language-service library has no practical occurrence path.

Comment thread packages/lsp/src/server.ts Outdated
…yped AST

- Replace try-catch with neverthrow Result wrappers in schema-resolver and document-manager
- Use Node.js fileURLToPath for cross-platform URI conversion
- Replace standalone `any` with @swc/types Node type for AST traversal

BugBot comments:
- #308 (comment)
- #308 (comment)
- #308 (comment)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comment thread packages/lsp/src/fragment-args-preprocessor.ts Outdated
…tion

Fixed findMatchingParen string escape detection to count consecutive
backslashes before a quote character. An even count means the quote is
unescaped (closes the string), while an odd count means it's escaped.

BugBot comment: #308 (comment)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comment thread packages/lsp/src/position-mapping.test.ts Outdated
}

return -1;
};
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Block string quotes break fragment argument preprocessing

Low Severity

The findMatchingParen function toggles string mode on each " character, which doesn't correctly handle GraphQL block strings ("""..."""). When a block string contains an odd number of embedded quote characters (e.g., """a"b"""), the string state tracking becomes desynchronized, causing the function to fail to find the closing parenthesis. This results in fragment arguments not being stripped, leading to false positive validation errors for that fragment.

Fix in Cursor Fix in Web

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Won't Fix

Reason: Low impact - block strings do not appear in fragment argument positions in real-world query-side GraphQL. The preprocessor's failure mode (returning -1, skipping preprocessing) is safe.

Fixed contentStartOffset from 44 to 43 and added intermediate value assertion
to prevent symmetric error cancellation from masking bugs.
BugBot comment: #308 (comment)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Comment thread packages/lsp/src/document-manager.ts Outdated
SWC returns span positions as UTF-8 byte offsets, but JavaScript string
operations use UTF-16 code units. For files with non-ASCII characters
(CJK, emoji, accented chars), this caused incorrect contentRange values,
diagnostic positions, and template extraction offsets.

Added createSwcSpanConverter utility in @soda-gql/common with:
- ASCII fast path (zero allocation for ASCII-only files)
- Pre-computed Uint32Array lookup for non-ASCII sources
- Applied to both LSP document-manager and builder SWC adapter

BugBot comment: #308 (comment)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
Copy link
Copy Markdown

@cursor cursor Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 1 potential issue.

Bugbot Autofix is OFF. To automatically fix reported issues with Cloud Agents, enable Autofix in the Cursor dashboard.

Comment thread packages/lsp/src/server.ts
When schemaResolver.reloadAll() fails, show an error message via
connection.window.showErrorMessage() instead of silently ignoring the error.

BugBot comment: #308 (comment)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@whatasoda whatasoda merged commit b4f216b into main Feb 1, 2026
3 checks passed
@whatasoda whatasoda deleted the feat/graphql-lsp-mvp branch February 1, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant