From 3cad6d947e5abc190dae9b2c661209aabedf156b Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sat, 31 Jan 2026 20:56:00 +0900 Subject: [PATCH 1/4] docs(rfc): add GraphQL LSP with multi-schema support RFC Comprehensive design document covering: - Tagged template API design (3 pattern candidates with comparison) - LSP architecture analysis (graphql-language-service-server vs from-scratch vs hybrid approach) - Multi-schema resolution via import path association - Implementation plan (5 phases) - Alternatives considered and open questions Co-Authored-By: Claude Opus 4.5 --- docs/rfcs/graphql-lsp-multi-schema.md | 669 ++++++++++++++++++++++++++ 1 file changed, 669 insertions(+) create mode 100644 docs/rfcs/graphql-lsp-multi-schema.md diff --git a/docs/rfcs/graphql-lsp-multi-schema.md b/docs/rfcs/graphql-lsp-multi-schema.md new file mode 100644 index 00000000..0601f2bc --- /dev/null +++ b/docs/rfcs/graphql-lsp-multi-schema.md @@ -0,0 +1,669 @@ +# RFC: GraphQL LSP with Multi-Schema Support + +## Status + +**Draft** - Design in progress + +## Summary + +This RFC proposes building an independent GraphQL Language Server Protocol (LSP) implementation for soda-gql that provides IDE features (autocomplete, diagnostics, hover) for GraphQL operations written as tagged template literals in TypeScript files. Unlike traditional `.graphqlrc.yaml` path-based schema routing, this approach uses explicit schema association through import paths or tag names, solving multi-schema conflicts while maintaining soda-gql's zero-runtime philosophy. + +## Motivation + +### Multi-schema conflicts with `.graphql` files + +The unified codegen pipeline enables reuse of existing `.graphql` assets by generating `.compat.ts` files. However, when a project uses multiple schemas (e.g., `default` and `admin`), there is no ergonomic way to associate each `.graphql` file with its corresponding schema. + +Tools like `graphql-config` solve this with path-based schema routing: + +```yaml +# .graphqlrc.yaml +projects: + default: + schema: ./schemas/default/schema.graphql + documents: ./src/default/**/*.graphql + admin: + schema: ./schemas/admin/schema.graphql + documents: ./src/admin/**/*.graphql +``` + +This approach is **implicit and fragile**: the schema association depends on directory structure rather than explicit declaration. Moving a file silently breaks validation. Developers must remember which directories map to which schemas. + +### TypeScript Language Service Plugin deprecation + +`@0no-co/graphqlsp` (the TypeScript LSP plugin powering `gql.tada`) provides excellent IDE support for GraphQL in TypeScript. However, TypeScript 7 (tsgo/Corsa) will not support the Language Service Plugin API. The existing JS-based plugin architecture fundamentally does not translate to the new Go-based implementation. Building on TS Language Service Plugins is not future-proof. + +References: +- [Transformer Plugin or Compiler API - typescript-go #516](https://github.com/microsoft/typescript-go/issues/516) (proposal removed) +- [What will happen to the existing TypeScript/JavaScript codebase? - Discussion #454](https://github.com/microsoft/typescript-go/discussions/454) + +### No IDE support for the existing composer API + +The current `gql.{schemaName}(() => ...)` API provides type-safe field selection at the TypeScript level but has no GraphQL-aware IDE features (no schema-driven autocomplete, no GraphQL validation diagnostics, no hover documentation for fields). + +### Goals + +1. **Explicit schema association**: Each GraphQL document explicitly declares which schema it targets +2. **IDE features**: Autocomplete, diagnostics, hover, and go-to-definition for GraphQL in TypeScript +3. **Editor-agnostic**: Works with any LSP-compatible editor (VSCode, Neovim, Sublime, etc.) +4. **Future-proof**: Independent of TypeScript Language Service Plugin API +5. **Zero-runtime preserved**: All IDE features are dev-time only; no runtime overhead + +## Background & Context + +### Current soda-gql architecture + +``` +soda-gql.config.ts + schemas: { default: {...}, admin: {...} } + │ + ├─► [Codegen] ─► graphql-system/index.ts (typed composer + runtime) + │ exports: gql.default(), gql.admin() + │ + ├─► [GraphQL Compat] ─► .compat.ts files (from .graphql files) + │ + └─► [Builder] ─► Static analysis of gql.{schema}() calls + │ + └─► [Plugin] ─► Build-time transformation to runtime artifacts +``` + +Key packages: +- `@soda-gql/config`: Configuration loading (`soda-gql.config.ts`) +- `@soda-gql/codegen`: Schema code generation with reachability filtering +- `@soda-gql/builder`: Static analysis engine (TypeScript AST and SWC) +- `@soda-gql/core`: Runtime types, composer, fragment/operation definitions + +### GraphQL LSP ecosystem + +Two main approaches exist for providing GraphQL IDE features: + +1. **graphql-language-service-server** (official): A standalone LSP server built on `graphql-config`. Supports tagged templates in JS/TS files. Part of the [graphql/graphiql](https://github.com/graphql/graphiql) monorepo. + +2. **@0no-co/graphqlsp**: A TypeScript Language Service Plugin. Hooks into tsserver for tighter TS integration. Powers `gql.tada`. Not viable for tsgo/TS7. + +### Why tagged template literals + +Tagged template literals provide a natural syntax for embedding GraphQL in TypeScript: + +```typescript +import { graphql } from "@/graphql-system/default"; + +const GetUser = graphql` + query GetUser($id: ID!) { + user(id: $id) { + id + name + email + } + } +`; +``` + +Advantages: +- **Import path encodes the schema**: `@/graphql-system/default` vs `@/graphql-system/admin` +- **Industry-standard pattern**: Used by Apollo Client, urql, Relay, gql.tada +- **Parser-friendly**: LSP can extract GraphQL content from tagged templates via AST analysis +- **Syntax highlighting**: Editor extensions (e.g., vscode-graphql-syntax) already support GraphQL in tagged templates via TextMate grammar injection + +## Design Decisions + +### 4.1 Tagged Template API + +The tagged template API coexists with the existing `gql.{schemaName}(callback)` composer API. Both styles produce the same build-time artifacts and runtime behavior. + +#### API pattern candidates + +**Pattern A: Schema-specific subpath imports** + +```typescript +import { graphql } from "@/graphql-system/default"; +import { graphql as adminGraphql } from "@/graphql-system/admin"; + +const GetUser = graphql`query GetUser($id: ID!) { user(id: $id) { id name } }`; +const GetAdmin = adminGraphql`query GetAdmin { admins { id } }`; +``` + +**Pattern B: Single import with member access** + +```typescript +import { graphql } from "@/graphql-system"; + +const GetUser = graphql.default`query GetUser($id: ID!) { user(id: $id) { id name } }`; +const GetAdmin = graphql.admin`query GetAdmin { admins { id } }`; +``` + +**Pattern C: Extend existing `gql` with tagged template support** + +```typescript +import { gql } from "@/graphql-system"; + +// Existing: callback style (unchanged) +const GetUser1 = gql.default(({ query, $var }) => + query.operation({ + name: "GetUser", + variables: { ...$var("id").ID("!") }, + fields: ({ f, $ }) => ({ + ...f.user({ id: $.id })(({ f }) => ({ + id: true, + name: true, + })), + }), + }) +); + +// New: tagged template style +const GetUser2 = gql.default` + query GetUser($id: ID!) { + user(id: $id) { id name } + } +`; +``` + +#### Comparison + +| Aspect | Pattern A (subpath) | Pattern B (member) | Pattern C (gql extension) | +|--------|---------------------|--------------------|---------------------------| +| Tag recognition by `graphql-tag-pluck` | ✅ Supported via `modules` config (path + identifier) | ❌ `graphql.default` not supported. Custom parser required | ❌ `gql.default` not supported. Custom parser required | +| External tool compatibility | High (`graphql` is a standard identifier) | Low | Low | +| Integration with existing API | Clear separation (different import paths) | Two usage patterns from same import | Most natural, but ambiguity between `gql.default(callback)` and `` gql.default`tag` `` | +| Multi-schema explicitness | Explicit via import statement | Explicit via member name | Explicit via member name | + +#### Decision criteria + +- **If relying on external tools** (graphql-eslint, existing graphql-config ecosystem): **Pattern A** is the safest choice, as `graphql` is a universally recognized tag identifier. +- **If building a custom LSP** (which this RFC proposes): **Pattern C** provides the best DX by unifying the import and allowing both callback and tagged template styles through the same `gql.{schemaName}` interface. The custom LSP can recognize `gql.{schemaName}` as a tagged template tag regardless of external tool support. + +The RFC presents both options. The final choice depends on how much weight is given to external tool compatibility vs. internal API cohesion. + +#### Runtime behavior + +```typescript +// Generated by codegen (in graphql-system output) +// Tagged template function signature: +export function graphql( + strings: TemplateStringsArray, + ...values: never[] // Interpolation is prohibited +): TypedDocumentNode; +``` + +Key constraints: +- **No interpolation**: `` graphql`...${expr}...` `` is a type error (`never[]` enforces this). Interpolation would break static analysis and LSP autocomplete. +- **Build-time extraction**: The builder plugin detects tagged templates via AST analysis and transforms them into the same artifact format as `gql.{schemaName}(callback)` calls. +- **Runtime transformation**: At build time, tagged templates are replaced with `createRuntimeOperation` / `createRuntimeFragment` calls (same codepath as existing API). + +#### Type inference (Open Question) + +Two approaches for type-safe results from tagged templates: + +**Approach 1: Codegen-generated TypedDocumentNode** (graphql-codegen pattern) +```typescript +const GetUser = graphql`query GetUser($id: ID!) { user(id: $id) { id name } }`; +// Type: TypedDocumentNode +// Types generated by a separate codegen step +``` + +**Approach 2: soda-gql $infer pattern** +```typescript +const GetUser = gql.default`query GetUser($id: ID!) { user(id: $id) { id name } }`; +type Data = typeof GetUser.$infer.output; +type Vars = typeof GetUser.$infer.input; +// Types inferred from the tagged template content at build time +``` + +This decision is deferred to implementation phase. See [Open Questions](#open-questions). + +### 4.2 Schema association mechanism + +Schema association is resolved through import paths: + +1. **Codegen generates per-schema subpaths**: `graphql-system/default/`, `graphql-system/admin/` +2. **LSP resolves imports to schema names**: When the LSP encounters a tagged template, it traces the tag function's import path back to a schema subpath +3. **Config provides the mapping**: The existing `schemas: Record` in `soda-gql.config.ts` defines available schemas. The existing `graphqlSystemAliases` config (e.g., `["@/graphql-system"]`) provides alias resolution. + +Resolution algorithm: +``` +1. Find tagged template at cursor position +2. Trace tag identifier to its import declaration +3. Resolve import path (handle tsconfig paths aliases) +4. Match against config: + - outdir + "/" + schemaName (e.g., "./graphql-system/default") + - graphqlSystemAliases[i] + "/" + schemaName (e.g., "@/graphql-system/default") +5. Return matched schemaName, or report diagnostic if unresolved +``` + +### 4.3 LSP base: build vs. buy analysis + +Three approaches were evaluated for the LSP server implementation: + +#### Option 1: Wrap graphql-language-service-server + +``` +soda-gql LSP Server +├── startServer() from graphql-language-service-server +├── Custom parseDocument (tagged template extraction) +├── graphql-config integration (auto-generated .graphqlrc) +└── Multi-schema resolver (soda-gql.config.ts → project mapping) +``` + +**Extension points available**: +- `parseDocument`: Injectable custom parser for extracting GraphQL from `.ts` files +- `startServer({ method, loadConfigOptions, parser })`: Customizable parser and config loading +- `graphql-config` projects: Per-schema project isolation +- Babel-based parser (`src/parsers/babel.ts`) handles tag recognition with configurable tag names + +**Pros**: +- Autocomplete (`getAutocompleteSuggestions`), diagnostics (`getDiagnostics`), hover, go-to-definition already implemented +- GraphQL spec-compliant validation +- Built-in `.graphql` file support +- Compatible with `graphql-config` ecosystem (graphql-eslint, etc.) + +**Cons**: +- Forces dependency on `graphql-config` (soda-gql has its own config system) +- Multi-schema resolution depends on `graphql-config` projects mechanism — import path analysis still needs custom implementation +- Large dependency footprint (part of graphiql monorepo) +- Offset mapping (cursor position in TS file → position in GraphQL document) needs custom implementation regardless + +#### Option 2: Full from-scratch (vscode-languageserver + graphql-js) + +``` +soda-gql LSP Server +├── vscode-languageserver (LSP protocol handling) +├── Custom TS parser (tagged template extraction) +├── graphql-js (parse, validate, buildSchema) +└── soda-gql config loader (reads soda-gql.config.ts directly) +``` + +**Pros**: +- Reads `soda-gql.config.ts` directly (no config duplication) +- Full control over multi-schema resolution +- Minimal dependency footprint +- Complete freedom in tagged template parsing (supports Pattern A/B/C) +- Can reuse soda-gql's existing TypeScript AST / SWC parsers + +**Cons**: +- Must implement all LSP handlers (completion, hover, diagnostics, definition) +- Must implement GraphQL position calculation, error recovery +- Must implement `.graphql` file support from scratch +- Higher testing and maintenance burden + +#### Option 3: Hybrid (recommended candidate) + +``` +soda-gql LSP Server +├── vscode-languageserver (LSP protocol handling) +├── graphql-language-service (completion/diagnostics/hover logic only) +│ └── getAutocompleteSuggestions(), getDiagnostics(), getHoverInformation() +├── Custom TS parser (tagged template extraction, import analysis) +├── soda-gql config loader (reads soda-gql.config.ts directly) +└── Schema resolver (import path → schema mapping) +``` + +Uses `graphql-language-service` (the **interface package**, not the server package) for the core GraphQL intelligence algorithms, but handles LSP protocol, config management, and file parsing independently. + +**Pros**: +- Battle-tested completion and diagnostics algorithms (from interface layer) +- Reads `soda-gql.config.ts` directly (no `graphql-config` dependency) +- Full control over multi-schema resolution +- Freedom in tagged template parsing design +- Moderate dependency footprint (interface package only, not the full server) + +**Cons**: +- Must implement LSP handlers (but simpler than from-scratch since core logic is borrowed) +- Must implement `.graphql` file support +- Depends on `graphql-language-service` interface package for updates + +#### Comparison matrix + +| Aspect | Option 1: Wrap server | Option 2: From scratch | Option 3: Hybrid | +|--------|-----------------------|------------------------|------------------| +| Initial implementation cost | Low | High | Medium | +| Completion/diagnostics quality | High (proven) | Needs implementation | High (interface layer) | +| Multi-schema control | Constrained by graphql-config | Full freedom | Full freedom | +| Config management | Requires graphql-config | soda-gql.config direct | soda-gql.config direct | +| Tagged template support | parseDocument customization | Custom parser | Custom parser | +| `.graphql` file support | Built-in | Custom implementation | Custom implementation | +| Dependency count | High | Minimal | Moderate | +| Long-term maintenance | Depends on upstream | Fully self-managed | Interface layer only | +| soda-gql-specific features | Constrained | Full freedom | Full freedom | + +### 4.4 Configuration integration + +The LSP reads `soda-gql.config.ts` directly using the existing `@soda-gql/config` package. No separate LSP-specific configuration file is needed. + +For external tool compatibility (graphql-eslint, GraphiQL), a `.graphqlrc.generated.json` can be auto-generated: + +```bash +bun run soda-gql codegen lsp-config +``` + +This generates a `graphql-config`-compatible file from `soda-gql.config.ts`: + +```json +{ + "projects": { + "default": { + "schema": "./schemas/default/schema.graphql", + "documents": "src/**/*.{ts,tsx}" + }, + "admin": { + "schema": "./schemas/admin/schema.graphql", + "documents": "src/**/*.{ts,tsx}" + } + } +} +``` + +The generated file should be gitignored. The LSP itself does **not** read this file — it exists solely for third-party tool compatibility. + +## Detailed Design + +### Package structure + +A new package `@soda-gql/lsp` will be created in `packages/lsp/`: + +``` +packages/lsp/ +├── src/ +│ ├── server.ts # LSP server entry point +│ ├── document-manager.ts # TS file parsing, tagged template extraction +│ ├── schema-resolver.ts # Import path → schema name resolution +│ ├── handlers/ +│ │ ├── completion.ts # textDocument/completion +│ │ ├── diagnostics.ts # textDocument/publishDiagnostics +│ │ ├── hover.ts # textDocument/hover +│ │ └── definition.ts # textDocument/definition +│ └── utils/ +│ └── position-mapping.ts # TS offset ↔ GraphQL offset conversion +├── test/ +└── package.json +``` + +Dependencies (for hybrid approach): +- `vscode-languageserver` / `vscode-languageserver-textdocument`: LSP protocol +- `graphql-language-service`: Completion, diagnostics, hover algorithms +- `graphql`: Parsing and validation +- `@soda-gql/config`: Config loading +- `typescript` (or `@swc/core`): TS AST parsing for tagged template extraction + +### Core components + +#### Document Manager + +Parses TypeScript files to extract tagged templates and their schema associations: + +```typescript +type ExtractedTemplate = { + /** Range within the TS file (for offset mapping) */ + range: { start: number; end: number }; + /** Resolved schema name */ + schemaName: string; + /** Raw GraphQL content (without template tag) */ + content: string; +}; + +type DocumentState = { + uri: string; + version: number; + templates: ExtractedTemplate[]; +}; +``` + +The document manager: +1. Parses the TypeScript file into an AST +2. Finds all tagged template expressions matching `gql.{name}` or `graphql` identifiers +3. Resolves the tag's import to a schema name via the schema resolver +4. Extracts the GraphQL string content +5. Caches results per document (invalidated on change) + +#### Schema Resolver + +Maps import paths to schema names using the soda-gql config: + +```typescript +type SchemaEntry = { + name: string; + schema: GraphQLSchema; + documentNode: DocumentNode; + matchPaths: string[]; // All paths that resolve to this schema +}; +``` + +Builds `matchPaths` from config: +- `{outdir}/{schemaName}` (e.g., `./graphql-system/default`) +- `{alias}/{schemaName}` for each `graphqlSystemAliases` entry (e.g., `@/graphql-system/default`) +- Handles tsconfig `paths` resolution if `tsconfigPath` is configured + +#### Position Mapping + +Converts between positions in the TypeScript file and positions within the GraphQL document: + +``` +TypeScript file: + Line 5: const q = gql.default` + Line 6: query GetUser { ← cursor at (6, 8) + Line 7: user { id } + Line 8: } + Line 9: `; + +GraphQL document (extracted): + Line 1: query GetUser { ← mapped to (1, 8) + Line 2: user { id } + Line 3: } +``` + +The mapping accounts for: +- Template literal start offset (after backtick) +- Leading whitespace / indentation +- Multi-line template strings + +### CLI integration + +New CLI command for starting the LSP server: + +```bash +# Start LSP server (typically invoked by editor, not manually) +bun run soda-gql lsp + +# Generate graphql-config for external tools +bun run soda-gql codegen lsp-config +``` + +### Editor integration + +#### VSCode + +A dedicated extension (`soda-gql-vscode`) that: +1. Bundles the LSP server +2. Starts the server when a `soda-gql.config.ts` is detected in the workspace +3. Injects GraphQL syntax highlighting into TypeScript files (via TextMate grammar) + +```jsonc +// Extension activation +{ + "activationEvents": [ + "workspaceContains:**/soda-gql.config.{ts,mts,js,mjs}" + ], + "contributes": { + "grammars": [{ + "scopeName": "inline.graphql", + "path": "./syntaxes/graphql.tmLanguage.json", + "injectTo": ["source.ts", "source.tsx"] + }] + } +} +``` + +#### Neovim + +Configuration via `nvim-lspconfig`: + +```lua +require('lspconfig.configs').soda_gql = { + default_config = { + cmd = { 'soda-gql', 'lsp' }, + filetypes = { 'typescript', 'typescriptreact' }, + root_dir = require('lspconfig.util').root_pattern('soda-gql.config.ts'), + } +} +require('lspconfig').soda_gql.setup{} +``` + +## Implementation Plan + +### Phase 0: Schema resolver and config extension + +- Implement `schema-resolver.ts`: import path → schema name resolution +- Extend config types if needed (e.g., per-schema `importPaths` overrides) +- Add `codegen lsp-config` CLI command to generate `.graphqlrc.generated.json` +- Unit tests for schema resolution with aliases and tsconfig paths + +### Phase 1: LSP server core + +- Set up `@soda-gql/lsp` package +- Implement document manager (TS AST → tagged template extraction) +- Implement position mapping (TS offset ↔ GraphQL offset) +- Wire up LSP handlers using `graphql-language-service` interface functions: + - `textDocument/completion` → `getAutocompleteSuggestions()` + - `textDocument/publishDiagnostics` → `getDiagnostics()` +- Integration tests with sample multi-schema projects + +### Phase 2: VSCode extension + +- Scaffold VSCode extension project +- Bundle LSP server +- Add GraphQL syntax highlighting injection for tagged templates +- Test activation, completion, and diagnostics end-to-end + +### Phase 3: Advanced LSP features + +- `textDocument/hover` → `getHoverInformation()` +- `textDocument/definition` (navigate to schema type definitions) +- Schema file watching and auto-reload +- Fragment cross-file resolution + +### Phase 4: Ecosystem integration + +- Neovim / Sublime integration guides +- `.graphql` file support (in addition to tagged templates) +- Migration guide from `.graphql` files to tagged templates +- graphql-eslint compatibility via generated `.graphqlrc` + +## Backward Compatibility + +This proposal introduces **no breaking changes**: + +- **Existing `gql.{schemaName}(callback)` API**: Fully preserved, no changes required +- **`.graphql` files with graphql-compat**: Continue to work via `codegen graphql` command +- **Existing config**: `soda-gql.config.ts` schema is extended with optional fields only; old configs work without LSP features + +All three styles can coexist in the same project: + +```typescript +// Style 1: Callback composer (existing) +const q1 = gql.default(({ query }) => query.operation({ ... })); + +// Style 2: Tagged template (new) +const q2 = gql.default`query GetUser { user { id } }`; + +// Style 3: .graphql file with compat (existing) +// → auto-generated .compat.ts +``` + +## Alternatives Considered + +### TypeScript Language Service Plugin + +Build as a TS plugin (like `@0no-co/graphqlsp`) instead of a standalone LSP server. + +**Rejected**: TypeScript 7 (tsgo/Corsa) drops Language Service Plugin support. This approach has no future. + +### Enhanced `.graphqlrc.yaml` with path-based routing + +Improve the existing `graphql-config` path-based approach with better DX. + +**Rejected**: Fundamentally implicit — schema association depends on directory structure, not explicit declaration. Moving files silently breaks validation. + +### Comment-based schema directives in `.graphql` files + +```graphql +# @soda-gql schema: admin +query GetAdmin { ... } +``` + +**Rejected**: Comments are not first-class constructs. Easy to forget, no compile-time validation, requires custom tooling anyway. + +### Multiple `graphqlCompat` config entries + +```typescript +graphqlCompat: [ + { input: ["./src/default/**/*.graphql"], schema: "default" }, + { input: ["./src/admin/**/*.graphql"], schema: "admin" }, +] +``` + +**Rejected**: This is the same path-based routing problem as `.graphqlrc.yaml`. Config embeds assumptions about directory structure. + +### Separate file extensions (`.admin.graphql`) + +Encode schema name in file extension. + +**Rejected**: Poor scalability, clutters the file system, requires editor reconfiguration for each new schema. + +## Open Questions + +### Type inference strategy + +How should tagged templates provide TypeScript types? + +- **Option A**: Codegen-generated `TypedDocumentNode` (like graphql-codegen client-preset) +- **Option B**: Build-time type inference via the builder plugin (preserving soda-gql's `$infer` pattern) +- **Option C**: No type inference from tagged templates (users choose callback API for type safety) + +### Fragment cross-file resolution + +When a tagged template references a fragment defined in another file: + +```typescript +// fragments.ts +export const UserFields = gql.default`fragment UserFields on User { id name }`; + +// queries.ts +import { UserFields } from "./fragments"; +const GetUser = gql.default`query GetUser { user { ...UserFields } }`; +``` + +How should the LSP resolve `...UserFields`? +- **Option A**: Follow TypeScript imports to find fragment definitions +- **Option B**: Workspace-wide scan for all fragment definitions +- **Option C**: Hybrid (follow imports first, fall back to workspace scan) + +### Schema reload strategy + +When schema files change during development: +- **Option A**: File watcher with auto-reload +- **Option B**: Manual reload via editor command +- **Option C**: Both (auto-reload with manual override) + +## References + +### soda-gql internals +- Config types: `packages/config/src/types.ts` +- GQL composer: `packages/core/src/composer/gql-composer.ts` +- GraphQL compat emitter: `packages/codegen/src/graphql-compat/emitter.ts` +- Builder flow: `docs/guides/builder-flow.md` + +### GraphQL ecosystem +- [graphql-language-service-server](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service-server) +- [graphql-language-service (interface)](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service) +- [graphql-tag-pluck](https://the-guild.dev/graphql/tools/docs/graphql-tag-pluck) +- [@0no-co/GraphQLSP](https://github.com/0no-co/GraphQLSP) +- [gql.tada multi-schema mode](https://gql-tada.0no.co/devlog/2024-04-26) + +### LSP protocol +- [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/) +- [vscode-languageserver-node](https://github.com/microsoft/vscode-languageserver-node) + +### TypeScript future +- [typescript-go](https://github.com/microsoft/typescript-go) +- [Transformer Plugin issue #516](https://github.com/microsoft/typescript-go/issues/516) (removed) +- [Progress on TypeScript 7 - December 2025](https://devblogs.microsoft.com/typescript/progress-on-typescript-7-december-2025/) From af03ee0419878b1ae65dec4432168b9a4f5ecc14 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sat, 31 Jan 2026 21:19:03 +0900 Subject: [PATCH 2/4] docs(rfc): add LSP implementation details report Supplementary document covering: - LSP protocol requirements by tier (core/navigation/productivity/advanced) - graphql-language-service interface layer API analysis with function signatures - Components that must be built from scratch (template extractor, position mapping, schema management, cross-file fragments, handler wiring) - soda-gql-specific extension opportunities (inlay hints, code lens, custom validation rules, semantic tokens, multi-schema context indicator, compat migration actions, build integration commands) - Risk analysis and dependency overview Co-Authored-By: Claude Opus 4.5 --- .../graphql-lsp-implementation-details.md | 585 ++++++++++++++++++ 1 file changed, 585 insertions(+) create mode 100644 docs/rfcs/graphql-lsp-implementation-details.md diff --git a/docs/rfcs/graphql-lsp-implementation-details.md b/docs/rfcs/graphql-lsp-implementation-details.md new file mode 100644 index 00000000..8118bf37 --- /dev/null +++ b/docs/rfcs/graphql-lsp-implementation-details.md @@ -0,0 +1,585 @@ +# GraphQL LSP Implementation Details Report + +## Purpose + +This document supplements the [GraphQL LSP Multi-Schema RFC](./graphql-lsp-multi-schema.md) with deeper technical analysis of what building an LSP from scratch entails, what existing libraries provide, and what opportunities exist for soda-gql-specific extensions beyond standard GraphQL LSP features. + +## Table of Contents + +- [1. LSP Protocol Requirements](#1-lsp-protocol-requirements) +- [2. graphql-language-service Interface Layer Analysis](#2-graphql-language-service-interface-layer-analysis) +- [3. What Must Be Built from Scratch](#3-what-must-be-built-from-scratch) +- [4. soda-gql-Specific Extension Opportunities](#4-soda-gql-specific-extension-opportunities) +- [5. Risk Analysis](#5-risk-analysis) + +--- + +## 1. LSP Protocol Requirements + +### 1.1 Server lifecycle + +Every LSP server must implement the following lifecycle: + +| Phase | Request/Notification | Description | +|-------|---------------------|-------------| +| Handshake | `initialize` | Client sends capabilities, server responds with its own | +| Ready | `initialized` | Client confirms initialization | +| Running | (various) | Handle requests and notifications | +| Shutdown | `shutdown` | Client requests graceful shutdown | +| Exit | `exit` | Client notifies server to terminate | + +The `initialize` response declares which capabilities the server supports. This is where the server selectively opts into features: + +```typescript +connection.onInitialize((params: InitializeParams): InitializeResult => { + return { + capabilities: { + textDocumentSync: TextDocumentSyncKind.Incremental, + completionProvider: { triggerCharacters: ['.', '{', '(', ':', '@', '$', '\n', ' '] }, + hoverProvider: true, + definitionProvider: true, + referencesProvider: true, + documentSymbolProvider: true, + codeActionProvider: true, + // Advanced (see section 4) + codeLensProvider: { resolveProvider: true }, + inlayHintProvider: true, + semanticTokensProvider: { /* ... */ }, + }, + }; +}); +``` + +### 1.2 Document synchronization (mandatory) + +The server must track document state. Three notifications are required: + +| Notification | Trigger | Purpose | +|-------------|---------|---------| +| `textDocument/didOpen` | File opened in editor | Register document, run initial diagnostics | +| `textDocument/didChange` | User edits file | Update cached content, re-parse tagged templates, re-run diagnostics | +| `textDocument/didClose` | File closed | Clean up cached state | + +`vscode-languageserver` provides a `TextDocuments` manager that handles this automatically: + +```typescript +import { TextDocuments } from 'vscode-languageserver/node'; +import { TextDocument } from 'vscode-languageserver-textdocument'; + +const documents = new TextDocuments(TextDocument); +documents.listen(connection); +``` + +### 1.3 Feature categories by priority + +**Tier 1 — Core (MVP)** + +| LSP Method | Purpose | Provided by graphql-language-service? | +|-----------|---------|---------------------------------------| +| `textDocument/completion` | Field, argument, type, directive autocomplete | ✅ `getAutocompleteSuggestions()` | +| `textDocument/publishDiagnostics` | Syntax errors, validation errors, deprecation warnings | ✅ `getDiagnostics()` | +| `textDocument/hover` | Type information, field descriptions, deprecation notices | ✅ `getHoverInformation()` | + +**Tier 2 — Navigation** + +| LSP Method | Purpose | Provided by graphql-language-service? | +|-----------|---------|---------------------------------------| +| `textDocument/definition` | Jump to type definition in schema, fragment definition | ✅ `getDefinitionQueryResultFor*()` (multiple variants) | +| `textDocument/references` | Find all usages of a fragment or type | ❌ Custom implementation needed | +| `textDocument/documentSymbol` | Outline view (operations, fragments) | ✅ `getOutline()` | + +**Tier 3 — Productivity** + +| LSP Method | Purpose | Provided by graphql-language-service? | +|-----------|---------|---------------------------------------| +| `textDocument/codeAction` | Quick fixes, extract fragment | ❌ Custom implementation needed | +| `textDocument/rename` | Rename fragment, rename variable | ❌ Custom implementation needed | +| `textDocument/signatureHelp` | Show argument signatures for fields | ❌ Custom implementation needed | +| `textDocument/formatting` | Format GraphQL content in tagged templates | ❌ Custom (could delegate to prettier) | +| `completionItem/resolve` | Lazy-load detailed completion info | Partial (data already available) | + +**Tier 4 — Advanced (soda-gql extensions)** + +| LSP Method | Purpose | Standard GraphQL LSP? | +|-----------|---------|----------------------| +| `textDocument/codeLens` | Show operation metadata, schema info | ❌ Not in existing GraphQL LSPs | +| `textDocument/inlayHint` | Inline type annotations, argument names | ❌ Not in existing GraphQL LSPs | +| `textDocument/semanticTokens` | Enhanced syntax highlighting | ❌ Not in existing GraphQL LSPs | +| Custom notifications | Schema reload, build status, multi-schema context | N/A (non-standard) | + +--- + +## 2. graphql-language-service Interface Layer Analysis + +### 2.1 Package overview + +The `graphql-language-service` package (v5.5.0) is the official, runtime-independent interface layer. Previously split into `graphql-language-service-interface`, `-parser`, `-utils`, and `-types`, now consolidated into a single package. + +**Key characteristic**: All functions are **stateless**. They take a `GraphQLSchema`, query text, and position as input and return results. No server lifecycle, no file management, no config loading. This makes them ideal for embedding in a custom LSP server. + +### 2.2 Core function signatures + +#### `getAutocompleteSuggestions` + +```typescript +function getAutocompleteSuggestions( + schema: GraphQLSchema, + queryText: string, + cursor: IPosition, // { line: number, character: number } + contextToken?: ContextTokenForCodeMirror, + fragmentDefs?: FragmentDefinitionNode[] | string, + options?: AutocompleteSuggestionOptions, +): CompletionItem[]; +``` + +- `schema`: The GraphQL schema to suggest against. **For soda-gql: must be the correct schema for the tagged template's import path.** +- `queryText`: The raw GraphQL string extracted from the tagged template. +- `cursor`: Position within the GraphQL string (not the TS file — offset mapping required). +- `fragmentDefs`: External fragment definitions that can be referenced. **For soda-gql: fragments from other files need to be collected.** +- Returns `CompletionItem[]` with `label`, `kind`, `documentation`, `insertText`, `detail`. + +#### `getDiagnostics` + +```typescript +function getDiagnostics( + query: string, + schema?: GraphQLSchema | null, + customRules?: ValidationRule[], + isRelayCompatMode?: boolean, + externalFragments?: FragmentDefinitionNode[] | string, +): Diagnostic[]; +``` + +- Works without a schema (syntax-only validation), but full validation requires one. +- `customRules`: Allows injecting soda-gql-specific validation rules (see section 4). +- `externalFragments`: Same as autocomplete — fragments from other files. + +#### `getHoverInformation` + +```typescript +function getHoverInformation( + schema: GraphQLSchema, + queryText: string, + cursor: IPosition, + contextToken?: ContextToken, + config?: GraphQLConfig, +): Hover['contents']; +``` + +- Returns markdown-formatted hover content. +- Shows type name, field description, deprecation reason. + +#### `getOutline` + +```typescript +function getOutline(queryText: string): OutlineTree; +``` + +- Pure parsing, no schema needed. +- Returns a tree structure usable for `textDocument/documentSymbol`. + +#### Definition helpers + +```typescript +function getDefinitionQueryResultForField( + fieldName: string, typeName: string, sourceDocument: DocumentNode, filePath: string +): DefinitionQueryResult; + +function getDefinitionQueryResultForFragmentSpread( + fragmentName: string, sourceDocument: DocumentNode, filePath: string +): DefinitionQueryResult; + +function getDefinitionQueryResultForNamedType( + typeName: string, sourceDocument: DocumentNode, filePath: string +): DefinitionQueryResult; +``` + +- These return position ranges within GraphQL documents. +- For soda-gql, schema-level definitions would need the schema SDL file path. + +#### Utility functions + +```typescript +// Position conversion +function offsetToPosition(text: string, offset: number): IPosition; +function pointToOffset(text: string, point: IPosition): number; + +// AST helpers +function getASTNodeAtPosition(query: string, ast: DocumentNode, point: IPosition): ASTNode | undefined; +function getContextAtPosition(query: string, schema: GraphQLSchema, point: IPosition): { token, state, typeInfo, mode }; +function getTypeInfo(schema: GraphQLSchema, tokenState: State): TypeInfo; + +// Fragment analysis +function getFragmentDependencies(query: string, fragmentDefinitions?: Map): FragmentDefinitionNode[]; + +// Variable schema +function getVariablesJSONSchema(variableToType: Record): JSONSchema6; +``` + +### 2.3 What the interface layer does NOT provide + +| Concern | Status | What you must build | +|---------|--------|---------------------| +| LSP protocol handling | ❌ | Server setup, connection, message routing (`vscode-languageserver`) | +| Document synchronization | ❌ | Track open files, incremental updates (`TextDocuments` manager) | +| File parsing | ❌ | Extract GraphQL from tagged templates in TypeScript files | +| Position mapping | ❌ | Convert TS file positions to GraphQL document positions and back | +| Config loading | ❌ | Read `soda-gql.config.ts`, resolve schemas | +| Schema loading | ❌ | Load `.graphql` files, build `GraphQLSchema` objects | +| Schema caching | ❌ | Cache schemas, invalidate on file change | +| Multi-schema routing | ❌ | Resolve which schema applies to each tagged template | +| Cross-file fragments | ❌ | Collect fragment definitions from other files for completion/validation | +| File watching | ❌ | Watch schema files and config for changes | +| `textDocument/references` | ❌ | Find all usages of a fragment/type across workspace | +| `textDocument/codeAction` | ❌ | Quick fixes, refactorings | +| `textDocument/rename` | ❌ | Symbol renaming | +| `textDocument/formatting` | ❌ | Format GraphQL in templates | + +--- + +## 3. What Must Be Built from Scratch + +### 3.1 Tagged template extractor + +The most critical custom component. It must: + +1. **Parse TypeScript AST** to find tagged template expressions +2. **Identify the tag**: Match against known patterns (`gql.{schemaName}`, `graphql`, etc.) +3. **Trace imports**: Follow the tag identifier back to its import declaration +4. **Extract content**: Get the raw GraphQL string from the template +5. **Compute offset map**: Map between TS file positions and GraphQL content positions + +Complexity factors: +- Template literals can span multiple lines with varying indentation +- The GraphQL content starts after the opening backtick, possibly with leading whitespace +- Must handle re-exports and aliased imports (`import { graphql as gql } from ...`) +- Must handle TypeScript path aliases (`@/graphql-system/...` → actual file path) + +**Reuse opportunity**: soda-gql's `@soda-gql/builder` already has a TypeScript AST analyzer (`packages/builder/src/discovery/`) that finds `gql.{schemaName}()` calls. The tagged template extractor can follow the same pattern. + +### 3.2 Position mapping + +When the user's cursor is at line 8, column 12 in a TypeScript file, and the tagged template starts at line 5, column 28, the LSP needs to translate this to the corresponding position within the GraphQL string. + +``` +TS file position (8, 12) → GraphQL position (3, 12) +``` + +This mapping must be bidirectional: +- **TS → GraphQL**: For forwarding editor requests (completion, hover) to `graphql-language-service` +- **GraphQL → TS**: For converting diagnostics and definition locations back to the editor + +Edge cases: +- Indentation stripping (common in tagged templates) +- The backtick character itself (not part of GraphQL content) +- Escaped characters in template strings (rare but possible) + +### 3.3 Schema management + +```typescript +type SchemaCache = Map; +``` + +Requirements: +- Load schemas from paths specified in `soda-gql.config.ts` +- Build `GraphQLSchema` objects using `graphql-js`'s `buildSchema()` or `buildASTSchema()` +- Cache schemas and invalidate when source files change +- Support multiple schemas simultaneously (one per config entry) + +**Reuse opportunity**: `@soda-gql/codegen` already has `loadSchema()` (`packages/codegen/src/schema.ts`) and `hashSchema()` functions. + +### 3.4 Cross-file fragment resolution + +When a tagged template references a fragment defined in another file: + +```typescript +// user-fields.ts +export const UserFields = gql.default` + fragment UserFields on User { id name email } +`; + +// get-user.ts +import { UserFields } from './user-fields'; +const GetUser = gql.default` + query GetUser($id: ID!) { + user(id: $id) { ...UserFields } + } +`; +``` + +The LSP must: +1. Detect `...UserFields` in the query +2. Resolve the TypeScript import `'./user-fields'` +3. Find the `UserFields` tagged template in that file +4. Extract the fragment's `DocumentNode` +5. Pass it to `getAutocompleteSuggestions()` and `getDiagnostics()` as `externalFragments` + +This requires a **workspace-level index** of all fragment definitions and their locations. + +### 3.5 LSP handler wiring + +Each LSP method requires a handler that: +1. Receives the LSP request (with TS file URI and position) +2. Finds the tagged template at that position +3. Maps the position to GraphQL coordinates +4. Calls the appropriate `graphql-language-service` function +5. Maps the result back to TS file coordinates +6. Returns the LSP response + +Example for completion: + +```typescript +connection.onCompletion((params: CompletionParams): CompletionItem[] => { + const doc = documents.get(params.textDocument.uri); + if (!doc) return []; + + const state = documentManager.getState(doc.uri); + const template = state.findTemplateAtPosition(params.position); + if (!template) return []; + + const graphqlPosition = positionMapper.tsToGraphql( + params.position, + template.range, + ); + + const schema = schemaResolver.getSchema(template.schemaName); + if (!schema) return []; + + const fragments = fragmentIndex.getFragmentsForSchema(template.schemaName); + + return getAutocompleteSuggestions( + schema, + template.content, + graphqlPosition, + undefined, + fragments, + ); +}); +``` + +--- + +## 4. soda-gql-Specific Extension Opportunities + +This section describes features that go beyond what any existing GraphQL LSP provides. Building a custom LSP opens the door to these soda-gql-specific enhancements. + +### 4.1 Inlay hints for inferred types + +LSP 3.17 introduced `textDocument/inlayHint`. No existing GraphQL LSP implements this. + +soda-gql could show inferred return types and variable types inline: + +```typescript +const GetUser = gql.default` + query GetUser($id: ID!) { + user(id: $id) { // ← inlay: ": User" + id // ← inlay: ": ID!" + name // ← inlay: ": String!" + posts(first: 10) { // ← inlay: ": [Post!]!" + title // ← inlay: ": String!" + } + } + } +`; +``` + +This eliminates the need to hover each field individually — the type information is always visible. + +**Implementation**: Use `getTypeInfo()` from `graphql-language-service` to resolve types at each field position, then emit `InlayHint[]` with type annotations. + +### 4.2 Code lens for operation metadata + +`textDocument/codeLens` can show actionable information above operations: + +```typescript +// ▸ Schema: admin | Fields: 12 | Depth: 3 | Est. complexity: 24 +const GetAdmin = gql.admin` + query GetAdmin { ... } +`; +``` + +Possible code lens items: +- **Schema name**: Which schema this operation targets (critical for multi-schema projects) +- **Field count**: Total number of selected fields +- **Query depth**: Nesting depth of the selection set +- **Estimated complexity**: Based on schema-defined complexity weights +- **Run query**: Open in GraphiQL or execute against a dev server (command-based) + +**Implementation**: Parse the operation, walk the selection set, compute metrics, return `CodeLens[]` with custom commands. + +### 4.3 Custom diagnostics: soda-gql validation rules + +`getDiagnostics()` accepts `customRules: ValidationRule[]`. soda-gql can inject its own rules: + +| Rule | Description | Severity | +|------|-------------|----------| +| `NoExcludedTypes` | Warn when querying types excluded by `typeFilter` in config | Warning | +| `InputDepthLimit` | Warn when input nesting exceeds `defaultInputDepth` or overrides | Warning | +| `FragmentSchemaMatch` | Error when spreading a fragment from a different schema | Error | +| `OperationNaming` | Enforce naming conventions for operations | Info | +| `DeprecatedFieldUsage` | Enhanced deprecation warnings with migration hints | Warning | +| `UnusedFragment` | Warn about fragments defined but not spread anywhere | Warning | + +Example implementation: + +```typescript +import { ValidationContext, ASTVisitor } from 'graphql'; + +const NoExcludedTypesRule = ( + excludedTypes: Set, +) => (context: ValidationContext): ASTVisitor => ({ + NamedType(node) { + if (excludedTypes.has(node.name.value)) { + context.reportError( + new GraphQLError( + `Type "${node.name.value}" is excluded by typeFilter in soda-gql config.`, + { nodes: [node] }, + ), + ); + } + }, +}); +``` + +### 4.4 Schema-aware semantic tokens + +`textDocument/semanticTokens` provides enhanced syntax highlighting beyond TextMate grammars. + +soda-gql could provide schema-aware token types: + +| Token | Semantic Type | Modifier | +|-------|--------------|----------| +| Query field name | `property` | `declaration` | +| Deprecated field | `property` | `deprecated` | +| Scalar type | `type` | — | +| Enum value | `enumMember` | — | +| Fragment name | `function` | `declaration` | +| Variable | `variable` | — | +| Directive | `decorator` | — | + +This would enable visual differentiation between, e.g., deprecated and non-deprecated fields directly in the editor's color scheme, without requiring a hover. + +**Implementation**: Walk the GraphQL AST, resolve types via schema, emit semantic token ranges mapped back to TS file positions. + +### 4.5 Multi-schema context indicator + +A custom LSP notification to inform the editor which schema is active for the current cursor position: + +```typescript +// Custom notification: soda-gql/activeSchema +connection.sendNotification('soda-gql/activeSchema', { + uri: document.uri, + schemaName: 'admin', + schemaPath: './schemas/admin/schema.graphql', +}); +``` + +The VSCode extension could display this in the status bar: + +``` +[GraphQL: admin] ← shown in status bar when cursor is inside a gql.admin tagged template +``` + +This is particularly valuable in multi-schema projects where different parts of the same file may target different schemas. + +### 4.6 Automatic compat migration + +A code action that converts `.graphql` file content to a tagged template: + +``` +// Before (in .graphql file): +query GetUser($id: ID!) { + user(id: $id) { id name } +} + +// Code action: "Convert to soda-gql tagged template" + +// After (in .ts file): +import { gql } from "@/graphql-system"; + +export const GetUserCompat = gql.default` + query GetUser($id: ID!) { + user(id: $id) { id name } + } +`; +``` + +This leverages the existing graphql-compat parser (`packages/codegen/src/graphql-compat/parser.ts`) but runs it interactively via LSP code actions instead of CLI codegen. + +### 4.7 `workspace/executeCommand` for build integration + +Custom commands executable via the LSP: + +| Command | Description | +|---------|-------------| +| `soda-gql.reloadSchemas` | Force reload all schemas from disk | +| `soda-gql.generateLspConfig` | Generate `.graphqlrc.generated.json` | +| `soda-gql.showSchemaInfo` | Display schema statistics in a panel | +| `soda-gql.switchSchema` | Change the default schema for new templates | +| `soda-gql.validateWorkspace` | Run full workspace validation | + +These integrate with the existing `@soda-gql/cli` and `@soda-gql/sdk` packages. + +### 4.8 Variables JSON schema generation + +`graphql-language-service` exports `getVariablesJSONSchema()` which generates a JSON Schema from operation variables. soda-gql could expose this to provide: + +- **Variable completion**: When typing variable values in tests or playground +- **Variable validation**: Ensure variable values match expected types +- **Documentation generation**: Auto-generate variable documentation for operations + +--- + +## 5. Risk Analysis + +### 5.1 Complexity risks + +| Risk | Likelihood | Impact | Mitigation | +|------|-----------|--------|------------| +| Position mapping bugs (off-by-one, indentation) | High | Medium | Extensive unit tests with edge cases; snapshot testing | +| Cross-file fragment resolution performance | Medium | High | Lazy indexing; file-level caching; incremental updates | +| TypeScript parser maintenance burden | Medium | Medium | Use `@swc/core` for speed; keep parser minimal (tagged template extraction only) | +| `graphql-language-service` API changes | Low | Medium | Pin version; review changelogs on update | +| Large schema performance (1000+ types) | Medium | High | Lazy schema loading; index only reachable types (reuse `reachability.ts`) | + +### 5.2 Scope risks + +The biggest risk is scope creep. Tier 4 features (section 4) are exciting but should not delay the MVP. Recommended approach: + +1. **MVP**: Tier 1 (completion, diagnostics, hover) + basic multi-schema routing +2. **V1**: Add Tier 2 (definition, document symbols, references) +3. **V2**: Add Tier 3 (code actions, rename, formatting) + select Tier 4 features +4. **V3+**: Full Tier 4 (inlay hints, code lens, semantic tokens, custom commands) + +### 5.3 Dependencies + +For the hybrid approach, the dependency chain is: + +``` +@soda-gql/lsp +├── vscode-languageserver (~200KB) +├── vscode-languageserver-textdocument (~15KB) +├── graphql-language-service (~150KB, depends on graphql) +├── graphql (~800KB, already a project dependency) +├── @soda-gql/config (internal) +└── typescript or @swc/core (for TS AST parsing) +``` + +Total new dependencies: `vscode-languageserver`, `vscode-languageserver-textdocument`, `graphql-language-service`. The `graphql` package is already used by `@soda-gql/codegen`. + +--- + +## References + +- [LSP Specification 3.17](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/) +- [vscode-languageserver-node](https://github.com/microsoft/vscode-languageserver-node) +- [VSCode Language Server Extension Guide](https://code.visualstudio.com/api/language-extensions/language-server-extension-guide) +- [graphql-language-service TypeDoc](https://graphiql-test.netlify.app/typedoc/modules/graphql_language_service) +- [graphql-language-service-server README](https://github.com/graphql/graphiql/blob/main/packages/graphql-language-service-server/README.md) +- [graphql-language-service npm](https://www.npmjs.com/package/graphql-language-service) +- [@0no-co/GraphQLSP](https://github.com/0no-co/GraphQLSP) From fd0ea8b477fa94aa1d995177543c20ec8af9f322 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sat, 31 Jan 2026 22:01:39 +0900 Subject: [PATCH 3/4] docs(rfc): finalize direction as Pattern C + Hybrid LSP Commit to Pattern C (gql.{schemaName} tagged template extension) and Hybrid LSP architecture (graphql-language-service interface + custom server). Move rejected alternatives (Patterns A/B, server wrap, full scratch) to Alternatives Considered with rationale. Co-Authored-By: Claude Opus 4.5 --- .../graphql-lsp-implementation-details.md | 10 +- docs/rfcs/graphql-lsp-multi-schema.md | 312 ++++++++---------- 2 files changed, 145 insertions(+), 177 deletions(-) diff --git a/docs/rfcs/graphql-lsp-implementation-details.md b/docs/rfcs/graphql-lsp-implementation-details.md index 8118bf37..8e773703 100644 --- a/docs/rfcs/graphql-lsp-implementation-details.md +++ b/docs/rfcs/graphql-lsp-implementation-details.md @@ -2,7 +2,7 @@ ## Purpose -This document supplements the [GraphQL LSP Multi-Schema RFC](./graphql-lsp-multi-schema.md) with deeper technical analysis of what building an LSP from scratch entails, what existing libraries provide, and what opportunities exist for soda-gql-specific extensions beyond standard GraphQL LSP features. +This document supplements the [GraphQL LSP Multi-Schema RFC](./graphql-lsp-multi-schema.md) with deeper technical analysis of the hybrid LSP architecture. It covers what the `graphql-language-service` interface layer provides, what must be built from scratch for the `gql.{schemaName}` tagged template pattern (Pattern C), and what opportunities exist for soda-gql-specific extensions beyond standard GraphQL LSP features. ## Table of Contents @@ -244,8 +244,8 @@ function getVariablesJSONSchema(variableToType: Record The most critical custom component. It must: 1. **Parse TypeScript AST** to find tagged template expressions -2. **Identify the tag**: Match against known patterns (`gql.{schemaName}`, `graphql`, etc.) -3. **Trace imports**: Follow the tag identifier back to its import declaration +2. **Identify the tag**: Match `gql.{schemaName}` member expression patterns (the chosen Pattern C) +3. **Trace imports**: Follow the `gql` identifier back to its import declaration, validate it resolves to the graphql-system output 4. **Extract content**: Get the raw GraphQL string from the template 5. **Compute offset map**: Map between TS file positions and GraphQL content positions @@ -255,7 +255,7 @@ Complexity factors: - Must handle re-exports and aliased imports (`import { graphql as gql } from ...`) - Must handle TypeScript path aliases (`@/graphql-system/...` → actual file path) -**Reuse opportunity**: soda-gql's `@soda-gql/builder` already has a TypeScript AST analyzer (`packages/builder/src/discovery/`) that finds `gql.{schemaName}()` calls. The tagged template extractor can follow the same pattern. +**Reuse opportunity**: soda-gql's `@soda-gql/builder` already has a TypeScript AST analyzer (`packages/builder/src/discovery/`) that finds `gql.{schemaName}()` calls. The tagged template extractor extends this to also recognize `` gql.{schemaName}`...` `` tagged template expressions — the same member expression pattern, different call syntax. ### 3.2 Position mapping @@ -497,7 +497,7 @@ query GetUser($id: ID!) { user(id: $id) { id name } } -// Code action: "Convert to soda-gql tagged template" +// Code action: "Convert to soda-gql tagged template (Pattern C)" // After (in .ts file): import { gql } from "@/graphql-system"; diff --git a/docs/rfcs/graphql-lsp-multi-schema.md b/docs/rfcs/graphql-lsp-multi-schema.md index 0601f2bc..ca7347d0 100644 --- a/docs/rfcs/graphql-lsp-multi-schema.md +++ b/docs/rfcs/graphql-lsp-multi-schema.md @@ -2,11 +2,16 @@ ## Status -**Draft** - Design in progress +**Draft** - Direction finalized ## Summary -This RFC proposes building an independent GraphQL Language Server Protocol (LSP) implementation for soda-gql that provides IDE features (autocomplete, diagnostics, hover) for GraphQL operations written as tagged template literals in TypeScript files. Unlike traditional `.graphqlrc.yaml` path-based schema routing, this approach uses explicit schema association through import paths or tag names, solving multi-schema conflicts while maintaining soda-gql's zero-runtime philosophy. +This RFC defines the design for an independent GraphQL Language Server Protocol (LSP) implementation for soda-gql. The LSP provides IDE features (autocomplete, diagnostics, hover, go-to-definition) for GraphQL operations written as tagged template literals in TypeScript files. + +The design commits to two key decisions: + +1. **Tagged Template API (Pattern C)**: Extend the existing `gql.{schemaName}` interface with tagged template support, so that `` gql.default`query { ... }` `` coexists naturally alongside `gql.default(callback)`. +2. **Hybrid LSP Architecture**: Build a custom LSP server using `vscode-languageserver` for protocol handling and `graphql-language-service` (interface layer only) for completion/diagnostics/hover algorithms, while managing config, file parsing, and multi-schema routing independently. ## Motivation @@ -83,61 +88,12 @@ Two main approaches exist for providing GraphQL IDE features: ### Why tagged template literals -Tagged template literals provide a natural syntax for embedding GraphQL in TypeScript: - -```typescript -import { graphql } from "@/graphql-system/default"; - -const GetUser = graphql` - query GetUser($id: ID!) { - user(id: $id) { - id - name - email - } - } -`; -``` - -Advantages: -- **Import path encodes the schema**: `@/graphql-system/default` vs `@/graphql-system/admin` -- **Industry-standard pattern**: Used by Apollo Client, urql, Relay, gql.tada -- **Parser-friendly**: LSP can extract GraphQL content from tagged templates via AST analysis -- **Syntax highlighting**: Editor extensions (e.g., vscode-graphql-syntax) already support GraphQL in tagged templates via TextMate grammar injection - -## Design Decisions - -### 4.1 Tagged Template API - -The tagged template API coexists with the existing `gql.{schemaName}(callback)` composer API. Both styles produce the same build-time artifacts and runtime behavior. - -#### API pattern candidates - -**Pattern A: Schema-specific subpath imports** - -```typescript -import { graphql } from "@/graphql-system/default"; -import { graphql as adminGraphql } from "@/graphql-system/admin"; - -const GetUser = graphql`query GetUser($id: ID!) { user(id: $id) { id name } }`; -const GetAdmin = adminGraphql`query GetAdmin { admins { id } }`; -``` - -**Pattern B: Single import with member access** - -```typescript -import { graphql } from "@/graphql-system"; - -const GetUser = graphql.default`query GetUser($id: ID!) { user(id: $id) { id name } }`; -const GetAdmin = graphql.admin`query GetAdmin { admins { id } }`; -``` - -**Pattern C: Extend existing `gql` with tagged template support** +Tagged template literals provide a natural syntax for embedding GraphQL in TypeScript. By extending the existing `gql` interface, tagged templates integrate seamlessly with the current composer API: ```typescript import { gql } from "@/graphql-system"; -// Existing: callback style (unchanged) +// Existing: callback composer (unchanged) const GetUser1 = gql.default(({ query, $var }) => query.operation({ name: "GetUser", @@ -159,35 +115,64 @@ const GetUser2 = gql.default` `; ``` -#### Comparison +Advantages: +- **Tag name encodes the schema**: `gql.default` vs `gql.admin` — explicit and unambiguous +- **Unified import**: Both callback and tagged template styles use the same `gql` import +- **Industry-standard pattern**: Tagged template literals for GraphQL are used by Apollo Client, urql, Relay, gql.tada +- **Parser-friendly**: LSP can extract GraphQL content from tagged templates via AST analysis +- **Syntax highlighting**: Editor extensions (e.g., vscode-graphql-syntax) already support GraphQL in tagged templates via TextMate grammar injection -| Aspect | Pattern A (subpath) | Pattern B (member) | Pattern C (gql extension) | -|--------|---------------------|--------------------|---------------------------| -| Tag recognition by `graphql-tag-pluck` | ✅ Supported via `modules` config (path + identifier) | ❌ `graphql.default` not supported. Custom parser required | ❌ `gql.default` not supported. Custom parser required | -| External tool compatibility | High (`graphql` is a standard identifier) | Low | Low | -| Integration with existing API | Clear separation (different import paths) | Two usage patterns from same import | Most natural, but ambiguity between `gql.default(callback)` and `` gql.default`tag` `` | -| Multi-schema explicitness | Explicit via import statement | Explicit via member name | Explicit via member name | +## Design Decisions + +### 4.1 Tagged Template API (Pattern C) + +The tagged template API extends the existing `gql.{schemaName}` interface. The same `gql.{schemaName}` expression works as both a function call (existing callback API) and a tagged template literal (new). Both styles produce the same build-time artifacts and runtime behavior. -#### Decision criteria +#### API design + +```typescript +import { gql } from "@/graphql-system"; -- **If relying on external tools** (graphql-eslint, existing graphql-config ecosystem): **Pattern A** is the safest choice, as `graphql` is a universally recognized tag identifier. -- **If building a custom LSP** (which this RFC proposes): **Pattern C** provides the best DX by unifying the import and allowing both callback and tagged template styles through the same `gql.{schemaName}` interface. The custom LSP can recognize `gql.{schemaName}` as a tagged template tag regardless of external tool support. +// Callback style (existing, unchanged) +const GetUser1 = gql.default(({ query }) => query.operation({ ... })); -The RFC presents both options. The final choice depends on how much weight is given to external tool compatibility vs. internal API cohesion. +// Tagged template style (new) +const GetUser2 = gql.default` + query GetUser($id: ID!) { + user(id: $id) { id name } + } +`; + +// Multi-schema: same pattern, different schema name +const GetAdmin = gql.admin` + query GetAdmin { + admins { id role } + } +`; +``` + +This design was chosen because: +- **Natural coexistence**: `gql.default(callback)` and `` gql.default`tag` `` share the same import and schema namespace. Developers choose the style that fits each use case. +- **No import proliferation**: Unlike subpath imports (`import { graphql } from "@/graphql-system/default"`), Pattern C uses a single import for all schemas. +- **Custom LSP eliminates external tool constraints**: Since this RFC proposes building a custom LSP, there is no need for `graphql-tag-pluck` compatibility. The LSP's parser recognizes `gql.{schemaName}` as a tagged template tag directly. #### Runtime behavior ```typescript // Generated by codegen (in graphql-system output) +// The gql.{schemaName} object acts as both a function and a tagged template tag: +// gql.default(callback) → existing callback API +// gql.default`query { ... }` → new tagged template API +// // Tagged template function signature: -export function graphql( +export function taggedTemplate( strings: TemplateStringsArray, ...values: never[] // Interpolation is prohibited ): TypedDocumentNode; ``` Key constraints: -- **No interpolation**: `` graphql`...${expr}...` `` is a type error (`never[]` enforces this). Interpolation would break static analysis and LSP autocomplete. +- **No interpolation**: `` gql.default`...${expr}...` `` is a type error (`never[]` enforces this). Interpolation would break static analysis and LSP autocomplete. - **Build-time extraction**: The builder plugin detects tagged templates via AST analysis and transforms them into the same artifact format as `gql.{schemaName}(callback)` calls. - **Runtime transformation**: At build time, tagged templates are replaced with `createRuntimeOperation` / `createRuntimeFragment` calls (same codepath as existing API). @@ -197,7 +182,7 @@ Two approaches for type-safe results from tagged templates: **Approach 1: Codegen-generated TypedDocumentNode** (graphql-codegen pattern) ```typescript -const GetUser = graphql`query GetUser($id: ID!) { user(id: $id) { id name } }`; +const GetUser = gql.default`query GetUser($id: ID!) { user(id: $id) { id name } }`; // Type: TypedDocumentNode // Types generated by a separate codegen step ``` @@ -214,79 +199,27 @@ This decision is deferred to implementation phase. See [Open Questions](#open-qu ### 4.2 Schema association mechanism -Schema association is resolved through import paths: +Schema association is resolved through the tag name (`gql.{schemaName}`) and its import path: -1. **Codegen generates per-schema subpaths**: `graphql-system/default/`, `graphql-system/admin/` -2. **LSP resolves imports to schema names**: When the LSP encounters a tagged template, it traces the tag function's import path back to a schema subpath +1. **Codegen generates per-schema entries**: The `gql` object exposes schema-specific members (`gql.default`, `gql.admin`) +2. **LSP resolves tag to schema name**: When the LSP encounters `` gql.{name}`...` ``, it extracts `{name}` as the schema identifier 3. **Config provides the mapping**: The existing `schemas: Record` in `soda-gql.config.ts` defines available schemas. The existing `graphqlSystemAliases` config (e.g., `["@/graphql-system"]`) provides alias resolution. Resolution algorithm: ``` 1. Find tagged template at cursor position -2. Trace tag identifier to its import declaration -3. Resolve import path (handle tsconfig paths aliases) -4. Match against config: - - outdir + "/" + schemaName (e.g., "./graphql-system/default") - - graphqlSystemAliases[i] + "/" + schemaName (e.g., "@/graphql-system/default") -5. Return matched schemaName, or report diagnostic if unresolved +2. Extract schema name from tag expression (e.g., gql.default → "default", gql.admin → "admin") +3. Validate that the tag's import path resolves to the graphql-system output: + - outdir (e.g., "./graphql-system") + - graphqlSystemAliases[i] (e.g., "@/graphql-system") + - Handle tsconfig paths aliases if tsconfigPath is configured +4. Match schema name against config schemas: Record +5. Return matched schema, or report diagnostic if unresolved ``` -### 4.3 LSP base: build vs. buy analysis - -Three approaches were evaluated for the LSP server implementation: +### 4.3 LSP architecture: Hybrid approach -#### Option 1: Wrap graphql-language-service-server - -``` -soda-gql LSP Server -├── startServer() from graphql-language-service-server -├── Custom parseDocument (tagged template extraction) -├── graphql-config integration (auto-generated .graphqlrc) -└── Multi-schema resolver (soda-gql.config.ts → project mapping) -``` - -**Extension points available**: -- `parseDocument`: Injectable custom parser for extracting GraphQL from `.ts` files -- `startServer({ method, loadConfigOptions, parser })`: Customizable parser and config loading -- `graphql-config` projects: Per-schema project isolation -- Babel-based parser (`src/parsers/babel.ts`) handles tag recognition with configurable tag names - -**Pros**: -- Autocomplete (`getAutocompleteSuggestions`), diagnostics (`getDiagnostics`), hover, go-to-definition already implemented -- GraphQL spec-compliant validation -- Built-in `.graphql` file support -- Compatible with `graphql-config` ecosystem (graphql-eslint, etc.) - -**Cons**: -- Forces dependency on `graphql-config` (soda-gql has its own config system) -- Multi-schema resolution depends on `graphql-config` projects mechanism — import path analysis still needs custom implementation -- Large dependency footprint (part of graphiql monorepo) -- Offset mapping (cursor position in TS file → position in GraphQL document) needs custom implementation regardless - -#### Option 2: Full from-scratch (vscode-languageserver + graphql-js) - -``` -soda-gql LSP Server -├── vscode-languageserver (LSP protocol handling) -├── Custom TS parser (tagged template extraction) -├── graphql-js (parse, validate, buildSchema) -└── soda-gql config loader (reads soda-gql.config.ts directly) -``` - -**Pros**: -- Reads `soda-gql.config.ts` directly (no config duplication) -- Full control over multi-schema resolution -- Minimal dependency footprint -- Complete freedom in tagged template parsing (supports Pattern A/B/C) -- Can reuse soda-gql's existing TypeScript AST / SWC parsers - -**Cons**: -- Must implement all LSP handlers (completion, hover, diagnostics, definition) -- Must implement GraphQL position calculation, error recovery -- Must implement `.graphql` file support from scratch -- Higher testing and maintenance burden - -#### Option 3: Hybrid (recommended candidate) +The LSP server uses a hybrid architecture that combines existing battle-tested algorithms from `graphql-language-service` with custom infrastructure for soda-gql's specific needs. ``` soda-gql LSP Server @@ -295,36 +228,35 @@ soda-gql LSP Server │ └── getAutocompleteSuggestions(), getDiagnostics(), getHoverInformation() ├── Custom TS parser (tagged template extraction, import analysis) ├── soda-gql config loader (reads soda-gql.config.ts directly) -└── Schema resolver (import path → schema mapping) +└── Schema resolver (tag name → schema mapping) ``` -Uses `graphql-language-service` (the **interface package**, not the server package) for the core GraphQL intelligence algorithms, but handles LSP protocol, config management, and file parsing independently. - -**Pros**: -- Battle-tested completion and diagnostics algorithms (from interface layer) -- Reads `soda-gql.config.ts` directly (no `graphql-config` dependency) -- Full control over multi-schema resolution -- Freedom in tagged template parsing design -- Moderate dependency footprint (interface package only, not the full server) - -**Cons**: -- Must implement LSP handlers (but simpler than from-scratch since core logic is borrowed) -- Must implement `.graphql` file support -- Depends on `graphql-language-service` interface package for updates - -#### Comparison matrix - -| Aspect | Option 1: Wrap server | Option 2: From scratch | Option 3: Hybrid | -|--------|-----------------------|------------------------|------------------| -| Initial implementation cost | Low | High | Medium | -| Completion/diagnostics quality | High (proven) | Needs implementation | High (interface layer) | -| Multi-schema control | Constrained by graphql-config | Full freedom | Full freedom | -| Config management | Requires graphql-config | soda-gql.config direct | soda-gql.config direct | -| Tagged template support | parseDocument customization | Custom parser | Custom parser | -| `.graphql` file support | Built-in | Custom implementation | Custom implementation | -| Dependency count | High | Minimal | Moderate | -| Long-term maintenance | Depends on upstream | Fully self-managed | Interface layer only | -| soda-gql-specific features | Constrained | Full freedom | Full freedom | +#### Why Hybrid + +The hybrid approach strikes the right balance between implementation effort and architectural control: + +- **graphql-language-service interface layer** provides stateless, schema-aware completion/diagnostics/hover functions that are proven across thousands of projects. Reimplementing these algorithms from scratch would add significant effort with no benefit. +- **Custom LSP server** (not wrapping `graphql-language-service-server`) avoids the forced dependency on `graphql-config`, which would create config duplication with `soda-gql.config.ts`. Reading soda-gql config directly ensures a single source of truth. +- **Custom TS parser** is required regardless of approach, since no existing parser recognizes the `gql.{schemaName}` member expression pattern. soda-gql's existing builder already has TypeScript AST analysis for `gql.{schemaName}()` calls, which can be extended for tagged templates. + +#### Characteristics + +**What graphql-language-service provides** (stateless functions, no server lifecycle): +- `getAutocompleteSuggestions()`: Field, argument, type, directive autocomplete +- `getDiagnostics()`: Syntax and validation errors with custom rule injection +- `getHoverInformation()`: Type information, field descriptions, deprecation notices +- `getOutline()`: Document symbols (operations, fragments) +- `getDefinitionQueryResultFor*()`: Go-to-definition helpers + +**What we build ourselves**: +- LSP server lifecycle and protocol handling (`vscode-languageserver`) +- TypeScript file parsing and tagged template extraction +- Position mapping (TS file coordinates ↔ GraphQL document coordinates) +- Multi-schema resolution (`gql.{schemaName}` → schema lookup) +- Config loading from `soda-gql.config.ts` +- Schema caching and reload +- Cross-file fragment resolution +- `.graphql` file support ### 4.4 Configuration integration @@ -366,7 +298,7 @@ packages/lsp/ ├── src/ │ ├── server.ts # LSP server entry point │ ├── document-manager.ts # TS file parsing, tagged template extraction -│ ├── schema-resolver.ts # Import path → schema name resolution +│ ├── schema-resolver.ts # Tag name → schema resolution │ ├── handlers/ │ │ ├── completion.ts # textDocument/completion │ │ ├── diagnostics.ts # textDocument/publishDiagnostics @@ -378,7 +310,7 @@ packages/lsp/ └── package.json ``` -Dependencies (for hybrid approach): +Dependencies: - `vscode-languageserver` / `vscode-languageserver-textdocument`: LSP protocol - `graphql-language-service`: Completion, diagnostics, hover algorithms - `graphql`: Parsing and validation @@ -395,7 +327,7 @@ Parses TypeScript files to extract tagged templates and their schema association type ExtractedTemplate = { /** Range within the TS file (for offset mapping) */ range: { start: number; end: number }; - /** Resolved schema name */ + /** Resolved schema name (extracted from gql.{schemaName} tag) */ schemaName: string; /** Raw GraphQL content (without template tag) */ content: string; @@ -410,27 +342,28 @@ type DocumentState = { The document manager: 1. Parses the TypeScript file into an AST -2. Finds all tagged template expressions matching `gql.{name}` or `graphql` identifiers -3. Resolves the tag's import to a schema name via the schema resolver -4. Extracts the GraphQL string content -5. Caches results per document (invalidated on change) +2. Finds all tagged template expressions matching `gql.{name}` member expressions +3. Extracts the schema name from the member expression +4. Validates the `gql` import resolves to the graphql-system output +5. Extracts the GraphQL string content +6. Caches results per document (invalidated on change) #### Schema Resolver -Maps import paths to schema names using the soda-gql config: +Maps schema names to `GraphQLSchema` objects using the soda-gql config: ```typescript type SchemaEntry = { name: string; schema: GraphQLSchema; documentNode: DocumentNode; - matchPaths: string[]; // All paths that resolve to this schema + matchPaths: string[]; // All import paths that resolve to this schema's gql object }; ``` Builds `matchPaths` from config: -- `{outdir}/{schemaName}` (e.g., `./graphql-system/default`) -- `{alias}/{schemaName}` for each `graphqlSystemAliases` entry (e.g., `@/graphql-system/default`) +- `{outdir}` (e.g., `./graphql-system`) — the `gql` import source +- `{alias}` for each `graphqlSystemAliases` entry (e.g., `@/graphql-system`) - Handles tsconfig `paths` resolution if `tsconfigPath` is configured #### Position Mapping @@ -512,7 +445,7 @@ require('lspconfig').soda_gql.setup{} ### Phase 0: Schema resolver and config extension -- Implement `schema-resolver.ts`: import path → schema name resolution +- Implement `schema-resolver.ts`: tag name → schema resolution using soda-gql config - Extend config types if needed (e.g., per-schema `importPaths` overrides) - Add `codegen lsp-config` CLI command to generate `.graphqlrc.generated.json` - Unit tests for schema resolution with aliases and tsconfig paths @@ -520,7 +453,7 @@ require('lspconfig').soda_gql.setup{} ### Phase 1: LSP server core - Set up `@soda-gql/lsp` package -- Implement document manager (TS AST → tagged template extraction) +- Implement document manager (TS AST → `gql.{schemaName}` tagged template extraction) - Implement position mapping (TS offset ↔ GraphQL offset) - Wire up LSP handlers using `graphql-language-service` interface functions: - `textDocument/completion` → `getAutocompleteSuggestions()` @@ -571,6 +504,41 @@ const q2 = gql.default`query GetUser { user { id } }`; ## Alternatives Considered +### Tagged template: Pattern A (schema-specific subpath imports) + +```typescript +import { graphql } from "@/graphql-system/default"; +import { graphql as adminGraphql } from "@/graphql-system/admin"; + +const GetUser = graphql`query GetUser { user { id } }`; +const GetAdmin = adminGraphql`query GetAdmin { admins { id } }`; +``` + +**Rejected**: Pattern A uses `graphql` as the tag identifier, which is compatible with `graphql-tag-pluck` and external tools. However, since this RFC builds a custom LSP, `graphql-tag-pluck` compatibility is unnecessary. Pattern A requires separate imports per schema, leading to import proliferation and aliasing (`graphql as adminGraphql`), which is less ergonomic than Pattern C's unified `gql` import. + +### Tagged template: Pattern B (single import with member access) + +```typescript +import { graphql } from "@/graphql-system"; + +const GetUser = graphql.default`query GetUser { user { id } }`; +const GetAdmin = graphql.admin`query GetAdmin { admins { id } }`; +``` + +**Rejected**: Pattern B provides the same member-access ergonomics as Pattern C but introduces a separate `graphql` identifier that doesn't integrate with the existing `gql.{schemaName}(callback)` API. Pattern C's advantage is that both callback and tagged template styles share the same `gql.{schemaName}` interface, making the mental model simpler. + +### LSP: Wrap graphql-language-service-server + +Use `graphql-language-service-server`'s `startServer()` with custom `parseDocument` for tagged template extraction. + +**Rejected**: This approach forces a dependency on `graphql-config` for schema management, creating config duplication with `soda-gql.config.ts`. Multi-schema resolution would still depend on `graphql-config`'s projects mechanism, limiting control. The server package also carries a large dependency footprint (part of the graphiql monorepo). The hybrid approach gets the same completion/diagnostics quality by using the interface layer directly, without the server-layer constraints. + +### LSP: Full from-scratch implementation + +Use only `vscode-languageserver` + `graphql-js`, implementing all completion/diagnostics/hover logic from scratch. + +**Rejected**: The `graphql-language-service` interface layer provides stateless, battle-tested algorithms for autocomplete suggestions, diagnostics, and hover information. Reimplementing these from scratch would be significant effort with no architectural benefit, since the interface functions have no server lifecycle or config dependencies — they take a schema + query + position and return results. + ### TypeScript Language Service Plugin Build as a TS plugin (like `@0no-co/graphqlsp`) instead of a standalone LSP server. From 25f6fa19b1304e6811f2b55ac92ae35381517245 Mon Sep 17 00:00:00 2001 From: whatasoda Date: Sun, 1 Feb 2026 13:26:46 +0900 Subject: [PATCH 4/4] docs(rfc): callback + tagged template API with Fragment Arguments Rewrite API design to require callback wrapper: - gql.{schemaName}(({ query, fragment }) => query`...`) - query/mutation/subscription/fragment as tagged template tags - Fragment Arguments RFC syntax for fragment variables - Tagged template results callable for metadata chaining - SWC for TS AST parsing in LSP - Fragment Arguments preprocessor for graphql-js compatibility Co-Authored-By: Claude Opus 4.5 --- .../graphql-lsp-implementation-details.md | 30 +- docs/rfcs/graphql-lsp-multi-schema.md | 394 +++++++++++++----- 2 files changed, 297 insertions(+), 127 deletions(-) diff --git a/docs/rfcs/graphql-lsp-implementation-details.md b/docs/rfcs/graphql-lsp-implementation-details.md index 8e773703..8560c746 100644 --- a/docs/rfcs/graphql-lsp-implementation-details.md +++ b/docs/rfcs/graphql-lsp-implementation-details.md @@ -2,7 +2,7 @@ ## Purpose -This document supplements the [GraphQL LSP Multi-Schema RFC](./graphql-lsp-multi-schema.md) with deeper technical analysis of the hybrid LSP architecture. It covers what the `graphql-language-service` interface layer provides, what must be built from scratch for the `gql.{schemaName}` tagged template pattern (Pattern C), and what opportunities exist for soda-gql-specific extensions beyond standard GraphQL LSP features. +This document supplements the [GraphQL LSP Multi-Schema RFC](./graphql-lsp-multi-schema.md) with deeper technical analysis of the hybrid LSP architecture. It covers what the `graphql-language-service` interface layer provides, what must be built from scratch for the callback + tagged template API (`gql.{schemaName}(({ query, fragment }) => query`...`)`) with Fragment Arguments RFC syntax, and what opportunities exist for soda-gql-specific extensions beyond standard GraphQL LSP features. ## Table of Contents @@ -243,19 +243,24 @@ function getVariablesJSONSchema(variableToType: Record The most critical custom component. It must: -1. **Parse TypeScript AST** to find tagged template expressions -2. **Identify the tag**: Match `gql.{schemaName}` member expression patterns (the chosen Pattern C) -3. **Trace imports**: Follow the `gql` identifier back to its import declaration, validate it resolves to the graphql-system output -4. **Extract content**: Get the raw GraphQL string from the template -5. **Compute offset map**: Map between TS file positions and GraphQL content positions +1. **Parse TypeScript AST via SWC** to find `gql.{schemaName}(callback)` call expressions +2. **Identify the callback**: Extract the arrow function or function expression argument +3. **Find tagged templates inside the callback body**: Match `query`/`mutation`/`subscription`/`fragment` tagged template expressions +4. **Trace imports**: Follow the `gql` identifier back to its import declaration, validate it resolves to the graphql-system output +5. **Extract content**: Get the raw GraphQL string from the template +6. **Preprocess Fragment Arguments**: Strip fragment argument declarations for `graphql-js` compatibility +7. **Compute offset map**: Map between TS file positions and GraphQL content positions Complexity factors: +- Two-level AST traversal: first find `gql.{name}(callback)`, then find tagged templates inside the callback - Template literals can span multiple lines with varying indentation - The GraphQL content starts after the opening backtick, possibly with leading whitespace -- Must handle re-exports and aliased imports (`import { graphql as gql } from ...`) -- Must handle TypeScript path aliases (`@/graphql-system/...` → actual file path) +- Must handle re-exports and aliased imports (`import { gql } from ...` with path aliases) +- Must handle TypeScript path aliases (`@/graphql-system` → actual file path) +- Fragment Arguments RFC syntax (`fragment Foo($var: Type) on Bar`) must be stripped before `graphql-js` parsing +- Metadata chaining (`fragment`...`({ metadata })`) — the tagged template result may be called -**Reuse opportunity**: soda-gql's `@soda-gql/builder` already has a TypeScript AST analyzer (`packages/builder/src/discovery/`) that finds `gql.{schemaName}()` calls. The tagged template extractor extends this to also recognize `` gql.{schemaName}`...` `` tagged template expressions — the same member expression pattern, different call syntax. +**Reuse opportunity**: soda-gql's `@soda-gql/builder` already has a SWC-based AST analyzer (`packages/builder/src/discovery/`) that finds `gql.{schemaName}()` calls. The tagged template extractor extends this to also find tagged templates inside callback bodies — the same `gql.{schemaName}(callback)` pattern, with additional inspection of the callback's body. ### 3.2 Position mapping @@ -421,6 +426,7 @@ Possible code lens items: | `NoExcludedTypes` | Warn when querying types excluded by `typeFilter` in config | Warning | | `InputDepthLimit` | Warn when input nesting exceeds `defaultInputDepth` or overrides | Warning | | `FragmentSchemaMatch` | Error when spreading a fragment from a different schema | Error | +| `FragmentArgumentsValidation` | Validate Fragment Arguments RFC syntax (variable types, default values, usage at spread sites) | Error | | `OperationNaming` | Enforce naming conventions for operations | Info | | `DeprecatedFieldUsage` | Enhanced deprecation warnings with migration hints | Warning | | `UnusedFragment` | Warn about fragments defined but not spread anywhere | Warning | @@ -497,16 +503,16 @@ query GetUser($id: ID!) { user(id: $id) { id name } } -// Code action: "Convert to soda-gql tagged template (Pattern C)" +// Code action: "Convert to soda-gql tagged template" // After (in .ts file): import { gql } from "@/graphql-system"; -export const GetUserCompat = gql.default` +export const GetUserCompat = gql.default(({ query }) => query` query GetUser($id: ID!) { user(id: $id) { id name } } -`; +`); ``` This leverages the existing graphql-compat parser (`packages/codegen/src/graphql-compat/parser.ts`) but runs it interactively via LSP code actions instead of CLI codegen. diff --git a/docs/rfcs/graphql-lsp-multi-schema.md b/docs/rfcs/graphql-lsp-multi-schema.md index ca7347d0..5d3da786 100644 --- a/docs/rfcs/graphql-lsp-multi-schema.md +++ b/docs/rfcs/graphql-lsp-multi-schema.md @@ -8,10 +8,11 @@ This RFC defines the design for an independent GraphQL Language Server Protocol (LSP) implementation for soda-gql. The LSP provides IDE features (autocomplete, diagnostics, hover, go-to-definition) for GraphQL operations written as tagged template literals in TypeScript files. -The design commits to two key decisions: +The design commits to these key decisions: -1. **Tagged Template API (Pattern C)**: Extend the existing `gql.{schemaName}` interface with tagged template support, so that `` gql.default`query { ... }` `` coexists naturally alongside `gql.default(callback)`. -2. **Hybrid LSP Architecture**: Build a custom LSP server using `vscode-languageserver` for protocol handling and `graphql-language-service` (interface layer only) for completion/diagnostics/hover algorithms, while managing config, file parsing, and multi-schema routing independently. +1. **Callback + Tagged Template API**: `gql.{schemaName}` always receives a callback. Inside the callback, `query`/`mutation`/`subscription`/`fragment` serve as tagged template tags. The tagged template result is callable for metadata chaining. +2. **Fragment Arguments RFC syntax**: Fragment variables are declared using the [GraphQL Fragment Arguments proposal](https://github.com/graphql/graphql-spec/pull/1081) syntax directly in GraphQL. +3. **Hybrid LSP Architecture**: Custom LSP server using `vscode-languageserver` for protocol handling and `graphql-language-service` (interface layer only) for completion/diagnostics/hover algorithms. SWC for TypeScript AST parsing. ## Motivation @@ -88,92 +89,198 @@ Two main approaches exist for providing GraphQL IDE features: ### Why tagged template literals -Tagged template literals provide a natural syntax for embedding GraphQL in TypeScript. By extending the existing `gql` interface, tagged templates integrate seamlessly with the current composer API: +Tagged template literals provide a natural syntax for embedding GraphQL in TypeScript. By combining the existing `gql.{schemaName}(callback)` pattern with tagged templates inside the callback, the API gains GraphQL-aware IDE support while preserving soda-gql's architecture: ```typescript import { gql } from "@/graphql-system"; -// Existing: callback composer (unchanged) -const GetUser1 = gql.default(({ query, $var }) => +// operation: query tagged template inside callback +const GetUser = gql.default(({ query }) => query` + query GetUser($id: ID!) { + user(id: $id) { id name } + } +`); + +// fragment: with Fragment Arguments RFC syntax for variables +const UserFields = gql.default(({ fragment }) => fragment` + fragment UserFields($showEmail: Boolean = false) on User { + id + email @include(if: $showEmail) + } +`); + +// existing callback API (unchanged, coexists) +const GetUser2 = gql.default(({ query, $var }) => query.operation({ name: "GetUser", variables: { ...$var("id").ID("!") }, fields: ({ f, $ }) => ({ - ...f.user({ id: $.id })(({ f }) => ({ - id: true, - name: true, - })), + ...f.user({ id: $.id })(({ f }) => ({ id: true, name: true })), }), }) ); - -// New: tagged template style -const GetUser2 = gql.default` - query GetUser($id: ID!) { - user(id: $id) { id name } - } -`; ``` Advantages: -- **Tag name encodes the schema**: `gql.default` vs `gql.admin` — explicit and unambiguous -- **Unified import**: Both callback and tagged template styles use the same `gql` import -- **Industry-standard pattern**: Tagged template literals for GraphQL are used by Apollo Client, urql, Relay, gql.tada -- **Parser-friendly**: LSP can extract GraphQL content from tagged templates via AST analysis +- **Schema explicit via `gql.{schemaName}`**: `gql.default` vs `gql.admin` — unambiguous schema association +- **Operation kind explicit via tag name**: `query`, `fragment`, `mutation`, `subscription` — type-level distinction +- **Callback preserves intermediate module compatibility**: `gql.{schemaName}` always receives a function +- **Parser-friendly**: LSP extracts tagged templates from callback bodies via SWC AST analysis - **Syntax highlighting**: Editor extensions (e.g., vscode-graphql-syntax) already support GraphQL in tagged templates via TextMate grammar injection ## Design Decisions -### 4.1 Tagged Template API (Pattern C) +### 4.1 Tagged Template API -The tagged template API extends the existing `gql.{schemaName}` interface. The same `gql.{schemaName}` expression works as both a function call (existing callback API) and a tagged template literal (new). Both styles produce the same build-time artifacts and runtime behavior. +The tagged template API extends the existing `gql.{schemaName}(callback)` composer pattern. The callback context provides `query`/`mutation`/`subscription`/`fragment` that serve as both tagged template tags (new) and existing API method hosts. Both styles produce the same build-time artifacts and runtime behavior. #### API design ```typescript import { gql } from "@/graphql-system"; -// Callback style (existing, unchanged) -const GetUser1 = gql.default(({ query }) => query.operation({ ... })); +// --- Operations --- -// Tagged template style (new) -const GetUser2 = gql.default` +// query +const GetUser = gql.default(({ query }) => query` query GetUser($id: ID!) { - user(id: $id) { id name } + user(id: $id) { id name email } } -`; +`); + +// mutation +const UpdateUser = gql.default(({ mutation }) => mutation` + mutation UpdateUser($id: ID!, $name: String!) { + updateUser(id: $id, name: $name) { id name } + } +`); + +// subscription +const OnMessage = gql.default(({ subscription }) => subscription` + subscription OnMessage($roomId: ID!) { + messageAdded(roomId: $roomId) { id text sender } + } +`); + +// --- Fragments --- + +// basic fragment +const UserFields = gql.default(({ fragment }) => fragment` + fragment UserFields on User { + id + name + email + } +`); + +// fragment with variables (Fragment Arguments RFC syntax) +const UserProfile = gql.default(({ fragment }) => fragment` + fragment UserProfile($showEmail: Boolean = false) on User { + id + name + email @include(if: $showEmail) + } +`); + +// fragment with metadata chaining +const PostList = gql.default(({ fragment }) => fragment` + fragment PostList($first: Int!) on Query { + posts(first: $first) { id title } + } +`({ + metadata: { pagination: true }, +})); + +// --- Existing callback API (unchanged, coexists) --- + +const GetUser2 = gql.default(({ query, $var }) => + query.operation({ + name: "GetUser", + variables: { ...$var("id").ID("!") }, + fields: ({ f, $ }) => ({ + ...f.user({ id: $.id })(({ f }) => ({ id: true, name: true })), + }), + }) +); +``` + +#### Design rationale + +- **`gql.{schemaName}` always receives a callback**: Required by the intermediate module system. The builder's static analysis depends on `gql.{schemaName}(callback)` call expressions. +- **Tag names encode operation kind**: `query`/`mutation`/`subscription`/`fragment` in the callback distinguish the element type at both the TypeScript type level and the AST level. This eliminates ambiguity that a bare `gql.default`...`` would have. +- **Metadata chaining via call**: The tagged template result is callable, enabling metadata attachment (e.g., `fragment`...`({ metadata: ... })`). This is extensible for future metadata needs without changing the GraphQL syntax. + +#### Fragment Arguments RFC syntax + +This RFC adopts the [Fragment Arguments proposal (graphql-spec #1081)](https://github.com/graphql/graphql-spec/pull/1081) for declaring fragment variables. This is a Stage 2 GraphQL spec proposal that adds variable declarations to fragment definitions: -// Multi-schema: same pattern, different schema name -const GetAdmin = gql.admin` - query GetAdmin { - admins { id role } +```graphql +# Standard GraphQL: no fragment variables +fragment UserFields on User { + id + name +} + +# Fragment Arguments RFC: variables declared on the fragment +fragment UserProfile($showEmail: Boolean = false) on User { + id + name + email @include(if: $showEmail) +} + +# Usage with arguments +query GetUser($id: ID!) { + user(id: $id) { + ...UserProfile(showEmail: true) } -`; +} ``` -This design was chosen because: -- **Natural coexistence**: `gql.default(callback)` and `` gql.default`tag` `` share the same import and schema namespace. Developers choose the style that fits each use case. -- **No import proliferation**: Unlike subpath imports (`import { graphql } from "@/graphql-system/default"`), Pattern C uses a single import for all schemas. -- **Custom LSP eliminates external tool constraints**: Since this RFC proposes building a custom LSP, there is no need for `graphql-tag-pluck` compatibility. The LSP's parser recognizes `gql.{schemaName}` as a tagged template tag directly. +This syntax is chosen because: +- **Natural**: Variable declarations look identical to operation variable declarations +- **Self-contained**: Fragment variables are declared in the GraphQL string itself, no TypeScript-level API needed +- **Future-proof**: If the spec proposal is accepted, soda-gql's syntax becomes standard GraphQL -#### Runtime behavior +**`graphql-js` compatibility**: The current `graphql-js` parser does not accept Fragment Arguments syntax. The LSP preprocesses fragment definitions by stripping argument declarations before passing to `graphql-language-service` functions (`getDiagnostics()`, `getAutocompleteSuggestions()`). Fragment argument validation is handled by custom soda-gql validation rules. + +#### Type structure ```typescript -// Generated by codegen (in graphql-system output) -// The gql.{schemaName} object acts as both a function and a tagged template tag: -// gql.default(callback) → existing callback API -// gql.default`query { ... }` → new tagged template API -// -// Tagged template function signature: -export function taggedTemplate( - strings: TemplateStringsArray, - ...values: never[] // Interpolation is prohibited -): TypedDocumentNode; +// query/mutation/subscription: tagged template tag + existing API methods +type QueryTag = { + // Tagged template (new) + (strings: TemplateStringsArray, ...values: never[]): ChainableOperation; + // Existing callback API + operation: (...) => AnyOperation; + compat: (...) => AnyGqlDefine; +}; + +// fragment: tagged template tag + existing per-type builders +type FragmentTag = { + // Tagged template (new) + (strings: TemplateStringsArray, ...values: never[]): ChainableFragment; + // Existing callback API (fragment.User, fragment.Post, ...) + readonly [TypeName in keyof Schema["object"]]: FragmentBuilderFor<...>; +}; + +// Tagged template results are callable for metadata chaining +type ChainableOperation = AnyOperation & { + (options: { metadata?: Record }): AnyOperation; +}; + +type ChainableFragment = AnyFragment & { + (options: { metadata?: Record }): AnyFragment; +}; ``` +`FragmentTag` has dual nature: it is callable as a tagged template tag, and it has properties for the existing `fragment.User(...)` builder API. At runtime, this is implemented via `Object.assign(tagFn, fragmentBuilders)` or `Proxy`. + +#### Runtime behavior + Key constraints: -- **No interpolation**: `` gql.default`...${expr}...` `` is a type error (`never[]` enforces this). Interpolation would break static analysis and LSP autocomplete. -- **Build-time extraction**: The builder plugin detects tagged templates via AST analysis and transforms them into the same artifact format as `gql.{schemaName}(callback)` calls. +- **`gql.{schemaName}` always receives a callback**: The callback is invoked with a context containing `query`/`mutation`/`subscription`/`fragment` tags plus existing API members (`$var`, `$dir`, `$colocate`, etc.). +- **No interpolation**: `` query`...${expr}...` `` is a type error (`never[]` enforces this). Interpolation would break static analysis and LSP autocomplete. +- **Build-time extraction**: The builder plugin detects tagged templates inside `gql.{schemaName}(callback)` calls via SWC AST analysis and transforms them into the same artifact format as existing callback API calls. - **Runtime transformation**: At build time, tagged templates are replaced with `createRuntimeOperation` / `createRuntimeFragment` calls (same codepath as existing API). #### Type inference (Open Question) @@ -182,39 +289,42 @@ Two approaches for type-safe results from tagged templates: **Approach 1: Codegen-generated TypedDocumentNode** (graphql-codegen pattern) ```typescript -const GetUser = gql.default`query GetUser($id: ID!) { user(id: $id) { id name } }`; +const GetUser = gql.default(({ query }) => query` + query GetUser($id: ID!) { user(id: $id) { id name } } +`); // Type: TypedDocumentNode -// Types generated by a separate codegen step ``` **Approach 2: soda-gql $infer pattern** ```typescript -const GetUser = gql.default`query GetUser($id: ID!) { user(id: $id) { id name } }`; +const GetUser = gql.default(({ query }) => query` + query GetUser($id: ID!) { user(id: $id) { id name } } +`); type Data = typeof GetUser.$infer.output; type Vars = typeof GetUser.$infer.input; -// Types inferred from the tagged template content at build time ``` This decision is deferred to implementation phase. See [Open Questions](#open-questions). ### 4.2 Schema association mechanism -Schema association is resolved through the tag name (`gql.{schemaName}`) and its import path: +Schema association is resolved through `gql.{schemaName}` and its import path: 1. **Codegen generates per-schema entries**: The `gql` object exposes schema-specific members (`gql.default`, `gql.admin`) -2. **LSP resolves tag to schema name**: When the LSP encounters `` gql.{name}`...` ``, it extracts `{name}` as the schema identifier +2. **LSP resolves tag to schema name**: When the LSP encounters `gql.{name}(callback)`, it extracts `{name}` as the schema identifier 3. **Config provides the mapping**: The existing `schemas: Record` in `soda-gql.config.ts` defines available schemas. The existing `graphqlSystemAliases` config (e.g., `["@/graphql-system"]`) provides alias resolution. Resolution algorithm: ``` -1. Find tagged template at cursor position -2. Extract schema name from tag expression (e.g., gql.default → "default", gql.admin → "admin") -3. Validate that the tag's import path resolves to the graphql-system output: +1. Find gql.{name}(callback) call expression via SWC AST +2. Extract schema name from member expression (e.g., gql.default → "default") +3. Validate that the gql import resolves to the graphql-system output: - outdir (e.g., "./graphql-system") - graphqlSystemAliases[i] (e.g., "@/graphql-system") - Handle tsconfig paths aliases if tsconfigPath is configured 4. Match schema name against config schemas: Record -5. Return matched schema, or report diagnostic if unresolved +5. Inside the callback body, find tagged templates (query`...`, fragment`...`, etc.) +6. For each tagged template, associate with the resolved schema ``` ### 4.3 LSP architecture: Hybrid approach @@ -226,9 +336,10 @@ soda-gql LSP Server ├── vscode-languageserver (LSP protocol handling) ├── graphql-language-service (completion/diagnostics/hover logic only) │ └── getAutocompleteSuggestions(), getDiagnostics(), getHoverInformation() -├── Custom TS parser (tagged template extraction, import analysis) +├── SWC-based TS parser (tagged template extraction from callbacks) +├── Fragment Arguments preprocessor (strip before graphql-js validation) ├── soda-gql config loader (reads soda-gql.config.ts directly) -└── Schema resolver (tag name → schema mapping) +└── Schema resolver (gql.{schemaName} → schema mapping) ``` #### Why Hybrid @@ -237,7 +348,8 @@ The hybrid approach strikes the right balance between implementation effort and - **graphql-language-service interface layer** provides stateless, schema-aware completion/diagnostics/hover functions that are proven across thousands of projects. Reimplementing these algorithms from scratch would add significant effort with no benefit. - **Custom LSP server** (not wrapping `graphql-language-service-server`) avoids the forced dependency on `graphql-config`, which would create config duplication with `soda-gql.config.ts`. Reading soda-gql config directly ensures a single source of truth. -- **Custom TS parser** is required regardless of approach, since no existing parser recognizes the `gql.{schemaName}` member expression pattern. soda-gql's existing builder already has TypeScript AST analysis for `gql.{schemaName}()` calls, which can be extended for tagged templates. +- **SWC-based TS parser** provides fast AST analysis for extracting tagged templates from callback bodies. SWC is already used by soda-gql's builder and offers significantly better parse performance than the TypeScript compiler API, which matters for an always-on LSP server. +- **Fragment Arguments preprocessor** is required regardless of LSP approach, since `graphql-js` does not support the Fragment Arguments syntax. The preprocessor strips argument declarations before passing to `graphql-language-service` functions. #### Characteristics @@ -250,7 +362,8 @@ The hybrid approach strikes the right balance between implementation effort and **What we build ourselves**: - LSP server lifecycle and protocol handling (`vscode-languageserver`) -- TypeScript file parsing and tagged template extraction +- SWC-based TypeScript file parsing and tagged template extraction from callbacks +- Fragment Arguments preprocessing (strip before `graphql-js` validation) - Position mapping (TS file coordinates ↔ GraphQL document coordinates) - Multi-schema resolution (`gql.{schemaName}` → schema lookup) - Config loading from `soda-gql.config.ts` @@ -297,8 +410,9 @@ A new package `@soda-gql/lsp` will be created in `packages/lsp/`: packages/lsp/ ├── src/ │ ├── server.ts # LSP server entry point -│ ├── document-manager.ts # TS file parsing, tagged template extraction -│ ├── schema-resolver.ts # Tag name → schema resolution +│ ├── document-manager.ts # SWC-based TS parsing, tagged template extraction +│ ├── schema-resolver.ts # gql.{schemaName} → schema resolution +│ ├── fragment-args.ts # Fragment Arguments preprocessor │ ├── handlers/ │ │ ├── completion.ts # textDocument/completion │ │ ├── diagnostics.ts # textDocument/publishDiagnostics @@ -314,23 +428,27 @@ Dependencies: - `vscode-languageserver` / `vscode-languageserver-textdocument`: LSP protocol - `graphql-language-service`: Completion, diagnostics, hover algorithms - `graphql`: Parsing and validation +- `@swc/core`: TypeScript AST parsing for tagged template extraction - `@soda-gql/config`: Config loading -- `typescript` (or `@swc/core`): TS AST parsing for tagged template extraction ### Core components #### Document Manager -Parses TypeScript files to extract tagged templates and their schema associations: +Parses TypeScript files via SWC to extract tagged templates from `gql.{schemaName}(callback)` calls: ```typescript type ExtractedTemplate = { /** Range within the TS file (for offset mapping) */ range: { start: number; end: number }; - /** Resolved schema name (extracted from gql.{schemaName} tag) */ + /** Resolved schema name (from gql.{schemaName}) */ schemaName: string; + /** Operation kind (from tag name: query/mutation/subscription/fragment) */ + kind: "query" | "mutation" | "subscription" | "fragment"; /** Raw GraphQL content (without template tag) */ content: string; + /** GraphQL content with Fragment Arguments stripped (for graphql-js) */ + preprocessedContent: string; }; type DocumentState = { @@ -341,12 +459,13 @@ type DocumentState = { ``` The document manager: -1. Parses the TypeScript file into an AST -2. Finds all tagged template expressions matching `gql.{name}` member expressions -3. Extracts the schema name from the member expression -4. Validates the `gql` import resolves to the graphql-system output +1. Parses the TypeScript file into an SWC AST +2. Finds all `gql.{name}(callback)` call expressions +3. Extracts the schema name from the member expression (`gql.default` → `"default"`) +4. Inside the callback body, finds tagged template expressions where the tag is `query`, `mutation`, `subscription`, or `fragment` 5. Extracts the GraphQL string content -6. Caches results per document (invalidated on change) +6. Preprocesses Fragment Arguments syntax (strips argument declarations for `graphql-js` compatibility) +7. Caches results per document (invalidated on change) #### Schema Resolver @@ -366,17 +485,36 @@ Builds `matchPaths` from config: - `{alias}` for each `graphqlSystemAliases` entry (e.g., `@/graphql-system`) - Handles tsconfig `paths` resolution if `tsconfigPath` is configured +#### Fragment Arguments Preprocessor + +Transforms Fragment Arguments RFC syntax into standard GraphQL for `graphql-js` compatibility: + +``` +Input: fragment UserProfile($showEmail: Boolean = false) on User { ... } +Output: fragment UserProfile on User { ... } + +Input: ...UserProfile(showEmail: true) +Output: ...UserProfile +``` + +The preprocessor: +1. Strips variable declarations from fragment definitions: `($showEmail: Boolean = false)` → removed +2. Strips arguments from fragment spreads: `(showEmail: true)` → removed +3. Preserves all position information for offset mapping back to the original source + +Custom soda-gql validation rules handle fragment argument validation separately (see Implementation Details report, section 4.3). + #### Position Mapping Converts between positions in the TypeScript file and positions within the GraphQL document: ``` TypeScript file: - Line 5: const q = gql.default` + Line 5: const q = gql.default(({ query }) => query` Line 6: query GetUser { ← cursor at (6, 8) Line 7: user { id } Line 8: } - Line 9: `; + Line 9: `); GraphQL document (extracted): Line 1: query GetUser { ← mapped to (1, 8) @@ -445,7 +583,7 @@ require('lspconfig').soda_gql.setup{} ### Phase 0: Schema resolver and config extension -- Implement `schema-resolver.ts`: tag name → schema resolution using soda-gql config +- Implement `schema-resolver.ts`: `gql.{schemaName}` → schema resolution using soda-gql config - Extend config types if needed (e.g., per-schema `importPaths` overrides) - Add `codegen lsp-config` CLI command to generate `.graphqlrc.generated.json` - Unit tests for schema resolution with aliases and tsconfig paths @@ -453,7 +591,8 @@ require('lspconfig').soda_gql.setup{} ### Phase 1: LSP server core - Set up `@soda-gql/lsp` package -- Implement document manager (TS AST → `gql.{schemaName}` tagged template extraction) +- Implement SWC-based document manager (find `gql.{name}(callback)` → extract tagged templates from callback body) +- Implement Fragment Arguments preprocessor - Implement position mapping (TS offset ↔ GraphQL offset) - Wire up LSP handlers using `graphql-language-service` interface functions: - `textDocument/completion` → `getAutocompleteSuggestions()` @@ -473,6 +612,7 @@ require('lspconfig').soda_gql.setup{} - `textDocument/definition` (navigate to schema type definitions) - Schema file watching and auto-reload - Fragment cross-file resolution +- Fragment Arguments validation (custom rules) ### Phase 4: Ecosystem integration @@ -489,14 +629,22 @@ This proposal introduces **no breaking changes**: - **`.graphql` files with graphql-compat**: Continue to work via `codegen graphql` command - **Existing config**: `soda-gql.config.ts` schema is extended with optional fields only; old configs work without LSP features -All three styles can coexist in the same project: +All styles can coexist in the same project: ```typescript -// Style 1: Callback composer (existing) -const q1 = gql.default(({ query }) => query.operation({ ... })); +// Style 1: Callback composer with field builders (existing) +const q1 = gql.default(({ query, $var }) => + query.operation({ + name: "GetUser", + variables: { ...$var("id").ID("!") }, + fields: ({ f, $ }) => ({ ...f.user({ id: $.id })(...) }), + }) +); -// Style 2: Tagged template (new) -const q2 = gql.default`query GetUser { user { id } }`; +// Style 2: Callback with tagged template (new) +const q2 = gql.default(({ query }) => query` + query GetUser($id: ID!) { user(id: $id) { id name } } +`); // Style 3: .graphql file with compat (existing) // → auto-generated .compat.ts @@ -504,40 +652,70 @@ const q2 = gql.default`query GetUser { user { id } }`; ## Alternatives Considered -### Tagged template: Pattern A (schema-specific subpath imports) +### Bare tagged template without callback + +```typescript +const GetUser = gql.default`query GetUser { user { id } }`; +``` + +**Rejected**: `gql.{schemaName}` must always receive a callback for intermediate module compatibility. Additionally, bare tagged templates cannot distinguish between fragments and operations at the TypeScript type level, and cannot provide context members like `$var` or `$dir`. + +### Tagged template: Schema-specific subpath imports (Pattern A) ```typescript import { graphql } from "@/graphql-system/default"; import { graphql as adminGraphql } from "@/graphql-system/admin"; const GetUser = graphql`query GetUser { user { id } }`; -const GetAdmin = adminGraphql`query GetAdmin { admins { id } }`; ``` -**Rejected**: Pattern A uses `graphql` as the tag identifier, which is compatible with `graphql-tag-pluck` and external tools. However, since this RFC builds a custom LSP, `graphql-tag-pluck` compatibility is unnecessary. Pattern A requires separate imports per schema, leading to import proliferation and aliasing (`graphql as adminGraphql`), which is less ergonomic than Pattern C's unified `gql` import. +**Rejected**: Requires separate imports per schema, leading to import proliferation and aliasing. Does not preserve the callback pattern required by the intermediate module system. -### Tagged template: Pattern B (single import with member access) +### Tagged template: Separate `graphql` identifier (Pattern B) ```typescript import { graphql } from "@/graphql-system"; const GetUser = graphql.default`query GetUser { user { id } }`; -const GetAdmin = graphql.admin`query GetAdmin { admins { id } }`; ``` -**Rejected**: Pattern B provides the same member-access ergonomics as Pattern C but introduces a separate `graphql` identifier that doesn't integrate with the existing `gql.{schemaName}(callback)` API. Pattern C's advantage is that both callback and tagged template styles share the same `gql.{schemaName}` interface, making the mental model simpler. +**Rejected**: Introduces a separate `graphql` identifier that doesn't integrate with the existing `gql.{schemaName}(callback)` API. Does not preserve the callback requirement. + +### Fragment variables via Relay-style directives + +```graphql +fragment UserFields on User + @argumentDefinitions(showEmail: { type: "Boolean", defaultValue: false }) { + id + email @include(if: $showEmail) +} +``` + +**Rejected**: Verbose syntax. Type names must be written as strings (`"Boolean"` instead of `Boolean`). The Fragment Arguments RFC syntax is cleaner and has a path to standardization. + +### Fragment variables via TypeScript API (callback metadata) + +```typescript +const UserFields = gql.default(({ fragment, $var }) => fragment` + fragment UserFields on User { ... } +`({ + variables: { showEmail: $var("showEmail").Boolean() }, +})); +``` + +**Rejected**: Splits variable declarations between GraphQL (usage via `$showEmail`) and TypeScript (declaration via `$var`). Fragment Arguments RFC syntax keeps everything in GraphQL, making the code self-contained and enabling LSP validation of the complete fragment definition. ### LSP: Wrap graphql-language-service-server Use `graphql-language-service-server`'s `startServer()` with custom `parseDocument` for tagged template extraction. -**Rejected**: This approach forces a dependency on `graphql-config` for schema management, creating config duplication with `soda-gql.config.ts`. Multi-schema resolution would still depend on `graphql-config`'s projects mechanism, limiting control. The server package also carries a large dependency footprint (part of the graphiql monorepo). The hybrid approach gets the same completion/diagnostics quality by using the interface layer directly, without the server-layer constraints. +**Rejected**: Forces dependency on `graphql-config` for schema management, creating config duplication with `soda-gql.config.ts`. The hybrid approach gets the same completion/diagnostics quality by using the interface layer directly, without the server-layer constraints. ### LSP: Full from-scratch implementation Use only `vscode-languageserver` + `graphql-js`, implementing all completion/diagnostics/hover logic from scratch. -**Rejected**: The `graphql-language-service` interface layer provides stateless, battle-tested algorithms for autocomplete suggestions, diagnostics, and hover information. Reimplementing these from scratch would be significant effort with no architectural benefit, since the interface functions have no server lifecycle or config dependencies — they take a schema + query + position and return results. +**Rejected**: The `graphql-language-service` interface layer provides stateless, battle-tested algorithms. Reimplementing from scratch would be significant effort with no architectural benefit. ### TypeScript Language Service Plugin @@ -547,9 +725,7 @@ Build as a TS plugin (like `@0no-co/graphqlsp`) instead of a standalone LSP serv ### Enhanced `.graphqlrc.yaml` with path-based routing -Improve the existing `graphql-config` path-based approach with better DX. - -**Rejected**: Fundamentally implicit — schema association depends on directory structure, not explicit declaration. Moving files silently breaks validation. +**Rejected**: Fundamentally implicit — schema association depends on directory structure, not explicit declaration. ### Comment-based schema directives in `.graphql` files @@ -558,24 +734,7 @@ Improve the existing `graphql-config` path-based approach with better DX. query GetAdmin { ... } ``` -**Rejected**: Comments are not first-class constructs. Easy to forget, no compile-time validation, requires custom tooling anyway. - -### Multiple `graphqlCompat` config entries - -```typescript -graphqlCompat: [ - { input: ["./src/default/**/*.graphql"], schema: "default" }, - { input: ["./src/admin/**/*.graphql"], schema: "admin" }, -] -``` - -**Rejected**: This is the same path-based routing problem as `.graphqlrc.yaml`. Config embeds assumptions about directory structure. - -### Separate file extensions (`.admin.graphql`) - -Encode schema name in file extension. - -**Rejected**: Poor scalability, clutters the file system, requires editor reconfiguration for each new schema. +**Rejected**: Comments are not first-class constructs. Easy to forget, no compile-time validation. ## Open Questions @@ -593,11 +752,15 @@ When a tagged template references a fragment defined in another file: ```typescript // fragments.ts -export const UserFields = gql.default`fragment UserFields on User { id name }`; +export const UserFields = gql.default(({ fragment }) => fragment` + fragment UserFields on User { id name } +`); // queries.ts import { UserFields } from "./fragments"; -const GetUser = gql.default`query GetUser { user { ...UserFields } }`; +const GetUser = gql.default(({ query }) => query` + query GetUser { user { ...UserFields } } +`); ``` How should the LSP resolve `...UserFields`? @@ -623,6 +786,7 @@ When schema files change during development: ### GraphQL ecosystem - [graphql-language-service-server](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service-server) - [graphql-language-service (interface)](https://github.com/graphql/graphiql/tree/main/packages/graphql-language-service) +- [Fragment Arguments RFC (graphql-spec #1081)](https://github.com/graphql/graphql-spec/pull/1081) - [graphql-tag-pluck](https://the-guild.dev/graphql/tools/docs/graphql-tag-pluck) - [@0no-co/GraphQLSP](https://github.com/0no-co/GraphQLSP) - [gql.tada multi-schema mode](https://gql-tada.0no.co/devlog/2024-04-26)