Skip to content
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion packages/cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@
"zod": "^4.1.11"
},
"optionalDependencies": {
"@soda-gql/formatter": "workspace:*"
"@soda-gql/formatter": "workspace:*",
"@soda-gql/lsp": "workspace:*"
}
}
31 changes: 31 additions & 0 deletions packages/cli/src/commands/lsp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
const LSP_HELP = `Usage: soda-gql lsp [options]

Start the GraphQL Language Server Protocol server.

The LSP server communicates over stdio and provides:
- Diagnostics (validation errors in GraphQL templates)
- Autocompletion (field, argument, type suggestions)
- Hover information (type details on hover)

Options:
--help, -h Show this help message

The server is typically started by an editor extension, not directly by users.
Configure your editor to use 'soda-gql lsp' as the GraphQL language server command.`;

export const lspCommand = async (argv: readonly string[]): Promise<never> => {
if (argv.includes("--help") || argv.includes("-h")) {
process.stdout.write(`${LSP_HELP}\n`);
process.exit(0);
}

// Dynamic import to avoid loading LSP deps for other commands
const { createLspServer } = await import("@soda-gql/lsp");
const server = createLspServer();
server.start();

// Server runs indefinitely via stdio; this promise never resolves
await new Promise(() => {});
// TypeScript needs this for the `never` return type
throw new Error("unreachable");
};
7 changes: 7 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { codegenCommand } from "./commands/codegen/index";
import { doctorCommand } from "./commands/doctor";
import { formatCommand } from "./commands/format";
import { initCommand } from "./commands/init";
import { lspCommand } from "./commands/lsp";
import { typegenCommand } from "./commands/typegen";
import { cliErrors } from "./errors";
import type { CommandResult, CommandSuccess, OutputFormat } from "./types";
Expand All @@ -18,6 +19,7 @@ Commands:
format Format soda-gql field selections
artifact Manage soda-gql artifacts
doctor Run diagnostic checks
lsp Start the GraphQL language server

Run 'soda-gql <command> --help' for more information on a specific command.
`;
Expand Down Expand Up @@ -73,6 +75,11 @@ const dispatch = async (argv: readonly string[]): Promise<DispatchResult> => {
return artifactCommand(rest);
}

if (command === "lsp") {
await lspCommand(rest);
return ok({ message: "" }); // unreachable, lsp runs forever
}

if (command === "doctor") {
const result = doctorCommand(rest);
if (result.isOk()) {
Expand Down
1 change: 1 addition & 0 deletions packages/lsp/@x-index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./src/index";
62 changes: 62 additions & 0 deletions packages/lsp/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
{
"name": "@soda-gql/lsp",
"version": "0.11.26",
"description": "GraphQL Language Server Protocol implementation for soda-gql",
"type": "module",
"private": false,
"license": "MIT",
"files": [
"dist",
"index.d.ts",
"index.js"
],
"author": {
"name": "Shota Hatada",
"email": "shota.hatada@whatasoda.me",
"url": "https://github.com/whatasoda"
},
"keywords": [
"graphql",
"lsp",
"language-server",
"soda-gql",
"typescript"
],
"repository": {
"type": "git",
"url": "https://github.com/whatasoda/soda-gql.git",
"directory": "packages/lsp"
},
"homepage": "https://github.com/whatasoda/soda-gql#readme",
"bugs": {
"url": "https://github.com/whatasoda/soda-gql/issues"
},
"engines": {
"node": ">=18"
},
"main": "./dist/index.mjs",
"module": "./dist/index.mjs",
"types": "./dist/index.d.mts",
"exports": {
".": {
"@soda-gql": "./@x-index.ts",
"types": "./dist/index.d.mts",
"import": "./dist/index.mjs",
"require": "./dist/index.cjs",
"default": "./dist/index.mjs"
},
"./package.json": "./package.json"
},
"dependencies": {
"@soda-gql/builder": "workspace:*",
"@soda-gql/codegen": "workspace:*",
"@soda-gql/config": "workspace:*",
"@swc/core": "^1.6.3",
"@swc/types": "^0.1.6",
"graphql": "^16.8.1",
"graphql-language-service": "^5.3.0",
"neverthrow": "^8.1.1",
"vscode-languageserver": "^9.0.1",
"vscode-languageserver-textdocument": "^1.0.11"
}
}
160 changes: 160 additions & 0 deletions packages/lsp/src/document-manager.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { describe, expect, test } from "bun:test";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { createGraphqlSystemIdentifyHelper } from "@soda-gql/builder";
import type { ResolvedSodaGqlConfig } from "@soda-gql/config";
import { createDocumentManager } from "./document-manager";

const fixturesDir = resolve(import.meta.dir, "../test/fixtures");

const createTestConfig = (): ResolvedSodaGqlConfig =>
({
analyzer: "swc" as const,
baseDir: fixturesDir,
outdir: resolve(fixturesDir, "graphql-system"),
graphqlSystemAliases: ["@/graphql-system"],
include: ["**/*.ts"],
exclude: [],
schemas: {
default: {
schema: [resolve(fixturesDir, "schemas/default.graphql")],
inject: { scalars: resolve(fixturesDir, "scalars.ts") },
defaultInputDepth: 3,
inputDepthOverrides: {},
},
admin: {
schema: [resolve(fixturesDir, "schemas/admin.graphql")],
inject: { scalars: resolve(fixturesDir, "scalars.ts") },
defaultInputDepth: 3,
inputDepthOverrides: {},
},
},
styles: { importExtension: false },
codegen: { chunkSize: 100 },
plugins: {},
}) as ResolvedSodaGqlConfig;

const readFixture = (name: string): string => readFileSync(resolve(fixturesDir, name), "utf-8");

describe("createDocumentManager", () => {
const config = createTestConfig();
const helper = createGraphqlSystemIdentifyHelper(config);

test("extracts single query template", () => {
const dm = createDocumentManager(helper);
const source = readFixture("simple-query.ts");
const state = dm.update(resolve(fixturesDir, "simple-query.ts"), 1, source);

expect(state.templates).toHaveLength(1);
const t = state.templates[0]!;
expect(t.schemaName).toBe("default");
expect(t.kind).toBe("query");
expect(t.content).toContain("query GetUser");
expect(t.content).toContain("user(id: $id)");
});

test("extracts multi-schema templates", () => {
const dm = createDocumentManager(helper);
const source = readFixture("multi-schema.ts");
const state = dm.update(resolve(fixturesDir, "multi-schema.ts"), 1, source);

expect(state.templates).toHaveLength(2);
expect(state.templates[0]!.schemaName).toBe("default");
expect(state.templates[0]!.kind).toBe("query");
expect(state.templates[1]!.schemaName).toBe("admin");
expect(state.templates[1]!.kind).toBe("query");
});

test("extracts fragment template", () => {
const dm = createDocumentManager(helper);
const source = readFixture("fragment-with-args.ts");
const state = dm.update(resolve(fixturesDir, "fragment-with-args.ts"), 1, source);

expect(state.templates).toHaveLength(1);
const t = state.templates[0]!;
expect(t.schemaName).toBe("default");
expect(t.kind).toBe("fragment");
expect(t.content).toContain("fragment UserFields");
});

test("handles metadata chaining", () => {
const dm = createDocumentManager(helper);
const source = readFixture("metadata-chaining.ts");
const state = dm.update(resolve(fixturesDir, "metadata-chaining.ts"), 1, source);

expect(state.templates).toHaveLength(1);
const t = state.templates[0]!;
expect(t.schemaName).toBe("default");
expect(t.kind).toBe("query");
expect(t.content).toContain("query GetUser");
});

test("handles block body with return", () => {
const dm = createDocumentManager(helper);
const source = readFixture("block-body.ts");
const state = dm.update(resolve(fixturesDir, "block-body.ts"), 1, source);

expect(state.templates).toHaveLength(1);
const t = state.templates[0]!;
expect(t.schemaName).toBe("default");
expect(t.kind).toBe("query");
expect(t.content).toContain("query GetUser");
});

test("returns empty templates for file without templates", () => {
const dm = createDocumentManager(helper);
const source = readFixture("no-templates.ts");
const state = dm.update(resolve(fixturesDir, "no-templates.ts"), 1, source);

expect(state.templates).toHaveLength(0);
});

test("findTemplateAtOffset returns correct template", () => {
const dm = createDocumentManager(helper);
const source = readFixture("simple-query.ts");
const uri = resolve(fixturesDir, "simple-query.ts");
dm.update(uri, 1, source);

const state = dm.get(uri)!;
const t = state.templates[0]!;

// Offset in the middle of the template content
const midOffset = Math.floor((t.contentRange.start + t.contentRange.end) / 2);
const found = dm.findTemplateAtOffset(uri, midOffset);
expect(found).toBeDefined();
expect(found!.content).toBe(t.content);
});

test("findTemplateAtOffset returns undefined outside template", () => {
const dm = createDocumentManager(helper);
const source = readFixture("simple-query.ts");
const uri = resolve(fixturesDir, "simple-query.ts");
dm.update(uri, 1, source);

// Offset 0 is before any template
const found = dm.findTemplateAtOffset(uri, 0);
expect(found).toBeUndefined();
});

test("contentRange correctly maps back to source", () => {
const dm = createDocumentManager(helper);
const source = readFixture("simple-query.ts");
const uri = resolve(fixturesDir, "simple-query.ts");
const state = dm.update(uri, 1, source);

const t = state.templates[0]!;
const extracted = source.slice(t.contentRange.start, t.contentRange.end);
expect(extracted).toBe(t.content);
});

test("remove clears document state", () => {
const dm = createDocumentManager(helper);
const source = readFixture("simple-query.ts");
const uri = resolve(fixturesDir, "simple-query.ts");
dm.update(uri, 1, source);

expect(dm.get(uri)).toBeDefined();
dm.remove(uri);
expect(dm.get(uri)).toBeUndefined();
});
});
Loading