Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"commit": false,
"access": "public",
"baseBranch": "main",
"ignore": ["example", "fixtures"],
"ignore": ["example", "fixtures", "vscode-graphqlsp"],
"updateInternalDependencies": "minor",
"snapshot": {
"prereleaseTemplate": "{tag}-{commit}",
Expand Down
5 changes: 5 additions & 0 deletions .changeset/global-plugin-dormancy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@0no-co/graphqlsp': minor
---

Support being loaded as a "global" tsserver plugin, as contributed by editor extensions, with project configuration taking precedence over editor settings. An editor-contributed instance now defers to a live project-local instance (including `gql.tada/ts-plugin`, detected through a shared marker on the language service), adopts the project's tsconfig `plugins` entry when the plugin package isn't installed locally, falls back to editor settings passed through `configurePlugin`, and otherwise stays dormant instead of reporting configuration errors in projects that never set up GraphQLSP.
4 changes: 3 additions & 1 deletion .github/scripts/has-unpublished-packages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { appendFileSync, existsSync } from 'node:fs';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';

const ignoredDirectories = new Set(['.git', 'dist', 'node_modules']);
// .staging is the vsix staging area of the VSCode extension; it contains a
// copy of the extension's manifest with the `private` flag stripped
const ignoredDirectories = new Set(['.git', 'dist', 'node_modules', '.staging']);
const workspaceRoot = process.cwd();
const hasWorkspaceManifest = existsSync(path.join(workspaceRoot, 'pnpm-workspace.yaml'));

Expand Down
5 changes: 4 additions & 1 deletion .github/scripts/stage-packages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ function packageJsonPathsFromWorkspace() {
entry.name === "node_modules" ||
entry.name === ".pnpm" ||
entry.name === "dist" ||
entry.name === "coverage"
entry.name === "coverage" ||
// vsix staging area of the VSCode extension; it contains a copy of
// the extension's manifest with the `private` flag stripped
entry.name === ".staging"
) {
continue;
}
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -108,3 +108,7 @@ dist

packages/graphqlsp/api/*
packages/graphqlsp/api

# VSCode extension packaging
packages/vscode-graphqlsp/.staging
*.vsix
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ auto-complete.
- Find references and rename for GraphQL fragments, across your project's files
- Will warn you when you are importing from a file that is exporting fragments that you're not using
- An "Extract to fragment" refactor that moves selected fields into a new co-located fragment (in `graphql()` call-expression mode)
- The VS Code extension adds GraphQL operations and fragments to the Outline view and symbol navigation

> Note that this plugin does not do syntax highlighting, for that you still need something like
> [the VSCode/... plugin](https://marketplace.visualstudio.com/items?itemName=GraphQL.vscode-graphql-syntax)
Expand Down
108 changes: 108 additions & 0 deletions packages/graphqlsp/src/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,108 @@
import type { SchemaOrigin } from '@gql.tada/internal';

import { ts } from './ts';

export interface Config {
schema: SchemaOrigin;
schemas: SchemaOrigin[];
tadaDisablePreprocessing?: boolean;
templateIsCallExpression?: boolean;
shouldCheckForColocatedFragments?: boolean;
template?: string;
clientDirectives?: string[];
trackFieldUsage?: boolean;
tadaOutputLocation?: string;
/** Set by tsserver on the synthetic config entries of "global" plugins,
* i.e. plugins contributed by editor extensions, that received no
* configuration overrides. */
global?: boolean;
/** Set by editor extensions on the configuration they pass through
* tsserver's `configurePlugin`, which replaces the synthetic entry
* carrying `global` above. */
editorContributed?: boolean;
}

/** Names GraphQLSP ships under in tsconfig "plugins" entries. */
const PLUGIN_NAMES = new Set(['@0no-co/graphqlsp', 'gql.tada/ts-plugin']);

const getPackageName = (pluginName: string): string => {
const parts = pluginName.split('/');
return pluginName.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]!;
};

/** Checks whether a tsconfig plugin can be resolved from the project's local
* node_modules tree without loading it. Older GraphQLSP releases don't expose
* the shared instance marker, so package presence is the only side-effect-free
* way for an editor-contributed copy to defer to them before they initialize. */
const hasLocalPluginPackage = (
info: ts.server.PluginCreateInfo,
pluginName: string
): boolean => {
const packageName = getPackageName(pluginName);
let directory = info.project.getCurrentDirectory();

while (true) {
const manifest = ts.combinePaths(
directory,
'node_modules',
packageName,
'package.json'
);
if (info.project.fileExists(manifest)) return true;

const parent = ts.getDirectoryPath(directory);
if (parent === directory) return false;
directory = parent;
}
};

/** Resolves the configuration this instance should run with, or `null` to
* stay dormant.
*
* A project-local instance (configured through a tsconfig "plugins" entry)
* always runs with its entry as-is. For an editor-contributed ("global")
* instance the project's configuration wins over editor settings:
* - a live local instance already handles the project → stay dormant,
* - an installed older local instance has no marker → stay dormant,
* - a tsconfig entry whose package is unavailable → adopt its configuration,
* - editor settings passed through `configurePlugin` → use them,
* - no configuration anywhere → stay dormant, so unrelated projects don't
* get "missing schema" configuration errors. */
export function resolveConfig(
info: ts.server.PluginCreateInfo,
logger: (message: string) => void,
instanceMarker: symbol
): Config | null {
const config: Config = info.config;
if (!config.global && !config.editorContributed) return config;

if ((info.languageService as any)[instanceMarker]) {
logger('The project already has a GraphQLSP instance; deferring to it');
return null;
}

const plugins = (info.project.getCompilerOptions().plugins || []) as Array<
ts.PluginImport & Partial<Config>
>;
const localEntry = plugins.find(entry => PLUGIN_NAMES.has(entry.name));
if (localEntry) {
if (hasLocalPluginPackage(info, localEntry.name)) {
logger(
`The project has a local "${localEntry.name}" installation; deferring to it`
);
return null;
}

logger(
`Adopting the project's "${localEntry.name}" tsconfig configuration because its local package is unavailable`
);
return localEntry as Config;
}

if (config.schema !== undefined || config.schemas !== undefined) {
return config;
}

logger('Loaded as a global plugin without configuration; skipping setup');
return null;
}
40 changes: 25 additions & 15 deletions packages/graphqlsp/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import type { SchemaOrigin } from '@gql.tada/internal';

import { ts, init as initTypeScript } from './ts';
import { resolveConfig } from './config';
import { loadSchema } from './graphql/getSchema';
import { getGraphQLCompletions } from './autoComplete';
import { getGraphQLQuickInfo } from './quickInfo';
Expand All @@ -23,6 +22,14 @@ import { templates } from './ast/templates';
import { getPersistedCodeFixAtPosition } from './persisted';
import { canExtractFragment, getExtractFragmentEdits } from './extractFragment';

/** Marks the language service proxies of active GraphQLSP instances.
*
* `Symbol.for` uses the shared symbol registry, so the marker survives
* multiple module copies of the plugin being loaded side by side — e.g. a
* project's own `gql.tada/ts-plugin` and a copy bundled with an editor
* extension. */
const instanceMarker = Symbol.for('@0no-co/graphqlsp');

function createBasicDecorator(info: ts.server.PluginCreateInfo) {
const proxy: ts.LanguageService = Object.create(null);
for (let k of Object.keys(info.languageService) as Array<
Expand All @@ -33,27 +40,27 @@ function createBasicDecorator(info: ts.server.PluginCreateInfo) {
proxy[k] = (...args: Array<{}>) => x.apply(info.languageService, args);
}

// Keep the active-instance marker of a wrapped GraphQLSP proxy visible to
// any plugin instance loaded on top of this one
if ((info.languageService as any)[instanceMarker]) {
(proxy as any)[instanceMarker] = true;
}

return proxy;
}

export type Logger = (msg: string) => void;

interface Config {
schema: SchemaOrigin;
schemas: SchemaOrigin[];
tadaDisablePreprocessing?: boolean;
templateIsCallExpression?: boolean;
shouldCheckForColocatedFragments?: boolean;
template?: string;
clientDirectives?: string[];
trackFieldUsage?: boolean;
tadaOutputLocation?: string;
}

function create(info: ts.server.PluginCreateInfo) {
const logger: Logger = (msg: string) =>
info.project.projectService.logger.info(`[GraphQLSP] ${msg}`);
const config: Config = info.config;

const config = resolveConfig(info, logger, instanceMarker);
if (!config) return createBasicDecorator(info);

// Everything downstream (diagnostics, completions, schema loading) reads
// `info.config` directly, so an adopted configuration has to land there
info.config = config;

logger('config: ' + JSON.stringify(config));

Expand All @@ -64,6 +71,9 @@ function create(info: ts.server.PluginCreateInfo) {
}

const proxy = createBasicDecorator(info);
// Marks this project as handled, keeping an editor-contributed instance
// loaded on top of this one dormant
(proxy as any)[instanceMarker] = true;

const schema = loadSchema(info, logger);

Expand Down
12 changes: 12 additions & 0 deletions packages/vscode-graphqlsp/.vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Run Extension",
"type": "extensionHost",
"request": "launch",
"args": ["--extensionDevelopmentPath=${workspaceFolder}"],
"outFiles": ["${workspaceFolder}/dist/**/*.js"]
}
]
}
8 changes: 8 additions & 0 deletions packages/vscode-graphqlsp/.vscodeignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
src/**
scripts/**
tsconfig.json
*.tsbuildinfo
**/*.map
.staging/**
*.vsix
*.tgz
21 changes: 21 additions & 0 deletions packages/vscode-graphqlsp/LICENSE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
# MIT License

Copyright (c) 2026 0no.co

Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
88 changes: 88 additions & 0 deletions packages/vscode-graphqlsp/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
# GraphQLSP for VSCode

VSCode extension for [GraphQLSP](https://github.com/0no-co/GraphQLSP), bringing schema-aware
GraphQL support to TypeScript and JavaScript:

- **TypeScript server plugin** — contributes `@0no-co/graphqlsp` to VSCode's built-in
TypeScript language features, so you get diagnostics, auto-completion, hover information,
and go-to-definition for GraphQL documents without installing the plugin per project.
- **Syntax highlighting** for `.graphql`, `.gql`, and `.graphqls` files.
- **Document symbols** for embedded GraphQL operations and fragments in the Outline view
and “Go to Symbol in Editor…” (`Cmd/Ctrl+Shift+O`).
- **Inline syntax highlighting** for GraphQL documents in TypeScript/JavaScript:
`` gql`...` `` and `` graphql`...` `` tagged templates, ``graphql(`...`)`` call
expressions (the [gql.tada](https://gql-tada.0no.co) style), and untagged template
literals starting with a `#graphql` comment.

## Setup

The recommended way to configure GraphQLSP is in your project's `tsconfig.json`, which keeps
the configuration shared with your whole team and CI:

```jsonc
{
"compilerOptions": {
"plugins": [
{
"name": "@0no-co/graphqlsp",
"schema": "./schema.graphql",
},
],
},
}
```

When a project lists the plugin in its `tsconfig.json` — as `@0no-co/graphqlsp` or as
gql.tada's `gql.tada/ts-plugin` — that configuration always wins, and the project doesn't
need the plugin in its own `node_modules`: the extension defers to a running project-local
instance, and otherwise adopts the tsconfig entry's configuration using its bundled copy.

Alternatively — e.g. for projects whose tsconfig you don't control — the plugin can be
configured through VSCode settings:

```jsonc
{
"graphqlsp.schema": "./schema.graphql",
"graphqlsp.templateIsCallExpression": false,
}
```

The settings mirror the plugin's options (`graphqlsp.schema`, `graphqlsp.schemas`,
`graphqlsp.template`, `graphqlsp.templateIsCallExpression`,
`graphqlsp.shouldCheckForColocatedFragments`, `graphqlsp.trackFieldUsage`,
`graphqlsp.clientDirectives`, `graphqlsp.tadaOutputLocation`,
`graphqlsp.tadaDisablePreprocessing`); see the
[GraphQLSP README](https://github.com/0no-co/GraphQLSP#readme) for what they do. VSCode
settings only apply to projects that _don't_ configure the plugin in their `tsconfig.json` —
per project, the plugin uses the first of: the project's tsconfig entry, these editor
settings, or nothing (dormant). Settings changes require a TypeScript server restart (the
extension offers one when settings change).

Without any configuration in either place, the language service plugin stays dormant and
only the syntax highlighting is active.

> **Note:** If you use a TypeScript version installed in your workspace, run the
> **“TypeScript: Select TypeScript Version…”** command and ensure the workspace version is
> used; the plugin is enabled for both the bundled and workspace versions.

## Development

```sh
pnpm install
pnpm --filter vscode-graphqlsp build
```

Open `packages/vscode-graphqlsp` in VSCode and press F5 (“Run Extension”) to launch an
Extension Development Host. To inspect plugin logs, run the
**“TypeScript: Open TS Server log”** command in the development host and search for
`[GraphQLSP]`.

To build an installable `.vsix`:

```sh
pnpm --filter vscode-graphqlsp package
```

This stages the extension together with an npm-installed copy of the workspace's
`@0no-co/graphqlsp` (pnpm's symlinked layout can't be packaged directly) and runs
`vsce package` on it.
24 changes: 24 additions & 0 deletions packages/vscode-graphqlsp/language-configuration.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"comments": {
"lineComment": "#"
},
"brackets": [
["{", "}"],
["[", "]"],
["(", ")"]
],
"autoClosingPairs": [
{ "open": "{", "close": "}" },
{ "open": "[", "close": "]" },
{ "open": "(", "close": ")" },
{ "open": "\"\"\"", "close": "\"\"\"", "notIn": ["string"] },
{ "open": "\"", "close": "\"", "notIn": ["string"] }
],
"surroundingPairs": [
["{", "}"],
["[", "]"],
["(", ")"],
["\"", "\""]
],
"wordPattern": "[_A-Za-z][_0-9A-Za-z]*"
}
Loading