From 406525c4e9b04ca6c8b2d4d1c8500227c5245258 Mon Sep 17 00:00:00 2001 From: jdecroock Date: Wed, 28 Oct 2026 19:39:00 +0100 Subject: [PATCH 1/2] feat: add TypeScript 7 editor features and LSP proxy --- .changeset/ts7-language-features.md | 9 + .gitattributes | 1 + .github/workflows/package-vscode.yaml | 34 + .gitignore | 3 + README.md | 330 ++++++++- docs/typescript-7-development.md | 174 +++++ packages/graphqlsp/README.md | 324 +++++++- packages/graphqlsp/bin/graphqlsp.cjs | 8 + packages/graphqlsp/package.json | 11 +- packages/graphqlsp/src/autoComplete.ts | 3 +- packages/graphqlsp/src/check.ts | 1 + packages/graphqlsp/src/codeFixes.ts | 5 +- packages/graphqlsp/src/config.ts | 7 +- packages/graphqlsp/src/definition.ts | 25 +- packages/graphqlsp/src/diagnostics.ts | 12 +- packages/graphqlsp/src/extractFragment.ts | 10 +- packages/graphqlsp/src/graphql/getSchema.ts | 81 +- packages/graphqlsp/src/host.ts | 1 + packages/graphqlsp/src/languageFeatures.ts | 255 +++++++ packages/graphqlsp/src/lsp.ts | 690 ++++++++++++++++++ packages/graphqlsp/src/lspProtocol.ts | 55 ++ packages/graphqlsp/src/quickInfo.ts | 3 +- packages/graphqlsp/src/references.ts | 28 +- packages/graphqlsp/src/session.ts | 191 +++-- packages/graphqlsp/src/ts7/namespace.ts | 20 + packages/graphqlsp/src/ts7/relay.ts | 68 +- packages/graphqlsp/src/ts7/windowsRelay.ts | 89 +++ packages/vscode-graphqlsp/README.md | 138 +++- packages/vscode-graphqlsp/package.json | 2 +- packages/vscode-graphqlsp/scripts/package.mjs | 28 +- .../src/typescript7/editVersions.ts | 41 ++ .../src/typescript7/features.ts | 211 ++++++ .../vscode-graphqlsp/src/typescript7/index.ts | 148 +++- .../src/typescript7/nativeMode.ts | 39 + .../src/typescript7/server.ts | 65 +- scripts/dogfood-vscode.mjs | 149 ++++ test/e2e/extract-fragment.test.ts | 2 +- test/e2e/lsp-proxy.test.ts | 262 +++++++ test/e2e/lsp-shutdown.test.ts | 134 ++++ test/e2e/ts7-content-mapper.test.ts | 2 +- test/e2e/ts7-session.test.ts | 294 +++++++- test/e2e/ts7.test.ts | 14 +- test/unit/editVersions.test.ts | 45 ++ test/unit/lspProtocol.test.ts | 56 ++ test/unit/nativeMode.test.ts | 29 + test/unit/schemaRecovery.test.ts | 67 ++ test/unit/vscode-ts7-features.test.ts | 224 ++++++ test/vscode/extension/extension.cjs | 1 + test/vscode/extension/package.json | 12 + test/vscode/extension/tests.cjs | 488 +++++++++++++ vite.config.ts | 12 + 51 files changed, 4689 insertions(+), 212 deletions(-) create mode 100644 .changeset/ts7-language-features.md create mode 100644 .github/workflows/package-vscode.yaml create mode 100644 docs/typescript-7-development.md create mode 100644 packages/graphqlsp/bin/graphqlsp.cjs create mode 100644 packages/graphqlsp/src/languageFeatures.ts create mode 100644 packages/graphqlsp/src/lsp.ts create mode 100644 packages/graphqlsp/src/lspProtocol.ts create mode 100644 packages/graphqlsp/src/ts7/windowsRelay.ts create mode 100644 packages/vscode-graphqlsp/src/typescript7/editVersions.ts create mode 100644 packages/vscode-graphqlsp/src/typescript7/features.ts create mode 100644 packages/vscode-graphqlsp/src/typescript7/nativeMode.ts create mode 100644 scripts/dogfood-vscode.mjs create mode 100644 test/e2e/lsp-proxy.test.ts create mode 100644 test/e2e/lsp-shutdown.test.ts create mode 100644 test/unit/editVersions.test.ts create mode 100644 test/unit/lspProtocol.test.ts create mode 100644 test/unit/nativeMode.test.ts create mode 100644 test/unit/schemaRecovery.test.ts create mode 100644 test/unit/vscode-ts7-features.test.ts create mode 100644 test/vscode/extension/extension.cjs create mode 100644 test/vscode/extension/package.json create mode 100644 test/vscode/extension/tests.cjs create mode 100644 vite.config.ts diff --git a/.changeset/ts7-language-features.md b/.changeset/ts7-language-features.md new file mode 100644 index 00000000..a1df9612 --- /dev/null +++ b/.changeset/ts7-language-features.md @@ -0,0 +1,9 @@ +--- +'@0no-co/graphqlsp': minor +--- + +Expose hover, completion, schema definitions, fragment references and rename, quick fixes, and fragment extraction through TypeScript 7.1 language-server API sessions. Add the `graphqlsp --stdio` LSP proxy for other editors, and support the synchronous API relay on Windows using .NET Framework 4.x. + +Preserve native TypeScript capabilities, keep GraphQL completion resolution local, reject stale cross-file edits, recover local schemas after deletion/recreation, and await named schemas before the first feature request. The VS Code integration also disposes native providers when switching back to classic TypeScript or resetting native enablement. + +Drain API workers during shutdown and bound the native TS 7.1 cleanup hang with a documented two-second fallback that terminates the native child before acknowledging proxy shutdown. Document npm-only LSP setup, VS Code activation, standalone checks, session methods, configuration boundaries, platform requirements, and reproducible editor validation. diff --git a/.gitattributes b/.gitattributes index 57bd243f..6c06c202 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,3 +1,4 @@ * text=auto **/*.generated.ts linguist-generated **/*.graphql linguist-generated +test/** text eol=lf diff --git a/.github/workflows/package-vscode.yaml b/.github/workflows/package-vscode.yaml new file mode 100644 index 00000000..59b9e41a --- /dev/null +++ b/.github/workflows/package-vscode.yaml @@ -0,0 +1,34 @@ +name: Package editor integration +on: + workflow_dispatch: + pull_request: + push: + branches: [main] +permissions: + contents: read +jobs: + editor: + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + steps: + - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 + - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: 22 + - uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8 + with: + version: 11.3.0 + - run: pnpm install --frozen-lockfile + - run: pnpm build + - run: pnpm exec vitest run test/e2e/ts7-session.test.ts test/e2e/lsp-proxy.test.ts test/e2e/lsp-shutdown.test.ts test/unit/lspProtocol.test.ts test/unit/schemaRecovery.test.ts test/unit/editVersions.test.ts test/unit/nativeMode.test.ts test/unit/vscode-ts7-features.test.ts --no-file-parallelism + env: + GRAPHQLSP_TS7_COMPILER: typescript + - run: pnpm --filter vscode-graphqlsp package + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: graphqlsp-editor-${{ runner.os }} + path: packages/vscode-graphqlsp/*.vsix diff --git a/.gitignore b/.gitignore index 8ac8016c..93fa22a3 100644 --- a/.gitignore +++ b/.gitignore @@ -109,7 +109,10 @@ dist packages/graphqlsp/api/* packages/graphqlsp/api packages/graphqlsp/check +packages/graphqlsp/lsp # VSCode extension packaging packages/vscode-graphqlsp/.staging *.vsix + +.tmp/ diff --git a/README.md b/README.md index d2e7bbf7..198413f6 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,9 @@ # GraphQLSP -This is a TypeScript LSP Plugin that will recognise documents in your -TypeScript code and help you out with hover-information, diagnostics and -auto-complete. +GraphQLSP provides GraphQL diagnostics, hover information, completion, and +navigation for documents embedded in JavaScript and TypeScript. It runs as a +TypeScript 5/6 language service plugin, or through the TypeScript 7.1+ native +language-server API. ## Features @@ -26,7 +27,7 @@ npm install -D @0no-co/graphqlsp ## Usage -Go to your `tsconfig.json` and add +For all integrations, add the GraphQLSP configuration to your `tsconfig.json`: ```json { @@ -41,7 +42,10 @@ Go to your `tsconfig.json` and add } ``` -now restart your TS-server and you should be good to go, ensure you are using the +For TypeScript 7, continue with [TypeScript 7 editor integration](#typescript-7-editor-integration); +the tsconfig entry alone does not activate GraphQLSP. + +For TypeScript 5/6, restart your TS server and ensure you are using the workspace version of TypeScript. In VSCode you can do so by clicking the bottom right when on a TypeScript file or adding a file like [this](https://github.com/0no-co/GraphQLSP/blob/main/packages/example/.vscode/settings.json). @@ -57,11 +61,26 @@ when on a TypeScript file or adding a file like [this](https://github.com/0no-co ### Configuration -**Required** +**Schema source (choose one)** - `schema` allows you to specify a url, `.json` or `.graphql` file as your schema. If you need to specify headers for your introspection you can opt into the object notation i.e. `{ "schema": { "url": "x", "headers": { "Authorization": "y" } }}` +- `schemas` accepts multiple named schemas instead of a single `schema`, for example: + + ```json + { + "name": "@0no-co/graphqlsp", + "schemas": [ + { "name": "pokemon", "schema": "./pokemon.graphql" }, + { "name": "accounts", "schema": "./accounts.graphql" } + ] + } + ``` + + Use the schema name configured in your gql.tada client to select its schema. + Each schema can also configure its own `tadaOutputLocation`. + **Optional** - `template` add an additional template to the defaults `gql` and `graphql` @@ -78,18 +97,21 @@ when on a TypeScript file or adding a file like [this](https://github.com/0no-co ## Checking projects with TypeScript 7 -TypeScript 7 doesn't load language service plugins, so GraphQLSP's diagnostics -can't run inside `tsc` or the TypeScript 7 language server. Instead, -`checkProject` reports them for a whole project, using the TypeScript 7.1 API -(`typescript@>=7.1`, or [`tsrs`](https://github.com/oxc-project/tsrs)). With -TypeScript 5/6 installed, it uses the TypeScript compiler API instead. +TypeScript 7 does not load language service plugins. Adding a plugin to +`tsconfig.json` does not make `tsc` report GraphQLSP diagnostics or make the +native language server load GraphQLSP. Use `checkProject` for a standalone +check, and the [editor integration](#typescript-7-editor-integration) for live +editor features. The classic TypeScript 5/6 plugin remains supported. -```ts +`checkProject` uses the TypeScript 7.1 API, or the TypeScript 5/6 compiler API. +It resolves the compiler from the checked project's directory by default: + +```js +// Save as check-graphql.mjs and run: node check-graphql.mjs import { checkProject } from '@0no-co/graphqlsp/check'; const { diagnostics } = await checkProject({ tsconfigPath: './tsconfig.json', - // 'typescript' (default) or 'tsrs', resolved from the tsconfig.json's directory compiler: 'typescript', }); @@ -99,28 +121,50 @@ for (const diagnostic of diagnostics) { `${diagnostic.severity} (${diagnostic.code}): ${diagnostic.message}` ); } +process.exitCode = diagnostics.some(item => item.severity === 'error') ? 1 : 0; ``` -The plugin entry is read from `compilerOptions.plugins` in your `tsconfig.json` -(following `extends`), like the TypeScript plugin's configuration. Schemas are -loaded once, without watching them, and gql.tada's `tadaOutputLocation` typings -aren't written. - -If your project uses [content mappers](https://devblogs.microsoft.com/typescript/announcing-typescript-7-1-beta/) -(`contentMappers` in your `tsconfig.json`), e.g. to import documents from `.graphql` -files with `@gql.tada/content-mapper`, pass `runExternalCode: true`. Like -`tsc --runExternalCode`, this runs the mappers, so documents imported from mapped -files can be resolved, e.g. by `graphql.persisted()`. Mapped files themselves aren't -checked, since their mappers report their diagnostics. tsrs's API doesn't run -content mappers yet. - -Editor integrations can use `createLanguageServerSession` instead, which checks -files in a running TypeScript 7.1 language server's projects, including unsaved -changes, through an API session of the language server (e.g. from the -TypeScript 7 VS Code extension's `TypeScriptSDK.initializeAPIConnection()`, or -its `custom/initializeAPISession` request). Its API is synchronous, so it -should run in its own process or worker. The GraphQLSP VS Code extension uses -it to report diagnostics while TypeScript 7's language server runs. +Run your normal TypeScript check separately; this function returns GraphQLSP +diagnostics, not TypeScript's compiler diagnostics. It does not change the exit +status of a separate `tsc` process. + +| Option | Meaning | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `tsconfigPath` | Required path of the project to check. | +| `compiler` | `'typescript'` (default) or `'tsrs'`. | +| `compilerPath` | Optional compiler package directory or `package.json`, useful for an aliased TypeScript installation. | +| `files` | Optional list of files; otherwise checks the project's non-declaration JS/TS files outside `node_modules`. Relative paths use the process working directory. | +| `logger` | Optional callback for GraphQLSP log messages. | +| `runExternalCode` | Opt into running the project's content mappers; defaults to `false`. | + +The result contains `compiler` (name and version), `tsconfigPath`, `files`, and +`diagnostics`. Diagnostic file paths are absolute; line and column are one-based. +Each diagnostic also has a source span (`start` and `length`), `code`, +`severity`, and `message`. + +Configuration comes from `compilerOptions.plugins`, including entries found +through `extends`. Relative schema and output paths resolve against the config +declaring the entry. Schemas load once per check; they are not watched, and +`tadaOutputLocation` typings are not written. + +For projects with `contentMappers` in their tsconfig, pass +`runExternalCode: true` to resolve imports from mapped files, such as +`.graphql` files used by `@gql.tada/content-mapper`. Mappers run external +processes, as with `tsc --runExternalCode`. GraphQLSP checks the JS/TS consumers; +the mappers report diagnostics for the mapped files themselves. The tsrs API +does not run content mappers yet, and its current package does not provide a +Windows binary. The native TypeScript path is the locally verified Windows path. + +### Connecting to an existing language server + +Custom integrations can use `createLanguageServerSession` with an API pipe +from a running TypeScript 7.1+ language server. The compiler package must match +the installation running that server. A pipe can come from the TypeScript +extension's `sdk.initializeAPIConnection()`, or the `pipe` field returned by +the server's `custom/initializeAPISession` request. + +The following belongs in your integration's worker; `sdk`, `fileName`, and +`currentText` are supplied by the integration. `fileName` is an absolute path. ```ts import { createLanguageServerSession } from '@0no-co/graphqlsp/check'; @@ -128,11 +172,222 @@ import { createLanguageServerSession } from '@0no-co/graphqlsp/check'; const session = await createLanguageServerSession({ packageJsonPath: sdk.packageJsonPath, pipe: await sdk.initializeAPIConnection(), + persist: false, // Optional: do not write gql.tada typings. }); -// `text` is the file's text the diagnostics were computed for -const { text, diagnostics } = (await session.check(fileName)) ?? {}; +try { + const checked = await session.check(fileName); + if (checked && checked.text === currentText) { + // Publish checked.diagnostics to your editor. + } + const hover = await session.hover(fileName, { line: 0, character: 0 }); + // Use hover.result only if hover.text still matches the current document. +} finally { + session.dispose(); +} +``` + +The checker API blocks synchronously while it talks to TypeScript, even though +the session methods return promises. Run it in a worker or child process to +keep the editor or LSP forwarding loop responsive. Calls are serialized within +a session. Await outstanding calls before disposal, and create a new session +after the underlying server restarts. + +| Session call | Result | +| ----------------------------------- | --------------------------------------------------------- | +| `check(fileName)` | `{ text, diagnostics }`. | +| `hover(fileName, position)` | `{ text, result }`, with an LSP Hover or `null`. | +| `complete(fileName, position)` | `{ text, result }`, with an LSP CompletionList or `null`. | +| `request(method, fileName, params)` | `{ text, result }`, using the LSP shape for that method. | +| `dispose()` | Closes the API connection and schema watchers. | + +Supported `request` methods are `textDocument/hover`, +`textDocument/completion`, `textDocument/definition`, +`textDocument/references`, `textDocument/prepareRename`, +`textDocument/rename`, and `textDocument/codeAction`. For example: + +```ts +const renamed = await session.request('textDocument/rename', fileName, { + position: { line: 3, character: 12 }, + newName: 'PokemonFields', +}); +``` + +Pass LSP `position` for navigation and references, `newName` for rename, +and `range` plus `context` for code actions. Reference context can include +`includeDeclaration`; code-action context can include `diagnostics` and +`only` (`quickfix` or `refactor.extract`). For quick fixes, pass diagnostics with +`source: 'graphqlsp'` and the diagnostic `code`. Positions and ranges are zero-based +and use UTF-16 character offsets. + +Session calls return `null` for files outside configured projects. Feature +payloads use `result: null` outside supported GraphQL contexts. Compare `text` +to the current document before publishing results. For cross-file edits, also +capture and validate every affected open document's identity and version; +the requesting file's `text` alone cannot detect a changed referenced file. + +Sessions write configured gql.tada typings by default; pass `persist: false` +to disable this. `logger` receives messages, and `onSchemaChange` tells the +integration to recheck unchanged open files after local schemas reload. + +## TypeScript 7 editor integration + +TypeScript **7.1 or newer** is required for the API used here; 7.0 is not +supported. Keep the GraphQLSP entry in `compilerOptions.plugins` from +[Usage](#usage), including its schema settings. GraphQLSP reads this as +configuration; the native TypeScript server does not load the classic plugin. + +| Environment | How to activate GraphQLSP | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| TypeScript 5/6 | Install the npm plugin and configure tsconfig; use the workspace TypeScript version in your editor. | +| TypeScript 7.1+ in VS Code | Install the GraphQLSP extension and a compatible TypeScript native extension, then enable native TypeScript. | +| TypeScript 7.1+ with a configurable LSP client | Install the npm package and launch the bundled LSP proxy as your TypeScript server. No VSIX is required. | +| CI or scripts | Call `checkProject` separately from your TypeScript compiler check. | + +Both TS 7 editor paths provide live diagnostics for open JS/TS files, hover, +completion, schema-field definitions, fragment references and rename, quick +fixes, and the **Extract to fragment** refactor in call-expression mode. They +use the native server's project snapshots, including unsaved edits. + +### Without a VSIX: npm and an LSP client + +Install a build of `@0no-co/graphqlsp` containing the `graphqlsp` executable +and TypeScript 7.1+. For this local implementation, install the supplied +tarball rather than assuming a registry release includes these changes: + +```sh +# Replace the path with the downloaded/local package path. +npm install -D "/absolute/path/to/graphqlsp-ts7-local.tgz" +# The prerelease used for local validation: +npm install -D typescript@7.1.0-beta ``` +Keep your existing `tsconfig.json` plugin entry, and set the LSP client's +working directory to your project root. Configure its executable and arguments +as follows (the field names depend on the editor): + +```json +{ + "command": "node", + "args": ["./node_modules/@0no-co/graphqlsp/bin/graphqlsp.cjs", "--stdio"] +} +``` + +This direct Node command also works on Windows clients that cannot launch +`.cmd` shims. The equivalent executable, when `node_modules/.bin` is on the +client's PATH, is `graphqlsp --stdio`. + +Attach this server to the editor's JavaScript, JavaScript React, TypeScript, +and TypeScript React language IDs (JS/JSX/TS/TSX files). It replaces your +direct `tsc --lsp --stdio` server command: the proxy starts and forwards to +the native TypeScript server itself. Disable a duplicate TypeScript LSP +attachment for those same buffers to avoid duplicate diagnostics and edits. +Use the normal LSP initialize/open/change/close/shutdown lifecycle; simply +running the command in a terminal waits for LSP input. + +By default the proxy resolves `typescript/package.json` from its working +directory. When the editor uses a different directory, a monorepo root, or +an aliased compiler package, use absolute paths for the proxy script and +`--typescript`: + +```sh +node /absolute/path/to/node_modules/@0no-co/graphqlsp/bin/graphqlsp.cjs --stdio --typescript /absolute/path/to/node_modules/typescript +``` + +| Proxy option | Meaning | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `--stdio` | Use the bundled stdio LSP entry point. stdout carries only LSP messages; logs go to stderr. | +| `--typescript ` | Select the native TypeScript package directory or its `package.json`; defaults to the project-local `typescript` package. | +| `--runExternalCode` | Allow the native server to run tsconfig content mappers. Omitted by default. | +| `--no-persist` | Disable writes of configured gql.tada introspection typings. Writes are enabled by default. | +| `--help` | Print command usage and exit. | + +**Can VS Code discover the npm package automatically, like the old plugin?** +With TS 7, npm installation plus a tsconfig entry is not sufficient. Use the +GraphQLSP extension or a VS Code LSP client configured to launch the proxy. +The built-in TypeScript extension does not launch this executable from the +plugin entry. The npm-only automatic plugin setup remains available for TS 5/6. + +### VS Code extension setup + +1. Install the GraphQLSP VSIX with **Extensions: Install from VSIX…** and a + compatible TypeScript extension, such as + [TypeScript Native Preview](https://marketplace.visualstudio.com/items?itemName=TypeScriptTeam.native-preview). +2. Point the TypeScript extension at your TypeScript 7.1+ installation and + enable native TypeScript. The locally tested settings are: + + ```json + { + "js/ts.experimental.useTsgo": true, + "js/ts.tsdk.path": "./node_modules/typescript" + } + ``` + +3. Add the GraphQLSP plugin entry and schema to the project's tsconfig, then + restart the TypeScript server and open a JS/TS file belonging to that project. + +The VSIX bundles GraphQLSP, so this path does not require its npm package in +every project. The project still needs its GraphQL dependencies. GraphQLSP +connects to the native server started by the TypeScript extension; do not also +configure the proxy for the same files. + +GraphQLSP follows the TypeScript extension's native-mode settings, including +the deprecated `typescript.experimental.useTsgo` alias and scope precedence. +Switching back to classic TypeScript, or resetting an explicit enablement, +disposes its native diagnostics and providers. Returning to native mode or +restarting the server creates a fresh API session. + +### Configuration, platforms, and limits + +- Configure TS 7 projects through `compilerOptions.plugins`; VS Code + `graphqlsp.*` settings apply only to the TS 5/6 plugin path. Existing + `@0no-co/graphqlsp` and `gql.tada/ts-plugin` entries are recognized. +- Configuration inherited through `extends` is supported. Relative schema + paths resolve from the config that declares the entry, not the editor's + working directory. Restart the server after changing plugin settings or + schema paths. +- Local schema contents are watched, including deletion and recreation. + Schema changes recheck open files; a failed load does not retain the old + schema as if it were current. Named schemas are loaded before the first + feature request. An unsaved schema edit must be saved to reach the file watcher. +- Remote URL schemas load once per TS 7 session; restart the server to refresh + them. Automatic file watching applies to local schemas. +- The VS Code extension requires a trusted workspace and is disabled in + Restricted Mode. +- The proxy and extension check embedded GraphQL in JS/TS. Standalone + `.graphql` files need their own GraphQL language server for language + features; content mappers can expose their imports to JS/TS consumers. + Syntax highlighting and document symbols are extension features, not + features supplied by the npm proxy. +- The TS 7 path does not reproduce navigation from generated gql.tada + turbo-cache result types back into queries. Ordinary native TypeScript + navigation remains available at those positions. +- On Windows, the sync API relay requires the installed **.NET Framework 4.x + C# compiler**. It builds a small included launcher locally; no .NET SDK or + download is needed. Unix uses a shell launcher. The relay is bypassed when + TypeScript supports synchronous pipe connections directly. +- TypeScript 7.1 can hang during native API-session cleanup. The proxy drains + its GraphQL worker first and requests clean native shutdown. If the native + child does not reply within two seconds, the proxy terminates that child + and acknowledges shutdown, then waits for the client's `exit`. This is a + bounded workaround; it does not fix the native server's cleanup race. + +### Troubleshooting + +| Symptom | Check | +| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| npm package and tsconfig entry have no effect in TS 7 | Start the proxy through an LSP client, or install and enable the VS Code integration. | +| Proxy reports that TypeScript 7.1+ is required | Verify the package selected from the working directory, or pass `--typescript` explicitly. | +| Proxy cannot resolve `typescript/package.json` | Use the project root as its working directory and install the compiler there, or select its absolute package path. | +| No GraphQL results for a file | Ensure it belongs to a configured tsconfig project, has a valid schema, and uses the configured `graphql()` or tagged-template form. Inferred projects without a tsconfig entry are skipped. | +| Native VS Code integration cannot find the API package | Select the same workspace SDK that the native server uses and allow the TypeScript extension to use it. Older extension APIs discover the package through SDK settings. | +| Settings changed but behavior did not | Restart the TypeScript server after config edits. Save schema-file edits so watchers see them. | +| Windows API relay cannot start | Check that .NET Framework 4.x's `csc.exe` is installed; inspect the logged launcher/compiler error. | +| Duplicate diagnostics or rename edits | Ensure only one TypeScript LSP handles each buffer; use either the extension integration or proxy. | + +For the proxy, capture stderr without mixing it into stdout. For VS Code, +open the **GraphQLSP** Output channel; TS 5/6 plugin logs +are in **TypeScript: Open TS Server log**. + ## Tracking unused fields Currently the tracking unused fields feature has a few caveats with regards to tracking, first and foremost @@ -191,6 +446,11 @@ for you to case your types. ## Local development +For native API architecture, packaging, regression tests, and actual VS Code +validation, see [TypeScript 7 development and validation](docs/typescript-7-development.md). + +The instructions below attach to the classic TypeScript 5/6 server. + Run `pnpm i` at the root. Open `packages/example` by running `code packages/example` or if you want to leverage breakpoints do it with the `TSS_DEBUG_BRK=9559` prefix. When you make changes in `packages/graphqlsp` all you need to do is run `pnpm i` in your other editor and restart the `TypeScript server` for the changes to apply. diff --git a/docs/typescript-7-development.md b/docs/typescript-7-development.md new file mode 100644 index 00000000..13e4777d --- /dev/null +++ b/docs/typescript-7-development.md @@ -0,0 +1,174 @@ +# TypeScript 7 development and validation + +The [root guide](../README.md#typescript-7-editor-integration) and +[npm README](../packages/graphqlsp/README.md#typescript-7-editor-integration) +describe end-user setup, including the npm-only LSP path. The +[extension README](../packages/vscode-graphqlsp/README.md) covers VS Code setup. +This document covers implementation boundaries and reproducible checks. + +## How the integrations fit together + +The classic TS 5/6 plugin and native API integrations share GraphQL host +operations for diagnostics, hover, completion, schema definitions, fragment +references and rename, quick fixes, and fragment extraction. + +- `packages/graphqlsp/src/session.ts` owns the API connection, snapshot lifetime, + schema state, and serialized feature/check calls. It refreshes the snapshot + after initial schema loading so results use current document text. +- `packages/graphqlsp/src/languageFeatures.ts` translates shared operations into + LSP positions, ranges, locations, completion items, code actions, and edits. +- `packages/graphqlsp/src/lsp.ts` forwards native LSP traffic and augments + supported requests through a worker. It preserves native completion triggers + and code-action kinds, resolves GraphQL completion items locally, and forwards + ordinary TypeScript operations to the native server. +- `packages/vscode-graphqlsp/src/typescript7` connects to a TypeScript extension's + API session and runs GraphQL work in a child process. It owns GraphQL completion + items, uses middleware where supported, and provides adapters for older APIs. + Native-mode changes dispose providers and diagnostics; server restarts create + a fresh connection. +- `packages/graphqlsp/src/ts7/relay.ts` bridges the synchronous API client when + direct pipe connections are unavailable. On Windows, `windowsRelay.ts` embeds + source compiled with .NET Framework 4.x; Unix uses a shell launcher. + +Results include the source text they were computed against. Integrations must +discard stale results. Cross-file edits additionally require validation of the +affected documents' identities and versions, including close/reopen transitions. +The VS Code adapter and proxy perform these checks before returning edits. + +Local schema watching covers edits, deletion, and creation after an initial +missing-file error. Changed schemas are invalidated before reload; failed loads +do not silently reuse the old schema. Named schemas await initial readiness too. + +## Prerequisites and build + +Local validation used Node.js 22 and pnpm 11.3.0, matching the configured CI. +From the repository root: + +```sh +pnpm install --frozen-lockfile +pnpm build +pnpm --filter vscode-graphqlsp build +``` + +The workspace compiler remains TS 5/6 for building and compatibility tests. The +native fixture installs `typescript-7` as an alias of TypeScript 7.1.0-beta, so +native testing does not replace the workspace compiler. The tsrs fixture has no +Windows binary and is skipped there; use the native TypeScript implementation. + +## Regression tests and typechecks + +Run the complete suite: + +```sh +pnpm exec vitest run --no-file-parallelism +``` + +For native editor/session/proxy changes, the targeted CI set is: + +```sh +pnpm exec vitest run test/e2e/ts7-session.test.ts test/e2e/lsp-proxy.test.ts test/e2e/lsp-shutdown.test.ts test/unit/lspProtocol.test.ts test/unit/schemaRecovery.test.ts test/unit/editVersions.test.ts test/unit/nativeMode.test.ts test/unit/vscode-ts7-features.test.ts --no-file-parallelism +``` + +This covers unsaved snapshots, schema recovery and initial readiness, native +capabilities, GraphQL/native completion resolution, stale edits, setting +precedence, cancellation, and shutdown with documents open and closed. + +Typecheck both packages explicitly: + +```sh +pnpm exec tsc -p packages/graphqlsp --noEmit --types node --module esnext --moduleResolution bundler +pnpm exec tsc -p packages/vscode-graphqlsp --noEmit +``` + +To save machine-readable regression evidence: + +```sh +pnpm exec vitest run --no-file-parallelism --reporter=default --reporter=json --outputFile.json=.tmp/regression-results.json +``` + +## Packaging both installation paths + +Package the npm module without installing an editor extension: + +```sh +pnpm --filter @0no-co/graphqlsp build +pnpm --filter @0no-co/graphqlsp pack --pack-destination . +``` + +The tarball name is derived from the package's current version. Install that +`.tgz` into a separate project with `npm install -D /absolute/path/to/package.tgz`. +Install TypeScript 7.1+ there, keep the GraphQLSP tsconfig entry, and launch the +proxy using the [documented Node command](../README.md#without-a-vsix-npm-and-an-lsp-client). + +Package the VS Code integration: + +```sh +pnpm --filter vscode-graphqlsp package +``` + +This builds the extension and core, packs the local npm module, and stages a +flat dependency tree before running `vsce package`. The output is +`packages/vscode-graphqlsp/vscode-graphqlsp-.vsix`; the accompanying npm +tarball is in `packages/vscode-graphqlsp/.staging/`. Both contain their respective +READMEs. Packing does not publish to npm or the VS Code Marketplace. + +## Actual VS Code validation + +After packaging, run the isolated editor harness from the repository root: + +```sh +node scripts/dogfood-vscode.mjs --code /absolute/path/to/Code --cli /absolute/path/to/resources/app/out/cli.js --results .tmp/dogfood-results.json +``` + +Use the installed VS Code executable and its matching CLI JavaScript file. On +Windows, quote paths containing spaces; versioned installations can place +`resources/app/out/cli.js` under a version directory beside `Code.exe`. The +optional `--vsix /absolute/path/to/file.vsix` selects an artifact explicitly. + +The harness needs a desktop session and network access to install Microsoft's +TypeScript extension. It creates fresh temporary user-data and extension +directories and a fixture workspace. It installs the local VSIX and native +TypeScript extension, runs editor commands, and leaves the user's normal +profile untouched. The console prints the retained temporary directory for +logs; the JSON report records versions, checks, and failures. Any failed check +causes an unsuccessful exit. + +The checks cover native diagnostics, unsaved correction, resolved completion +and application, hover, schema definitions, cross-file references, applied +rename, applied quick fixes and fragment extraction, schema edits and +deletion/recreation, server restart, TS 5/6 versus native switching, legacy +setting scope precedence, and resetting native enablement to unset. + +## Shutdown workaround + +The API worker stops accepting work, drains pending operations, and disposes +its session before native LSP shutdown. The relay releases retained snapshots +and files, half-closes the API connection, and waits for remote closure. + +The tested native TS 7.1 server can still stall in API-session cleanup. After +requesting native shutdown, the proxy gives it two seconds to reply. On timeout, +it logs the fallback, terminates that in-memory child, and replies to the +client's shutdown request. It keeps the client transport alive until `exit`, +rejects further feature requests, and suppresses late duplicate shutdown replies. +This contains the native cleanup race; it does not repair it upstream. + +## Validation scope and release status + +The Windows implementation was exercised with VS Code 1.136.2, +TypeScriptTeam.native-preview 1.0.1, and TypeScript 7.1.0-beta. Before the +documentation pass, 149 regression tests in 28 files passed, 13 real-editor +checks passed, both packages typechecked, and an independent sub-agent's +14-case native shutdown stress run passed. These are local validation results, +not claims about every TypeScript or editor extension version. + +The released native extension uses its older API shape; the newer middleware +adapter is covered by unit tests, not an actual released-editor run. The +`package-vscode.yaml` workflow is configured to build/test/package on Windows, +macOS, and Linux. macOS/Linux jobs were not executed in this local session, +and that workflow does not run the actual desktop-editor harness. + +The npm changeset and extension version are independent. This local work does +not publish either artifact; CI packaging and Marketplace publishing are +separate actions. Rebuild after documentation changes so the npm README and +VSIX listing match the source, and record hashes against the exact artifact +used for editor validation. diff --git a/packages/graphqlsp/README.md b/packages/graphqlsp/README.md index 0c69ca34..76b3b79a 100644 --- a/packages/graphqlsp/README.md +++ b/packages/graphqlsp/README.md @@ -1,8 +1,9 @@ # GraphQLSP -This is a TypeScript LSP Plugin that will recognise documents in your -TypeScript code and help you out with hover-information, diagnostics and -auto-complete. +GraphQLSP provides GraphQL diagnostics, hover information, completion, and +navigation for documents embedded in JavaScript and TypeScript. It runs as a +TypeScript 5/6 language service plugin, or through the TypeScript 7.1+ native +language-server API. ## Features @@ -23,7 +24,7 @@ npm install -D @0no-co/graphqlsp ## Usage -Go to your `tsconfig.json` and add +For all integrations, add the GraphQLSP configuration to your `tsconfig.json`: ```json { @@ -38,7 +39,10 @@ Go to your `tsconfig.json` and add } ``` -now restart your TS-server and you should be good to go, ensure you are using the +For TypeScript 7, continue with [TypeScript 7 editor integration](#typescript-7-editor-integration); +the tsconfig entry alone does not activate GraphQLSP. + +For TypeScript 5/6, restart your TS server and ensure you are using the workspace version of TypeScript. In VSCode you can do so by clicking the bottom right when on a TypeScript file or adding a file like [this](https://github.com/0no-co/GraphQLSP/blob/main/packages/example/.vscode/settings.json). @@ -54,11 +58,26 @@ when on a TypeScript file or adding a file like [this](https://github.com/0no-co ### Configuration -**Required** +**Schema source (choose one)** - `schema` allows you to specify a url, `.json` or `.graphql` file as your schema. If you need to specify headers for your introspection you can opt into the object notation i.e. `{ "schema": { "url": "x", "headers": { "Authorization": "y" } }}` +- `schemas` accepts multiple named schemas instead of a single `schema`, for example: + + ```json + { + "name": "@0no-co/graphqlsp", + "schemas": [ + { "name": "pokemon", "schema": "./pokemon.graphql" }, + { "name": "accounts", "schema": "./accounts.graphql" } + ] + } + ``` + + Use the schema name configured in your gql.tada client to select its schema. + Each schema can also configure its own `tadaOutputLocation`. + **Optional** - `template` add an additional template to the defaults `gql` and `graphql` @@ -76,6 +95,299 @@ when on a TypeScript file or adding a file like [this](https://github.com/0no-co - `tadaDisablePreprocessing` this setting disables the optimisation of `tadaOutput` to a pre-processed TypeScript type, this is off by default. - `clientDirectives` this setting allows you to specify additional `clientDirectives` which won't be seen as a missing schema-directive. +## Checking projects with TypeScript 7 + +TypeScript 7 does not load language service plugins. Adding a plugin to +`tsconfig.json` does not make `tsc` report GraphQLSP diagnostics or make the +native language server load GraphQLSP. Use `checkProject` for a standalone +check, and the [editor integration](#typescript-7-editor-integration) for live +editor features. The classic TypeScript 5/6 plugin remains supported. + +`checkProject` uses the TypeScript 7.1 API, or the TypeScript 5/6 compiler API. +It resolves the compiler from the checked project's directory by default: + +```js +// Save as check-graphql.mjs and run: node check-graphql.mjs +import { checkProject } from '@0no-co/graphqlsp/check'; + +const { diagnostics } = await checkProject({ + tsconfigPath: './tsconfig.json', + compiler: 'typescript', +}); + +for (const diagnostic of diagnostics) { + console.log( + `${diagnostic.file}:${diagnostic.line}:${diagnostic.column} ` + + `${diagnostic.severity} (${diagnostic.code}): ${diagnostic.message}` + ); +} +process.exitCode = diagnostics.some(item => item.severity === 'error') ? 1 : 0; +``` + +Run your normal TypeScript check separately; this function returns GraphQLSP +diagnostics, not TypeScript's compiler diagnostics. It does not change the exit +status of a separate `tsc` process. + +| Option | Meaning | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `tsconfigPath` | Required path of the project to check. | +| `compiler` | `'typescript'` (default) or `'tsrs'`. | +| `compilerPath` | Optional compiler package directory or `package.json`, useful for an aliased TypeScript installation. | +| `files` | Optional list of files; otherwise checks the project's non-declaration JS/TS files outside `node_modules`. Relative paths use the process working directory. | +| `logger` | Optional callback for GraphQLSP log messages. | +| `runExternalCode` | Opt into running the project's content mappers; defaults to `false`. | + +The result contains `compiler` (name and version), `tsconfigPath`, `files`, and +`diagnostics`. Diagnostic file paths are absolute; line and column are one-based. +Each diagnostic also has a source span (`start` and `length`), `code`, +`severity`, and `message`. + +Configuration comes from `compilerOptions.plugins`, including entries found +through `extends`. Relative schema and output paths resolve against the config +declaring the entry. Schemas load once per check; they are not watched, and +`tadaOutputLocation` typings are not written. + +For projects with `contentMappers` in their tsconfig, pass +`runExternalCode: true` to resolve imports from mapped files, such as +`.graphql` files used by `@gql.tada/content-mapper`. Mappers run external +processes, as with `tsc --runExternalCode`. GraphQLSP checks the JS/TS consumers; +the mappers report diagnostics for the mapped files themselves. The tsrs API +does not run content mappers yet, and its current package does not provide a +Windows binary. The native TypeScript path is the locally verified Windows path. + +### Connecting to an existing language server + +Custom integrations can use `createLanguageServerSession` with an API pipe +from a running TypeScript 7.1+ language server. The compiler package must match +the installation running that server. A pipe can come from the TypeScript +extension's `sdk.initializeAPIConnection()`, or the `pipe` field returned by +the server's `custom/initializeAPISession` request. + +The following belongs in your integration's worker; `sdk`, `fileName`, and +`currentText` are supplied by the integration. `fileName` is an absolute path. + +```ts +import { createLanguageServerSession } from '@0no-co/graphqlsp/check'; + +const session = await createLanguageServerSession({ + packageJsonPath: sdk.packageJsonPath, + pipe: await sdk.initializeAPIConnection(), + persist: false, // Optional: do not write gql.tada typings. +}); +try { + const checked = await session.check(fileName); + if (checked && checked.text === currentText) { + // Publish checked.diagnostics to your editor. + } + const hover = await session.hover(fileName, { line: 0, character: 0 }); + // Use hover.result only if hover.text still matches the current document. +} finally { + session.dispose(); +} +``` + +The checker API blocks synchronously while it talks to TypeScript, even though +the session methods return promises. Run it in a worker or child process to +keep the editor or LSP forwarding loop responsive. Calls are serialized within +a session. Await outstanding calls before disposal, and create a new session +after the underlying server restarts. + +| Session call | Result | +| ----------------------------------- | --------------------------------------------------------- | +| `check(fileName)` | `{ text, diagnostics }`. | +| `hover(fileName, position)` | `{ text, result }`, with an LSP Hover or `null`. | +| `complete(fileName, position)` | `{ text, result }`, with an LSP CompletionList or `null`. | +| `request(method, fileName, params)` | `{ text, result }`, using the LSP shape for that method. | +| `dispose()` | Closes the API connection and schema watchers. | + +Supported `request` methods are `textDocument/hover`, +`textDocument/completion`, `textDocument/definition`, +`textDocument/references`, `textDocument/prepareRename`, +`textDocument/rename`, and `textDocument/codeAction`. For example: + +```ts +const renamed = await session.request('textDocument/rename', fileName, { + position: { line: 3, character: 12 }, + newName: 'PokemonFields', +}); +``` + +Pass LSP `position` for navigation and references, `newName` for rename, +and `range` plus `context` for code actions. Reference context can include +`includeDeclaration`; code-action context can include `diagnostics` and +`only` (`quickfix` or `refactor.extract`). For quick fixes, pass diagnostics with +`source: 'graphqlsp'` and the diagnostic `code`. Positions and ranges are zero-based +and use UTF-16 character offsets. + +Session calls return `null` for files outside configured projects. Feature +payloads use `result: null` outside supported GraphQL contexts. Compare `text` +to the current document before publishing results. For cross-file edits, also +capture and validate every affected open document's identity and version; +the requesting file's `text` alone cannot detect a changed referenced file. + +Sessions write configured gql.tada typings by default; pass `persist: false` +to disable this. `logger` receives messages, and `onSchemaChange` tells the +integration to recheck unchanged open files after local schemas reload. + +## TypeScript 7 editor integration + +TypeScript **7.1 or newer** is required for the API used here; 7.0 is not +supported. Keep the GraphQLSP entry in `compilerOptions.plugins` from +[Usage](#usage), including its schema settings. GraphQLSP reads this as +configuration; the native TypeScript server does not load the classic plugin. + +| Environment | How to activate GraphQLSP | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| TypeScript 5/6 | Install the npm plugin and configure tsconfig; use the workspace TypeScript version in your editor. | +| TypeScript 7.1+ in VS Code | Install the GraphQLSP extension and a compatible TypeScript native extension, then enable native TypeScript. | +| TypeScript 7.1+ with a configurable LSP client | Install the npm package and launch the bundled LSP proxy as your TypeScript server. No VSIX is required. | +| CI or scripts | Call `checkProject` separately from your TypeScript compiler check. | + +Both TS 7 editor paths provide live diagnostics for open JS/TS files, hover, +completion, schema-field definitions, fragment references and rename, quick +fixes, and the **Extract to fragment** refactor in call-expression mode. They +use the native server's project snapshots, including unsaved edits. + +### Without a VSIX: npm and an LSP client + +Install a build of `@0no-co/graphqlsp` containing the `graphqlsp` executable +and TypeScript 7.1+. For this local implementation, install the supplied +tarball rather than assuming a registry release includes these changes: + +```sh +# Replace the path with the downloaded/local package path. +npm install -D "/absolute/path/to/graphqlsp-ts7-local.tgz" +# The prerelease used for local validation: +npm install -D typescript@7.1.0-beta +``` + +Keep your existing `tsconfig.json` plugin entry, and set the LSP client's +working directory to your project root. Configure its executable and arguments +as follows (the field names depend on the editor): + +```json +{ + "command": "node", + "args": ["./node_modules/@0no-co/graphqlsp/bin/graphqlsp.cjs", "--stdio"] +} +``` + +This direct Node command also works on Windows clients that cannot launch +`.cmd` shims. The equivalent executable, when `node_modules/.bin` is on the +client's PATH, is `graphqlsp --stdio`. + +Attach this server to the editor's JavaScript, JavaScript React, TypeScript, +and TypeScript React language IDs (JS/JSX/TS/TSX files). It replaces your +direct `tsc --lsp --stdio` server command: the proxy starts and forwards to +the native TypeScript server itself. Disable a duplicate TypeScript LSP +attachment for those same buffers to avoid duplicate diagnostics and edits. +Use the normal LSP initialize/open/change/close/shutdown lifecycle; simply +running the command in a terminal waits for LSP input. + +By default the proxy resolves `typescript/package.json` from its working +directory. When the editor uses a different directory, a monorepo root, or +an aliased compiler package, use absolute paths for the proxy script and +`--typescript`: + +```sh +node /absolute/path/to/node_modules/@0no-co/graphqlsp/bin/graphqlsp.cjs --stdio --typescript /absolute/path/to/node_modules/typescript +``` + +| Proxy option | Meaning | +| --------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `--stdio` | Use the bundled stdio LSP entry point. stdout carries only LSP messages; logs go to stderr. | +| `--typescript ` | Select the native TypeScript package directory or its `package.json`; defaults to the project-local `typescript` package. | +| `--runExternalCode` | Allow the native server to run tsconfig content mappers. Omitted by default. | +| `--no-persist` | Disable writes of configured gql.tada introspection typings. Writes are enabled by default. | +| `--help` | Print command usage and exit. | + +**Can VS Code discover the npm package automatically, like the old plugin?** +With TS 7, npm installation plus a tsconfig entry is not sufficient. Use the +GraphQLSP extension or a VS Code LSP client configured to launch the proxy. +The built-in TypeScript extension does not launch this executable from the +plugin entry. The npm-only automatic plugin setup remains available for TS 5/6. + +### VS Code extension setup + +1. Install the GraphQLSP VSIX with **Extensions: Install from VSIX…** and a + compatible TypeScript extension, such as + [TypeScript Native Preview](https://marketplace.visualstudio.com/items?itemName=TypeScriptTeam.native-preview). +2. Point the TypeScript extension at your TypeScript 7.1+ installation and + enable native TypeScript. The locally tested settings are: + + ```json + { + "js/ts.experimental.useTsgo": true, + "js/ts.tsdk.path": "./node_modules/typescript" + } + ``` + +3. Add the GraphQLSP plugin entry and schema to the project's tsconfig, then + restart the TypeScript server and open a JS/TS file belonging to that project. + +The VSIX bundles GraphQLSP, so this path does not require its npm package in +every project. The project still needs its GraphQL dependencies. GraphQLSP +connects to the native server started by the TypeScript extension; do not also +configure the proxy for the same files. + +GraphQLSP follows the TypeScript extension's native-mode settings, including +the deprecated `typescript.experimental.useTsgo` alias and scope precedence. +Switching back to classic TypeScript, or resetting an explicit enablement, +disposes its native diagnostics and providers. Returning to native mode or +restarting the server creates a fresh API session. + +### Configuration, platforms, and limits + +- Configure TS 7 projects through `compilerOptions.plugins`; VS Code + `graphqlsp.*` settings apply only to the TS 5/6 plugin path. Existing + `@0no-co/graphqlsp` and `gql.tada/ts-plugin` entries are recognized. +- Configuration inherited through `extends` is supported. Relative schema + paths resolve from the config that declares the entry, not the editor's + working directory. Restart the server after changing plugin settings or + schema paths. +- Local schema contents are watched, including deletion and recreation. + Schema changes recheck open files; a failed load does not retain the old + schema as if it were current. Named schemas are loaded before the first + feature request. An unsaved schema edit must be saved to reach the file watcher. +- Remote URL schemas load once per TS 7 session; restart the server to refresh + them. Automatic file watching applies to local schemas. +- The VS Code extension requires a trusted workspace and is disabled in + Restricted Mode. +- The proxy and extension check embedded GraphQL in JS/TS. Standalone + `.graphql` files need their own GraphQL language server for language + features; content mappers can expose their imports to JS/TS consumers. + Syntax highlighting and document symbols are extension features, not + features supplied by the npm proxy. +- The TS 7 path does not reproduce navigation from generated gql.tada + turbo-cache result types back into queries. Ordinary native TypeScript + navigation remains available at those positions. +- On Windows, the sync API relay requires the installed **.NET Framework 4.x + C# compiler**. It builds a small included launcher locally; no .NET SDK or + download is needed. Unix uses a shell launcher. The relay is bypassed when + TypeScript supports synchronous pipe connections directly. +- TypeScript 7.1 can hang during native API-session cleanup. The proxy drains + its GraphQL worker first and requests clean native shutdown. If the native + child does not reply within two seconds, the proxy terminates that child + and acknowledges shutdown, then waits for the client's `exit`. This is a + bounded workaround; it does not fix the native server's cleanup race. + +### Troubleshooting + +| Symptom | Check | +| ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| npm package and tsconfig entry have no effect in TS 7 | Start the proxy through an LSP client, or install and enable the VS Code integration. | +| Proxy reports that TypeScript 7.1+ is required | Verify the package selected from the working directory, or pass `--typescript` explicitly. | +| Proxy cannot resolve `typescript/package.json` | Use the project root as its working directory and install the compiler there, or select its absolute package path. | +| No GraphQL results for a file | Ensure it belongs to a configured tsconfig project, has a valid schema, and uses the configured `graphql()` or tagged-template form. Inferred projects without a tsconfig entry are skipped. | +| Native VS Code integration cannot find the API package | Select the same workspace SDK that the native server uses and allow the TypeScript extension to use it. Older extension APIs discover the package through SDK settings. | +| Settings changed but behavior did not | Restart the TypeScript server after config edits. Save schema-file edits so watchers see them. | +| Windows API relay cannot start | Check that .NET Framework 4.x's `csc.exe` is installed; inspect the logged launcher/compiler error. | +| Duplicate diagnostics or rename edits | Ensure only one TypeScript LSP handles each buffer; use either the extension integration or proxy. | + +For the proxy, capture stderr without mixing it into stdout. For VS Code, +open the **GraphQLSP** Output channel; TS 5/6 plugin logs +are in **TypeScript: Open TS Server log**. + ## Tracking unused fields Currently the tracking unused fields feature has a few caveats with regards to tracking, first and foremost diff --git a/packages/graphqlsp/bin/graphqlsp.cjs b/packages/graphqlsp/bin/graphqlsp.cjs new file mode 100644 index 00000000..739c9a1f --- /dev/null +++ b/packages/graphqlsp/bin/graphqlsp.cjs @@ -0,0 +1,8 @@ +#!/usr/bin/env node +'use strict'; +require('../dist/lsp.js') + .runLanguageServer() + .catch(error => { + process.stderr.write(`[graphqlsp] ${error.stack || error}\n`); + process.exitCode = 1; + }); diff --git a/packages/graphqlsp/package.json b/packages/graphqlsp/package.json index bbf2feb0..0f149f39 100644 --- a/packages/graphqlsp/package.json +++ b/packages/graphqlsp/package.json @@ -24,7 +24,13 @@ "require": "./dist/check.js", "source": "./src/check.ts" }, - "./package.json": "./package.json" + "./package.json": "./package.json", + "./lsp": { + "types": "./dist/lsp.d.ts", + "import": "./dist/lsp.mjs", + "require": "./dist/lsp.js", + "source": "./src/lsp.ts" + } }, "scripts": { "build": "rollup -c ../../scripts/rollup.config.mjs", @@ -65,5 +71,8 @@ }, "publishConfig": { "provenance": true + }, + "bin": { + "graphqlsp": "./bin/graphqlsp.cjs" } } diff --git a/packages/graphqlsp/src/autoComplete.ts b/packages/graphqlsp/src/autoComplete.ts index d44852c4..01f46d7a 100644 --- a/packages/graphqlsp/src/autoComplete.ts +++ b/packages/graphqlsp/src/autoComplete.ts @@ -1,3 +1,4 @@ +import type { GraphQLSPHost } from './host'; import { ts } from './ts'; import { @@ -33,7 +34,7 @@ export function getGraphQLCompletions( filename: string, cursorPosition: number, schema: SchemaRef, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.WithMetadata | undefined { const isCallExpression = info.config.templateIsCallExpression ?? true; const typeChecker = info.languageService.getProgram()?.getTypeChecker(); diff --git a/packages/graphqlsp/src/check.ts b/packages/graphqlsp/src/check.ts index 9e0e9a39..3de26359 100644 --- a/packages/graphqlsp/src/check.ts +++ b/packages/graphqlsp/src/check.ts @@ -26,6 +26,7 @@ export type { CheckDiagnostic, CheckSeverity } from './ts7/report'; export { createLanguageServerSession } from './session'; export type { LanguageServerCheckResult, + LanguageServerFeatureResult, LanguageServerSession, LanguageServerSessionOptions, } from './session'; diff --git a/packages/graphqlsp/src/codeFixes.ts b/packages/graphqlsp/src/codeFixes.ts index be5a4f49..15696e50 100644 --- a/packages/graphqlsp/src/codeFixes.ts +++ b/packages/graphqlsp/src/codeFixes.ts @@ -1,3 +1,4 @@ +import type { GraphQLSPHost } from './host'; import { ts } from './ts'; import { parse, visit } from 'graphql'; import type { FieldNode, SelectionSetNode } from 'graphql'; @@ -60,7 +61,7 @@ export function getGraphQLCodeFixesAtPosition( end: number, errorCodes: readonly number[], schema: SchemaRef, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.CodeFixAction[] { if (!errorCodes.some(code => CODE_FIXABLE_DIAGNOSTICS.includes(code))) { return []; @@ -243,7 +244,7 @@ const getUnusedFieldFixes = ( source: ts.SourceFile, diagnosticStart: number, message: string, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.CodeFixAction[] => { const listMatch = /^Field\(s\) (.+) are not used\.$/.exec(message) || diff --git a/packages/graphqlsp/src/config.ts b/packages/graphqlsp/src/config.ts index 11d89d60..330f998e 100644 --- a/packages/graphqlsp/src/config.ts +++ b/packages/graphqlsp/src/config.ts @@ -1,4 +1,5 @@ import type { SchemaOrigin } from '@gql.tada/internal'; +import path from 'node:path'; import { ts } from './ts'; @@ -42,15 +43,15 @@ const hasLocalPluginPackage = ( let directory = info.project.getCurrentDirectory(); while (true) { - const manifest = ts.combinePaths( - directory, + const manifest = path.posix.join( + directory.replace(/\\/g, '/'), 'node_modules', packageName, 'package.json' ); if (info.project.fileExists(manifest)) return true; - const parent = ts.getDirectoryPath(directory); + const parent = path.posix.dirname(directory.replace(/\\/g, '/')); if (parent === directory) return false; directory = parent; } diff --git a/packages/graphqlsp/src/definition.ts b/packages/graphqlsp/src/definition.ts index 168ff9d2..81f14f94 100644 --- a/packages/graphqlsp/src/definition.ts +++ b/packages/graphqlsp/src/definition.ts @@ -1,3 +1,4 @@ +import type { GraphQLSPHost, HostProgram } from './host'; import path from 'path'; import { @@ -116,7 +117,7 @@ const getTadaCacheMatch = (node: ts.Node): TadaCacheMatch | undefined => { const findTadaCacheMatch = ( definition: ts.DefinitionInfo, - program: ts.Program + program: HostProgram ): TadaCacheMatch | undefined => { const source = program.getSourceFile(definition.fileName); if (!source) return undefined; @@ -129,7 +130,7 @@ const findDocumentCallInSource = ( source: ts.SourceFile, documentText: string, schemaName: string | null, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): { fileName: string; node: ts.StringLiteralLike } | undefined => { if (source.isDeclarationFile) return undefined; if (!source.getText().includes(documentText)) return undefined; @@ -151,8 +152,8 @@ const findDocumentCall = ( documentText: string, schemaName: string | null, preferFileName: string, - program: ts.Program, - info: ts.server.PluginCreateInfo + program: HostProgram, + info: GraphQLSPHost ): { fileName: string; node: ts.StringLiteralLike } | undefined => { // The document literal is usually co-located with the result usage, so the // originating file is the likeliest match. @@ -167,7 +168,7 @@ const findDocumentCall = ( if (match) return match; } - for (const source of program.getSourceFiles()) { + for (const source of program.getSourceFiles?.() || []) { if (source === preferred) continue; const match = findDocumentCallInSource( source, @@ -303,9 +304,9 @@ const getFieldAtOffset = ( nameLoc && isPositionInRange(offset, nameLoc.start, nameLoc.end) ? nameLoc : aliasLoc && - isPositionInRange(offset, aliasLoc.start, aliasLoc.end) - ? aliasLoc - : null; + isPositionInRange(offset, aliasLoc.start, aliasLoc.end) + ? aliasLoc + : null; if (!loc) return; const parentType = typeInfo.getParentType(); @@ -356,7 +357,7 @@ const getDefinitionForDocumentField = ( filename: string, cursorPosition: number, schema: SchemaRef, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.DefinitionInfoAndBoundSpan | undefined => { const isCallExpression = info.config.templateIsCallExpression ?? true; const typeChecker = info.languageService.getProgram()?.getTypeChecker(); @@ -420,7 +421,7 @@ const getDefinitionForDocumentField = ( const getDefinitionForTadaResultField = ( filename: string, schema: SchemaRef, - info: ts.server.PluginCreateInfo, + info: GraphQLSPHost, originalDefinitions: readonly ts.DefinitionInfo[] | undefined ): readonly ts.DefinitionInfo[] | undefined => { if (!originalDefinitions?.length) return undefined; @@ -475,7 +476,7 @@ export function getGraphQLDefinitionAtPosition( filename: string, cursorPosition: number, schema: SchemaRef, - info: ts.server.PluginCreateInfo, + info: GraphQLSPHost, originalDefinitions: readonly ts.DefinitionInfo[] | undefined ): readonly ts.DefinitionInfo[] | undefined { const documentFieldDefinition = getDefinitionForDocumentField( @@ -500,7 +501,7 @@ export function getGraphQLDefinitionAndBoundSpan( filename: string, cursorPosition: number, schema: SchemaRef, - info: ts.server.PluginCreateInfo, + info: GraphQLSPHost, original: ts.DefinitionInfoAndBoundSpan | undefined ): ts.DefinitionInfoAndBoundSpan | undefined { const documentFieldDefinition = getDefinitionForDocumentField( diff --git a/packages/graphqlsp/src/diagnostics.ts b/packages/graphqlsp/src/diagnostics.ts index 9c31a09a..2e4abbe4 100644 --- a/packages/graphqlsp/src/diagnostics.ts +++ b/packages/graphqlsp/src/diagnostics.ts @@ -768,6 +768,13 @@ const runDiagnostics = ( undefined, docFragments ) + .map(diag => ({ + ...diag, + message: + typeof diag.message === 'string' + ? diag.message + : diag.message.value, + })) .filter(diag => { if (!diag.message.includes('Unknown directive')) return true; @@ -845,7 +852,10 @@ const runDiagnostics = ( : diag.severity === 2 ? USING_DEPRECATED_FIELD_CODE : SEMANTIC_DIAGNOSTIC_CODE, - messageText: diag.message.split('\n')[0], + messageText: (typeof diag.message === 'string' + ? diag.message + : diag.message.value + ).split('\n')[0], }) as ts.Diagnostic ); diff --git a/packages/graphqlsp/src/extractFragment.ts b/packages/graphqlsp/src/extractFragment.ts index 01a1cc86..b01a6b48 100644 --- a/packages/graphqlsp/src/extractFragment.ts +++ b/packages/graphqlsp/src/extractFragment.ts @@ -1,3 +1,4 @@ +import type { GraphQLSPHost } from './host'; import { BREAK, DocumentNode, @@ -49,7 +50,7 @@ const findExtractableSelection = ( filename: string, positionOrRange: number | ts.TextRange, schema: SchemaRef, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ExtractableSelection | undefined => { // The refactor is only supported for call-expression documents; in // tagged-template mode fragments aren't composed via a fragment array @@ -73,6 +74,7 @@ const findExtractableSelection = ( }); for (const { node, schema: schemaName } of nodes) { + if (!ts.isStringLiteralLike(node)) continue; const documentOffset = node.getStart() + 1; const documentText = node.getText().slice(1, -1); if ( @@ -153,7 +155,7 @@ const findExtractableSelection = ( start, end, parentTypeName, - documentNodes: nodes.map(x => x.node), + documentNodes: nodes.map(x => x.node).filter(ts.isStringLiteralLike), }; } @@ -166,7 +168,7 @@ export const canExtractFragment = ( filename: string, positionOrRange: number | ts.TextRange, schema: SchemaRef, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): boolean => !!findExtractableSelection(filename, positionOrRange, schema, info); @@ -323,7 +325,7 @@ export const getExtractFragmentEdits = ( filename: string, positionOrRange: number | ts.TextRange, schema: SchemaRef, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.RefactorEditInfo | undefined => { const found = findExtractableSelection( filename, diff --git a/packages/graphqlsp/src/graphql/getSchema.ts b/packages/graphqlsp/src/graphql/getSchema.ts index 5df1a333..a6e97957 100644 --- a/packages/graphqlsp/src/graphql/getSchema.ts +++ b/packages/graphqlsp/src/graphql/getSchema.ts @@ -72,7 +72,24 @@ export const swapWrite = async ( const tempTarget = getSwapFilePath(target); await fs.writeFile(tempTarget, contents); try { - await fs.rename(tempTarget, target); + // Windows can briefly lock the destination while another instance touches + // or reads its typings. Retry the atomic replacement; never delete it first. + for (let attempt = 0; ; attempt++) { + try { + await fs.rename(tempTarget, target); + break; + } catch (error) { + if ( + process.platform !== 'win32' || + attempt >= 20 || + !['EPERM', 'EACCES', 'EBUSY'].includes( + (error as NodeJS.ErrnoException).code || '' + ) + ) + throw error; + await new Promise(resolve => setTimeout(resolve, 5 * (attempt + 1))); + } + } } catch (error) { // Clean-up is best-effort and must not mask the rename error await fs.unlink(tempTarget).catch(() => {}); @@ -161,7 +178,7 @@ export interface SchemaRef { readonly turboLocations: ReadonlyMap; /** Best-effort detection of schema file changes that the file watcher * missed; throttled internally, so it's safe to call often. */ - checkStale(): void; + checkStale(force?: boolean): void; /** Settles once the initial schema load has finished, successfully or not; * failures are reported through `errors` instead. */ readonly ready: Promise; @@ -225,6 +242,7 @@ export const loadSchema = ( let inner: InternalSchemaRef | null = null; let detectStaleSchemas: (() => void) | null = null; let lastStaleCheck = 0; + let revision = 0; let ready: Promise = Promise.resolve(); const ref: SchemaRef = { @@ -242,11 +260,14 @@ export const loadSchema = ( return inner ? inner.multi : EMPTY_MULTI; }, get version() { - return inner ? inner.version : 0; + return (inner ? inner.version : 0) + revision; }, - checkStale() { + checkStale(force = false) { const now = Date.now(); - if (!detectStaleSchemas || now - lastStaleCheck < STALE_CHECK_INTERVAL) + if ( + !detectStaleSchemas || + (!force && now - lastStaleCheck < STALE_CHECK_INTERVAL) + ) return; lastStaleCheck = now; detectStaleSchemas(); @@ -340,13 +361,15 @@ export const loadSchema = ( typeof origin === 'string' && !getURLConfig(origin) ) .map(origin => path.resolve(rootPath, origin)); - const schemaFileMtimes = new Map(); + const schemaFileMtimes = new Map(); const recordMtimes = async () => { await Promise.all( schemaFilePaths.map(async file => { try { schemaFileMtimes.set(file, (await fs.stat(file)).mtimeMs); - } catch (_error) {} + } catch (_error) { + schemaFileMtimes.set(file, null); + } }) ); }; @@ -366,24 +389,50 @@ export const loadSchema = ( }) .then(recordMtimes); + let reloading: Promise | undefined; + let reloadQueued = false; detectStaleSchemas = () => { - (async () => { - let stale = false; + if (reloading) { + reloadQueued = true; + return; + } + reloading = (async () => { + const changed = new Set(); + const observed = new Map(); for (const file of schemaFilePaths) { + let mtime: number | null = null; try { - const lastMtime = schemaFileMtimes.get(file); - const mtime = (await fs.stat(file)).mtimeMs; - if (lastMtime !== undefined && mtime > lastMtime) stale = true; + mtime = (await fs.stat(file)).mtimeMs; } catch (_error) {} + observed.set(file, mtime); + if (mtime !== schemaFileMtimes.get(file)) changed.add(file); } - if (!stale) return; + if (!changed.size) return; + revision++; logger('Schema files changed without watcher events; reloading...'); + // A failed reload must not leave the previous schema active. Healthy + // schemas in a multi-schema configuration remain available. + for (const [name, file] of sourceLocations) { + if (!changed.has(file)) continue; + if (name) inner!.multi[name] = null; + else inner!.current = null; + } await revalidate(true); + // Preserve the pre-read state so a concurrent file change is not lost. + for (const [file, mtime] of observed) schemaFileMtimes.set(file, mtime); persistAll(); if (options.onChange) options.onChange(); - })().catch(error => { - logger(`Failed to check schemas for staleness: ${error}`); - }); + })() + .catch(error => { + logger(`Failed to check schemas for staleness: ${error}`); + }) + .finally(() => { + reloading = undefined; + if (reloadQueued) { + reloadQueued = false; + detectStaleSchemas?.(); + } + }); }; try { diff --git a/packages/graphqlsp/src/host.ts b/packages/graphqlsp/src/host.ts index 3fc9c6fe..41c61119 100644 --- a/packages/graphqlsp/src/host.ts +++ b/packages/graphqlsp/src/host.ts @@ -22,6 +22,7 @@ export type HostChecker = Pick< export interface HostProgram { getSourceFile(fileName: string): ts.SourceFile | undefined; getTypeChecker(): HostChecker; + getSourceFiles?(): readonly ts.SourceFile[]; } /** The subset of a TypeScript plugin's `PluginCreateInfo` that GraphQLSP's diff --git a/packages/graphqlsp/src/languageFeatures.ts b/packages/graphqlsp/src/languageFeatures.ts new file mode 100644 index 00000000..e1af5172 --- /dev/null +++ b/packages/graphqlsp/src/languageFeatures.ts @@ -0,0 +1,255 @@ +import fs from 'node:fs'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import type { ts } from './ts'; +import type { GraphQLSPHost } from './host'; +import type { SchemaRef } from './graphql/getSchema'; +import { getGraphQLQuickInfo } from './quickInfo'; +import { getGraphQLCompletions } from './autoComplete'; +import { getGraphQLDefinitionAtPosition } from './definition'; +import { + getGraphQLFragmentReferenceEntries, + getGraphQLFragmentRenameInfo, + getGraphQLFragmentRenameLocations, +} from './references'; +import { getGraphQLCodeFixesAtPosition } from './codeFixes'; +import { getExtractFragmentEdits } from './extractFragment'; + +export interface Position { + line: number; + character: number; +} +export interface Range { + start: Position; + end: Position; +} + +/** LSP positions are UTF-16 offsets, as are TypeScript's string offsets. */ +export const positionAt = (text: string, offset: number): Position => { + offset = Math.max(0, Math.min(text.length, offset)); + const prefix = text.slice(0, offset); + const line = (prefix.match(/\n/g) || []).length; + return { line, character: offset - (prefix.lastIndexOf('\n') + 1) }; +}; + +export const offsetAt = (text: string, position: Position): number => { + let start = 0; + for (let line = 0; line < position.line; line++) { + const next = text.indexOf('\n', start); + if (next < 0) return text.length; + start = next + 1; + } + const newline = text.indexOf('\n', start); + let end = newline < 0 ? text.length : newline; + if (text[end - 1] === '\r') end--; + return Math.min(end, start + Math.max(0, position.character)); +}; + +export const rangeAt = (text: string, span: ts.TextSpan): Range => ({ + start: positionAt(text, span.start), + end: positionAt(text, span.start + span.length), +}); + +export const featureMethods = new Set([ + 'textDocument/hover', + 'textDocument/completion', + 'textDocument/definition', + 'textDocument/references', + 'textDocument/prepareRename', + 'textDocument/rename', + 'textDocument/codeAction', +]); + +/** Returns undefined when GraphQLSP does not own this position. */ +export function getLanguageFeature( + method: string, + params: any, + fileName: string, + text: string, + schema: SchemaRef, + host: GraphQLSPHost +): any { + const program = host.languageService.getProgram(); + const sourceText = (file: string) => + program?.getSourceFile(file)?.text ?? fs.readFileSync(file, 'utf8'); + const location = (file: string, span: ts.TextSpan) => ({ + uri: pathToFileURL(file).href, + range: rangeAt(sourceText(file), span), + }); + const workspaceEdit = (changes: readonly ts.FileTextChanges[]) => ({ + changes: Object.fromEntries( + changes.map(change => [ + pathToFileURL(change.fileName).href, + change.textChanges.map(edit => ({ + range: rangeAt(sourceText(change.fileName), edit.span), + newText: edit.newText, + })), + ]) + ), + }); + const offset = params.position ? offsetAt(text, params.position) : 0; + switch (method) { + case 'textDocument/hover': { + const info = getGraphQLQuickInfo(fileName, offset, schema, host); + if (!info) return; + return { + contents: { + kind: 'markdown', + value: + '```graphql\n' + + (info.displayParts || []).map(part => part.text).join('') + + '\n```\n\n' + + (info.documentation || []).map(part => part.text).join(''), + }, + range: rangeAt(text, info.textSpan), + }; + } + case 'textDocument/completion': { + const info = getGraphQLCompletions(fileName, offset, schema, host); + if (!info) return; + const prefix = /[_0-9A-Za-z]*$/.exec(text.slice(0, offset))![0]; + const suffix = /^[_0-9A-Za-z]*/.exec(text.slice(offset))![0]; + const replacement = rangeAt(text, { + start: offset - prefix.length, + length: prefix.length + suffix.length, + }); + return { + isIncomplete: false, + items: info.entries.map(entry => ({ + label: entry.name, + kind: 5, + sortText: entry.sortText, + detail: entry.labelDetails?.detail, + documentation: entry.labelDetails?.description, + textEdit: { + range: entry.replacementSpan + ? rangeAt(text, entry.replacementSpan) + : replacement, + newText: + entry.insertText?.startsWith('...') && + text.slice(0, offset - prefix.length).endsWith('...') + ? entry.insertText.slice(3) + : entry.insertText || entry.name, + }, + })), + }; + } + case 'textDocument/definition': { + const definitions = getGraphQLDefinitionAtPosition( + fileName, + offset, + schema, + host, + undefined + ); + if (definitions !== undefined) + return definitions.map(definition => + location(definition.fileName, definition.textSpan) + ); + const references = getGraphQLFragmentReferenceEntries( + fileName, + offset, + host + ); + return references + ?.filter(ref => ref.isWriteAccess) + .map(ref => location(ref.fileName, ref.textSpan)); + } + case 'textDocument/references': { + const references = getGraphQLFragmentReferenceEntries( + fileName, + offset, + host + ); + return references + ?.filter( + ref => params.context?.includeDeclaration || !ref.isWriteAccess + ) + .map(ref => location(ref.fileName, ref.textSpan)); + } + case 'textDocument/prepareRename': { + const info = getGraphQLFragmentRenameInfo(fileName, offset, host); + if (info) + return { + range: rangeAt(text, info.triggerSpan), + placeholder: info.displayName, + }; + return; + } + case 'textDocument/rename': { + if ( + typeof params.newName !== 'string' || + !/^[_A-Za-z][_0-9A-Za-z]*$/.test(params.newName) + ) + return; + const references = getGraphQLFragmentRenameLocations( + fileName, + offset, + host + ); + if (!references) return; + const changes = new Map(); + for (const reference of references) { + const edits = changes.get(reference.fileName) || []; + edits.push({ span: reference.textSpan, newText: params.newName }); + changes.set(reference.fileName, edits); + } + return workspaceEdit( + [...changes].map(([fileName, textChanges]) => ({ + fileName, + textChanges, + })) + ); + } + case 'textDocument/codeAction': { + if (!params.range) return; + const start = offsetAt(text, params.range.start), + end = offsetAt(text, params.range.end); + const only: string[] | undefined = params.context?.only; + const wants = (kind: string) => + !only || + only.some(value => kind === value || kind.startsWith(value + '.')); + const actions: any[] = []; + if (wants('quickfix')) { + const codes = (params.context?.diagnostics || []) + .filter((diagnostic: any) => diagnostic.source === 'graphqlsp') + .map((diagnostic: any) => Number(diagnostic.code)); + for (const fix of getGraphQLCodeFixesAtPosition( + fileName, + start, + end, + codes, + schema, + host + )) + actions.push({ + title: fix.description, + kind: 'quickfix', + edit: workspaceEdit(fix.changes), + }); + } + if (wants('refactor.extract')) { + const edits = getExtractFragmentEdits( + fileName, + { pos: start, end }, + schema, + host + ); + if (edits) + actions.push({ + title: 'Extract to fragment', + kind: 'refactor.extract', + edit: workspaceEdit(edits.edits), + }); + } + return actions.length ? actions : undefined; + } + } +} + +export const fileNameFromURI = (uri: string): string | undefined => { + try { + return uri.startsWith('file:') ? fileURLToPath(uri) : undefined; + } catch { + return undefined; + } +}; diff --git a/packages/graphqlsp/src/lsp.ts b/packages/graphqlsp/src/lsp.ts new file mode 100644 index 00000000..1beb6c3c --- /dev/null +++ b/packages/graphqlsp/src/lsp.ts @@ -0,0 +1,690 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { createRequire } from 'node:module'; +import { randomUUID } from 'node:crypto'; +import { fork, spawn, type ChildProcess } from 'node:child_process'; +import { + createLanguageServerSession, + type LanguageServerSession, +} from './session'; +import { + featureMethods, + fileNameFromURI, + offsetAt, + rangeAt, +} from './languageFeatures'; +import { readMessages, writeMessage } from './lspProtocol'; +import type { Message } from './lspProtocol'; + +const log = (message: unknown) => + process.stderr.write(`[graphqlsp] ${message}\n`); +const debug = (message: string) => { + if (process.env.GRAPHQLSP_RELAY_DEBUG) log(message); +}; + +/** Worker IPC keeps synchronous checker calls off the forwarding event loop. */ +async function runWorker(): Promise { + let closing = false; + let queue: Promise = Promise.resolve(); + const send = (message: any) => { + if (!process.connected) return; + // IPC can close between connected and send; the callback owns that error. + try { + process.send?.(message, () => {}); + } catch {} + }; + const session: Promise = createLanguageServerSession({ + packageJsonPath: process.env.GRAPHQLSP_TS7_PACKAGE_JSON!, + pipe: process.env.GRAPHQLSP_TS7_PIPE!, + logger: log, + persist: process.env.GRAPHQLSP_PERSIST !== '0', + onSchemaChange: () => { + if (!closing) send({ type: 'schemaChange' }); + }, + }); + const stop = () => { + if (closing) return; + closing = true; + debug('worker: stop received, waiting for queued handlers'); + void queue.then(async () => { + debug('worker: queued handlers drained; disposing session'); + try { + (await session).dispose(); + debug('worker: session disposal completed'); + } catch (error) { + log(error); + } + if (process.connected) { + try { + debug('worker: sending closed acknowledgement'); + process.send?.({ type: 'closed' }, () => process.exit(0)); + } catch { + process.exit(0); + } + } else process.exit(0); + }); + }; + process.once('disconnect', stop); + process.on('message', (message: any) => { + if (message.type === 'stop') { + stop(); + return; + } + if (closing) return; + queue = queue.then(async () => { + try { + const active = await session; + const result = + message.method === 'check' + ? await active.check(message.fileName) + : await active.request( + message.method, + message.fileName, + message.params + ); + if (!closing) send({ id: message.id, result }); + } catch (error) { + if (!closing) send({ id: message.id, error: String(error) }); + } + }); + }); + await session; + if (!process.connected) stop(); + else if (!closing) send({ type: 'ready' }); +} + +export async function runLanguageServer(): Promise { + if (process.argv.includes('--session-worker')) return runWorker(); + const args = process.argv.slice(2); + if (args.includes('--help')) { + process.stdout.write( + 'Usage: graphqlsp --stdio [--typescript /path/to/typescript] [--runExternalCode]\n' + ); + return; + } + const option = args.indexOf('--typescript'); + if (option >= 0 && (!args[option + 1] || args[option + 1]!.startsWith('--'))) + throw new Error( + '--typescript requires a package directory or package.json' + ); + const packageJsonPath = fs.realpathSync( + option >= 0 + ? path.resolve( + args[option + 1]!, + ...(args[option + 1]!.endsWith('.json') ? [] : ['package.json']) + ) + : createRequire(path.join(process.cwd(), 'package.json')).resolve( + 'typescript/package.json' + ) + ); + const manifest = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')); + const [major, minor] = manifest.version.split('.').map(Number); + if (major < 7 || (major === 7 && minor < 1)) + throw new Error( + 'The GraphQLSP LSP proxy requires TypeScript 7.1 or newer.' + ); + const bin = + typeof manifest.bin === 'string' ? manifest.bin : manifest.bin.tsc; + const server = spawn( + process.execPath, + [ + path.resolve(path.dirname(packageJsonPath), bin), + '--lsp', + '--stdio', + ...(args.includes('--runExternalCode') ? ['--runExternalCode'] : []), + ], + { stdio: ['pipe', 'pipe', 'inherit'], windowsHide: true } + ); + const documents = new Map(); + const nativeRequests = new Map(); + const internal = new Map void>(); + const pending = new Map< + number, + { resolve: (value: any) => void; timeout: NodeJS.Timeout } + >(); + const timers = new Map(); + const graphqlDiagnostics = new Map(); + const nativeDiagnostics = new Map(); + const cancelled = new Set(); + const augmenting = new Set(); + const requestDocuments = new Map< + string | number, + Map + >(); + const internalPrefix = `graphqlsp-${randomUUID()}-`; + const uriKey = (uri: string) => { + const file = fileNameFromURI(uri) || uri; + return process.platform === 'win32' ? file.toLowerCase() : file; + }; + let worker: ChildProcess | undefined, + seq = 0, + stopped = false; + let shuttingDown = false; + let shutdownRequest: Message | undefined; + let shutdownAnswered = false; + let shutdownTimeout: NodeJS.Timeout | undefined; + let ready: Promise = Promise.resolve(); + const native = (message: Message) => writeMessage(server.stdin!, message); + const client = (message: Message) => writeMessage(process.stdout, message); + const answerShutdown = (reply?: Message) => { + if (shutdownAnswered || !shutdownRequest || stopped) return; + shutdownAnswered = true; + clearTimeout(shutdownTimeout); + nativeRequests.delete(shutdownRequest.id!); + client(reply || { jsonrpc: '2.0', id: shutdownRequest.id, result: null }); + }; + const synchronizeDocument = (uri: string): Promise => + new Promise(resolve => { + if (stopped || shuttingDown) { + resolve(); + return; + } + const id = `${internalPrefix}sync-${++seq}`; + const timeout = setTimeout(() => { + debug('parent: worker stop timed out; forcing termination'); + internal.delete(id); + resolve(); + }, 5000); + internal.set(id, () => { + clearTimeout(timeout); + resolve(); + }); + native({ + jsonrpc: '2.0', + id, + method: 'textDocument/hover', + params: { textDocument: { uri }, position: { line: 0, character: 0 } }, + }); + }); + const clearPending = () => { + for (const entry of pending.values()) { + clearTimeout(entry.timeout); + entry.resolve(null); + } + pending.clear(); + }; + let workerStop: Promise | undefined; + const stopWorker = (): Promise => + (workerStop ??= new Promise(resolve => { + clearPending(); + const child = worker; + worker = undefined; + if (!child || child.exitCode !== null || child.signalCode !== null) { + resolve(); + return; + } + const timeout = setTimeout(() => { + child.kill(); + resolve(); + }, 2000); + child.once('exit', () => { + debug('parent: worker exited'); + clearTimeout(timeout); + resolve(); + }); + if (child.connected) debug('parent: sending stop to worker'); + if (child.connected) + child.send({ type: 'stop' }, error => { + if (error) child.kill(); + }); + else child.kill(); + })); + const ask = async ( + method: string, + fileName: string, + params?: any + ): Promise => { + await ready; + if (!worker?.connected || stopped || shuttingDown) return null; + return new Promise(resolve => { + const id = ++seq; + const timeout = setTimeout(() => { + pending.delete(id); + resolve(null); + }, 10000); + pending.set(id, { resolve, timeout }); + worker!.send({ id, method, fileName, params }); + }); + }; + const publish = (uri: string) => { + const document = documents.get(uri); + const original = nativeDiagnostics.get(uri); + // Don't attach diagnostics for an old native snapshot to newer edits. + const diagnostics = + original && + (original.params.version == null || + original.params.version === document?.version) + ? original.params.diagnostics + : []; + client({ + jsonrpc: '2.0', + method: 'textDocument/publishDiagnostics', + params: { + uri, + version: document?.version, + diagnostics: [...diagnostics, ...(graphqlDiagnostics.get(uri) || [])], + }, + }); + }; + const schedule = (uri: string, delay = 250) => { + if (stopped || shuttingDown) return; + clearTimeout(timers.get(uri)); + timers.set( + uri, + setTimeout(async () => { + timers.delete(uri); + const document = documents.get(uri), + fileName = fileNameFromURI(uri); + if (!document || !fileName || stopped || shuttingDown) return; + await synchronizeDocument(uri); + const result = await ask('check', fileName); + if (documents.get(uri) !== document || stopped || shuttingDown) return; + if (result && result.text !== document.text) { + schedule(uri, 100); + return; + } + graphqlDiagnostics.set( + uri, + (result?.diagnostics || []).map((diagnostic: any) => ({ + range: rangeAt(document.text, diagnostic), + message: diagnostic.message, + code: diagnostic.code, + source: 'graphqlsp', + severity: + { error: 1, warning: 2, suggestion: 4, message: 3 }[ + diagnostic.severity as string + ] || 3, + })) + ); + publish(uri); + }, delay) + ); + }; + const startWorker = () => { + if (stopped || shuttingDown) return; + const id = `${internalPrefix}api-${++seq}`; + ready = new Promise(resolve => { + const timeout = setTimeout(() => { + internal.delete(id); + log('Timed out opening the TypeScript API session'); + resolve(); + }, 10000); + internal.set(id, message => { + clearTimeout(timeout); + if (stopped || shuttingDown) { + resolve(); + return; + } + if (message.error) { + log(message.error.message); + resolve(); + return; + } + worker = fork(process.argv[1]!, ['--session-worker'], { + env: { + ...process.env, + NODE_PATH: [ + path.dirname(path.dirname(packageJsonPath)), + process.env.NODE_PATH, + ] + .filter(Boolean) + .join(path.delimiter), + GRAPHQLSP_TS7_PACKAGE_JSON: packageJsonPath, + GRAPHQLSP_TS7_PIPE: message.result.pipe, + GRAPHQLSP_PERSIST: args.includes('--no-persist') ? '0' : '1', + }, + stdio: ['ignore', 'ignore', 'inherit', 'ipc'], + execArgv: [], + }); + const startup = setTimeout(() => { + log('GraphQLSP worker startup timed out'); + worker?.kill(); + resolve(); + }, 10000); + worker.on('message', (response: any) => { + if (response.type === 'ready') { + clearTimeout(startup); + resolve(); + for (const uri of documents.keys()) schedule(uri, 0); + } else if (response.type === 'schemaChange') { + for (const uri of documents.keys()) schedule(uri, 0); + } else { + const entry = pending.get(response.id); + if (entry) { + clearTimeout(entry.timeout); + pending.delete(response.id); + if (response.error) log(response.error); + entry.resolve(response.result ?? null); + } + } + }); + worker.on('error', error => log(error)); + worker.on('exit', () => { + clearTimeout(startup); + clearPending(); + resolve(); + }); + }); + native({ + jsonrpc: '2.0', + id, + method: 'custom/initializeAPISession', + params: {}, + }); + }); + }; + const cleanup = () => { + if (stopped) return; + stopped = true; + clearTimeout(shutdownTimeout); + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + for (const resolve of internal.values()) + resolve({ error: { message: 'Proxy closed' } }); + internal.clear(); + void stopWorker(); + server.kill(); + process.stdin.destroy(); + }; + readMessages( + process.stdin, + message => { + if (message.method === 'exit') { + process.exitCode = shutdownAnswered ? 0 : 1; + cleanup(); + return; + } + if (shutdownAnswered || (shuttingDown && message.method)) { + if (message.id != null && message.method) + client({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32600, message: 'Server is shutting down' }, + }); + return; + } + // TypeScript currently rejects explicit null params on lifecycle messages. + if ( + (message.method === 'shutdown' || message.method === 'exit') && + message.params === null + ) + delete message.params; + if ( + message.method === '$/cancelRequest' && + (nativeRequests.has(message.params.id) || + augmenting.has(message.params.id)) + ) + cancelled.add(message.params.id); + if (message.method === 'textDocument/didOpen') { + const doc = message.params.textDocument; + documents.set(doc.uri, { text: doc.text, version: doc.version }); + schedule(doc.uri); + } + if (message.method === 'textDocument/didChange') { + const uri = message.params.textDocument.uri, + previous = documents.get(uri); + if (previous) { + let text = previous.text; + for (const change of message.params.contentChanges) + text = change.range + ? text.slice(0, offsetAt(text, change.range.start)) + + change.text + + text.slice(offsetAt(text, change.range.end)) + : change.text; + documents.set(uri, { + text, + version: message.params.textDocument.version, + }); + graphqlDiagnostics.delete(uri); + schedule(uri); + } + } + if (message.method === 'textDocument/didSave') + schedule(message.params.textDocument.uri, 0); + if (message.method === 'textDocument/didClose') { + const uri = message.params.textDocument.uri; + clearTimeout(timers.get(uri)); + timers.delete(uri); + documents.delete(uri); + graphqlDiagnostics.delete(uri); + nativeDiagnostics.delete(uri); + client({ + jsonrpc: '2.0', + method: 'textDocument/publishDiagnostics', + params: { uri, diagnostics: [] }, + }); + } + if (message.id != null && message.method) { + if ( + message.method === 'completionItem/resolve' && + message.params?.data?.graphqlsp === internalPrefix + ) { + client({ jsonrpc: '2.0', id: message.id, result: message.params }); + return; + } + nativeRequests.set(message.id, message); + if (featureMethods.has(message.method)) + requestDocuments.set(message.id, new Map(documents)); + } + if (message.method === 'shutdown') { + shuttingDown = true; + shutdownRequest = message; + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + // Native shutdown waits for API sessions. Close ours first, while + // retaining the LSP forwarding loop for the shutdown response. + void stopWorker().then(() => { + debug('parent: forwarding native shutdown'); + if (stopped) return; + shutdownTimeout = setTimeout(() => { + // TS 7.1 can deadlock in API-session cleanup after transport drain. + // Acknowledge our shutdown, terminate the in-memory child, and keep + // the wrapper alive until the client sends exit. + if (shutdownAnswered || stopped) return; + log( + 'Native TypeScript shutdown did not complete in 2s; terminating its process after GraphQL worker cleanup.' + ); + answerShutdown(); + server.kill(); + }, 2000); + native(message); + }); + return; + } + native(message); + if (message.method === 'initialized') startWorker(); + }, + error => { + log(error); + cleanup(); + } + ); + readMessages( + server.stdout!, + message => { + if ( + !message.method && + shutdownAnswered && + message.id === shutdownRequest?.id + ) + return; + if (typeof message.id === 'string' && internal.has(message.id)) { + const resolve = internal.get(message.id)!; + internal.delete(message.id); + resolve(message); + return; + } + if (message.method === 'textDocument/publishDiagnostics') { + nativeDiagnostics.set(message.params.uri, message); + publish(message.params.uri); + return; + } + if (message.method || message.id == null) { + client(message); + return; + } + const request = nativeRequests.get(message.id); + if (request?.method === 'shutdown') { + debug('parent: received native shutdown response'); + answerShutdown(message); + return; + } + nativeRequests.delete(message.id); + const versions = requestDocuments.get(message.id); + requestDocuments.delete(message.id); + if (request?.method === 'initialize' && message.result) { + Object.assign(message.result.capabilities, { + hoverProvider: true, + completionProvider: { + ...message.result.capabilities.completionProvider, + triggerCharacters: [ + ...new Set([ + ...(message.result.capabilities.completionProvider + ?.triggerCharacters || []), + '.', + '@', + '$', + ]), + ], + }, + definitionProvider: true, + referencesProvider: true, + renameProvider: { prepareProvider: true }, + codeActionProvider: { + ...(typeof message.result.capabilities.codeActionProvider === + 'object' + ? message.result.capabilities.codeActionProvider + : {}), + codeActionKinds: [ + ...new Set([ + ...(message.result.capabilities.codeActionProvider + ?.codeActionKinds || []), + 'quickfix', + 'refactor.extract', + ]), + ], + }, + }); + client(message); + return; + } + if (!request || !featureMethods.has(request.method!)) { + cancelled.delete(message.id); + client(message); + return; + } + const uri = request.params?.textDocument?.uri, + document = versions?.get(uri), + fileName = fileNameFromURI(uri || ''); + if (!document || !fileName) { + cancelled.delete(message.id); + client(message); + return; + } + augmenting.add(message.id); + void (async () => { + let response: any; + for (let retry = 0; retry < 20; retry++) { + if ( + cancelled.has(message.id!) || + documents.get(uri) !== document || + stopped + ) + break; + response = await ask(request.method!, fileName, request.params); + if (!response || response.text === document.text) break; + await new Promise(resolve => setTimeout(resolve, 100)); + } + if (stopped || shutdownAnswered) return; + if (cancelled.delete(message.id!)) { + client({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32800, message: 'Request cancelled' }, + }); + return; + } + if (documents.get(uri) !== document) { + client({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32801, message: 'Document changed' }, + }); + return; + } + if (response?.text === document.text && response.result != null) { + if (request.method === 'textDocument/completion') { + for (const item of response.result.items) + item.data = { graphqlsp: internalPrefix }; + } + const edits = + response.result.changes || + (Array.isArray(response.result) + ? Object.assign( + {}, + ...response.result.map((action: any) => action.edit?.changes) + ) + : undefined); + if ( + edits && + Object.keys(edits).some(uri => { + const openURI = + [...documents.keys(), ...(versions?.keys() || [])].find( + open => uriKey(open) === uriKey(uri) + ) || uri; + return documents.get(openURI) !== versions?.get(openURI); + }) + ) { + client({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32801, message: 'An edited document changed' }, + }); + return; + } + const result = + request.method === 'textDocument/codeAction' + ? [...(message.result || []), ...response.result] + : response.result; + client({ jsonrpc: '2.0', id: message.id, result }); + } else client(message); + })() + .catch(error => { + log(error); + client(message); + }) + .finally(() => { + augmenting.delete(message.id!); + cancelled.delete(message.id!); + }); + }, + error => { + log(error); + cleanup(); + } + ); + server.on('error', error => { + log(error); + cleanup(); + process.exitCode = 1; + }); + server.on('exit', code => { + if (stopped) return; + if (shuttingDown) { + void stopWorker().then(() => answerShutdown()); + return; + } + cleanup(); + process.exitCode = code || 0; + process.stdin.destroy(); + }); + server.stdin!.on('error', error => { + if (shuttingDown) return; + if (!stopped) log(error); + cleanup(); + }); + process.stdout.on('error', cleanup); + process.stdin.on('end', cleanup); + process.on('SIGTERM', cleanup); + process.on('SIGINT', cleanup); +} diff --git a/packages/graphqlsp/src/lspProtocol.ts b/packages/graphqlsp/src/lspProtocol.ts new file mode 100644 index 00000000..453db1b6 --- /dev/null +++ b/packages/graphqlsp/src/lspProtocol.ts @@ -0,0 +1,55 @@ +import type { Readable, Writable } from 'node:stream'; + +export interface Message { + jsonrpc?: string; + id?: string | number | null; + method?: string; + params?: any; + result?: any; + error?: any; +} + +/** LSP framing uses UTF-8 byte counts, not JavaScript string lengths. */ +export function readMessages( + input: Readable, + receive: (message: Message) => void, + fail: (error: Error) => void +): void { + let buffer = Buffer.alloc(0); + input.on('data', (chunk: Buffer) => { + try { + buffer = Buffer.concat([buffer, chunk]); + for (;;) { + const end = buffer.indexOf('\r\n\r\n'); + if (end < 0) { + if (buffer.length > 8192) throw new Error('LSP header exceeds 8 KiB'); + return; + } + const match = /(?:^|\r\n)Content-Length:\s*(\d+)/i.exec( + buffer.subarray(0, end).toString('ascii') + ); + const length = match && Number(match[1]); + if ( + length === null || + !Number.isSafeInteger(length) || + length < 0 || + length > 64 * 1024 * 1024 + ) + throw new Error('Invalid LSP Content-Length'); + if (buffer.length < end + 4 + length) return; + const body = buffer.subarray(end + 4, end + 4 + length); + buffer = buffer.subarray(end + 4 + length); + receive(JSON.parse(body.toString('utf8'))); + } + } catch (error) { + fail(error as Error); + } + }); +} + +export function writeMessage(output: Writable, message: Message): void { + const body = Buffer.from(JSON.stringify(message)); + output.write( + Buffer.concat([Buffer.from(`Content-Length: ${body.length}\r\n\r\n`), body]) + ); +} diff --git a/packages/graphqlsp/src/quickInfo.ts b/packages/graphqlsp/src/quickInfo.ts index a1f7aba3..3d67bdcf 100644 --- a/packages/graphqlsp/src/quickInfo.ts +++ b/packages/graphqlsp/src/quickInfo.ts @@ -1,3 +1,4 @@ +import type { GraphQLSPHost } from './host'; import { ts } from './ts'; import { getTokenAtPosition, getTypeInfo } from 'graphql-language-service'; import type { GraphQLArgument, GraphQLSchema } from 'graphql'; @@ -134,7 +135,7 @@ export function getGraphQLQuickInfo( filename: string, cursorPosition: number, schema: SchemaRef, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.QuickInfo | undefined { const isCallExpression = info.config.templateIsCallExpression ?? true; const typeChecker = info.languageService.getProgram()?.getTypeChecker(); diff --git a/packages/graphqlsp/src/references.ts b/packages/graphqlsp/src/references.ts index e276185e..fafc90a4 100644 --- a/packages/graphqlsp/src/references.ts +++ b/packages/graphqlsp/src/references.ts @@ -1,3 +1,4 @@ +import type { GraphQLSPHost, HostChecker } from './host'; import type { NameNode } from 'graphql'; import { parse, visit } from 'graphql'; @@ -81,7 +82,7 @@ const findFragmentNameAtOffset = ( const getFragmentTokenAtPosition = ( filename: string, cursorPosition: number, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): FragmentNameToken | undefined => { const isCallExpression = info.config.templateIsCallExpression ?? true; const typeChecker = info.languageService.getProgram()?.getTypeChecker(); @@ -142,8 +143,8 @@ interface FragmentDocument { const collectCallDependencies = ( callNode: checks.GraphQLCallNode, - info: ts.server.PluginCreateInfo, - typeChecker: ts.TypeChecker | undefined, + info: GraphQLSPHost, + typeChecker: HostChecker | undefined, dependencies = new Set() ): Set => { const fragmentRefs = resolveTadaFragmentArray(callNode.arguments[1]); @@ -209,7 +210,7 @@ const parseFragmentDocument = ( const collectFragmentDocuments = ( token: FragmentNameToken, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): FragmentDocument[] => { const program = info.languageService.getProgram(); if (!program) return []; @@ -217,7 +218,7 @@ const collectFragmentDocuments = ( const isCallExpression = info.config.templateIsCallExpression ?? true; const typeChecker = program.getTypeChecker(); const documents: FragmentDocument[] = []; - for (const source of program.getSourceFiles()) { + for (const source of program.getSourceFiles?.() || []) { if (source.isDeclarationFile || source.fileName.includes('node_modules')) { continue; } @@ -234,7 +235,9 @@ const collectFragmentDocuments = ( if (found.schema !== token.schemaName) continue; if (!ts.isStringLiteralLike(found.node)) continue; const parent = found.node.parent; - const callNode = ts.isCallExpression(parent) ? parent : undefined; + const callNode = checks.isGraphQLCall(parent, typeChecker) + ? parent + : undefined; const document = parseFragmentDocument( source, found.node, @@ -269,7 +272,7 @@ const collectFragmentDocuments = ( const findAllFragmentReferences = ( token: FragmentNameToken, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): FragmentReferenceLocation[] => { const documents = collectFragmentDocuments(token, info); if (!documents.length) return []; @@ -329,7 +332,7 @@ const findAllFragmentReferences = ( export function getGraphQLFragmentReferences( filename: string, cursorPosition: number, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.ReferencedSymbol[] | undefined { const token = getFragmentTokenAtPosition(filename, cursorPosition, info); if (!token) return undefined; @@ -337,7 +340,8 @@ export function getGraphQLFragmentReferences( const references = findAllFragmentReferences(token, info); if (!references.length) return undefined; - const definition = references.find(ref => ref.isDefinition) || references[0]; + const definition = (references.find(ref => ref.isDefinition) || + references[0])!; return [ { @@ -363,7 +367,7 @@ export function getGraphQLFragmentReferences( export function getGraphQLFragmentReferenceEntries( filename: string, cursorPosition: number, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.ReferenceEntry[] | undefined { const token = getFragmentTokenAtPosition(filename, cursorPosition, info); if (!token) return undefined; @@ -381,7 +385,7 @@ export function getGraphQLFragmentReferenceEntries( export function getGraphQLFragmentRenameInfo( filename: string, cursorPosition: number, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): ts.RenameInfoSuccess | undefined { const token = getFragmentTokenAtPosition(filename, cursorPosition, info); if (!token) return undefined; @@ -399,7 +403,7 @@ export function getGraphQLFragmentRenameInfo( export function getGraphQLFragmentRenameLocations( filename: string, cursorPosition: number, - info: ts.server.PluginCreateInfo + info: GraphQLSPHost ): readonly ts.RenameLocation[] | undefined { const token = getFragmentTokenAtPosition(filename, cursorPosition, info); if (!token) return undefined; diff --git a/packages/graphqlsp/src/session.ts b/packages/graphqlsp/src/session.ts index 526a4b4e..d1fae33f 100644 --- a/packages/graphqlsp/src/session.ts +++ b/packages/graphqlsp/src/session.ts @@ -1,4 +1,8 @@ import path from 'node:path'; +import fs from 'node:fs'; +import { getLanguageFeature } from './languageFeatures'; +import type { Position } from './languageFeatures'; +import { clearGraphQLDiagnosticsCache } from './diagnostics'; import { ts, init } from './ts'; import { templates } from './ast/templates'; @@ -46,7 +50,24 @@ export interface LanguageServerCheckResult { diagnostics: CheckDiagnostic[]; } +export interface LanguageServerFeatureResult { + text: string; + result: any; +} export interface LanguageServerSession { + request( + method: string, + fileName: string, + params: any + ): Promise; + hover( + fileName: string, + position: Position + ): Promise; + complete( + fileName: string, + position: Position + ): Promise; /** Reports GraphQLSP's diagnostics for a file in the language server's * current state, including unsaved changes it received from editors. * Returns `null` for files outside of projects configuring GraphQLSP. */ @@ -59,6 +80,8 @@ interface ProjectState { schema: SchemaRef; /** Swapped for the program of the snapshot a file is checked in. */ program: HostProgram | undefined; + ready?: boolean; + stopWatching(): void; } /** Runs GraphQLSP's diagnostics on a TypeScript 7.1 language server's @@ -102,6 +125,24 @@ export async function createLanguageServerSession( throw error; } } + if (relay) { + // API.close() releases retained files and snapshots before client.close(). + // Drain at that final boundary, while the underlying channel is usable. + const client = api.client; + const close = client.close.bind(client); + let closed = false; + client.close = () => { + if (closed) return; + closed = true; + try { + relay!.drain(client); + } catch (error) { + logger(`API relay already closed while draining: ${error}`); + } finally { + close(); + } + }; + } const typescript = createTypeScriptNamespace(compiler); const projects = new Map(); @@ -118,7 +159,6 @@ export async function createLanguageServerSession( return null; } - if (plugin.config.template) templates.add(plugin.config.template); const host: GraphQLSPHost = { config: plugin.config, languageService: { @@ -129,68 +169,133 @@ export async function createLanguageServerSession( projectService: { logger: { info: logger } }, }, }; + const stopWatching: (() => void)[] = []; state = { host, program: undefined, schema: loadSchema(host, logger, { rootPath: path.dirname(plugin.configPath), - // Schemas are checked for changes on every check instead + // @gql.tada/internal's watcher depends on the legacy TypeScript sys API. + // Sessions own portable Node watchers instead. watch: false, persist: options.persist !== false, onChange: options.onSchemaChange, }), + stopWatching: () => { + for (const stop of stopWatching) stop(); + }, }; + const ownedState = state; + void state.schema.ready.then(() => { + if (disposed) return; + for (const schemaPath of ownedState.schema.sourceLocations.values()) { + const changed = () => ownedState.schema.checkStale(true); + fs.watchFile(schemaPath, { persistent: false, interval: 500 }, changed); + stopWatching.push(() => fs.unwatchFile(schemaPath, changed)); + } + }); projects.set(configFileName, state); return state; }; - return { - async check(fileName) { - let snapshot = api.getCurrentLanguageServerSnapshot(); - try { - let project = snapshot.getDefaultProjectForFile(fileName); - if (!project || !project.configFileName) return null; - const state = getProjectState(project.configFileName); - if (!state) return null; + let disposed = false; + let queue: Promise = Promise.resolve(); + const run = (operation: () => Promise): Promise => { + const result = queue.then(() => { + if (disposed) throw new Error('GraphQLSP session is disposed'); + return operation(); + }); + queue = result.catch(() => {}); + return result; + }; + const withProject = async ( + fileName: string, + callback: (state: ProjectState, text: string) => any + ): Promise => { + let snapshot = api.getCurrentLanguageServerSnapshot(); + try { + let project = snapshot.getDefaultProjectForFile(fileName); + if (!project || !project.configFileName) return null; + const state = getProjectState(project.configFileName); + if (!state) return null; - if (!state.schema.current && !Object.keys(state.schema.multi).length) { - // While the schema loads, the language server may receive changes - await state.schema.ready; - snapshot.dispose(); - snapshot = api.getCurrentLanguageServerSnapshot(); - project = snapshot.getDefaultProjectForFile(fileName); - if (!project) return null; - } - state.schema.checkStale(); + if (!state.ready) { + // While the schema loads, the language server may receive changes + await state.schema.ready; + state.ready = true; + snapshot.dispose(); + snapshot = api.getCurrentLanguageServerSnapshot(); + project = snapshot.getDefaultProjectForFile(fileName); + if (!project) return null; + } + state.schema.checkStale(); - const source = project.program.getSourceFile(fileName); - if (!source) return null; - const program = project.program; - const checker = createChecker(project.checker, project); - state.program = { - getSourceFile: (fileName: string) => program.getSourceFile(fileName), - getTypeChecker: () => checker, - }; + const source = project.program.getSourceFile(fileName); + if (!source) return null; + const program = project.program; + const checker = createChecker(project.checker, project); + state.program = { + getSourceFile: (fileName: string) => program.getSourceFile(fileName), + getTypeChecker: () => checker, + getSourceFiles: () => + program + .getSourceFileNames() + .map((name: string) => program.getSourceFile(name)) + .filter(Boolean), + }; - const previousTypeScript = ts; - init({ typescript }); - try { - const diagnostics = - getGraphQLDiagnostics(fileName, state.schema, state.host) || []; - return { - text: source.text, - diagnostics: toCheckDiagnostics(fileName, diagnostics), - }; - } finally { - init({ typescript: previousTypeScript }); - state.program = undefined; - } + const previousTypeScript = ts; + const template = state.host.config.template; + const hadTemplate = templates.has(template); + if (template) templates.add(template); + init({ typescript }); + try { + clearGraphQLDiagnosticsCache(); + return callback(state, source.text); } finally { - snapshot.dispose(); + init({ typescript: previousTypeScript }); + if (template && !hadTemplate) templates.delete(template); + state.program = undefined; } - }, - + } finally { + snapshot.dispose(); + } + }; + const request = (method: string, fileName: string, params: any) => + run(() => + withProject(fileName, (state, text) => ({ + text, + result: + getLanguageFeature( + method, + params, + fileName, + text, + state.schema, + state.host + ) ?? null, + })) + ); + return { + check: fileName => + run(() => + withProject(fileName, (state, text) => ({ + text, + diagnostics: toCheckDiagnostics( + fileName, + getGraphQLDiagnostics(fileName, state.schema, state.host) || [] + ), + })) + ), + request, + hover: (fileName, position) => + request('textDocument/hover', fileName, { position }), + complete: (fileName, position) => + request('textDocument/completion', fileName, { position }), dispose() { + if (disposed) return; + disposed = true; + for (const state of projects.values()) state?.stopWatching(); projects.clear(); try { api.close(); diff --git a/packages/graphqlsp/src/ts7/namespace.ts b/packages/graphqlsp/src/ts7/namespace.ts index b07f43ae..54138fa1 100644 --- a/packages/graphqlsp/src/ts7/namespace.ts +++ b/packages/graphqlsp/src/ts7/namespace.ts @@ -29,6 +29,26 @@ export const createTypeScriptNamespace = ( ...is, SyntaxKind, + ScriptElementKind: { + label: 'label', + variableElement: 'var', + constElement: 'const', + unknown: '', + memberVariableElement: 'property', + interfaceElement: 'interface', + scriptElement: 'script', + }, + ScriptElementKindModifier: { none: '' }, + flattenDiagnosticMessageText: (message: any, newline: string): string => + typeof message === 'string' + ? message + : [ + message.messageText, + ...(message.next || []).map((part: any) => + namespace.flattenDiagnosticMessageText(part, newline) + ), + ].join(newline), + getLeadingCommentRanges: ast.getLeadingCommentRanges, SymbolFlags: sync.SymbolFlags, TypeFlags: sync.TypeFlags, DiagnosticCategory: sync.DiagnosticCategory, diff --git a/packages/graphqlsp/src/ts7/relay.ts b/packages/graphqlsp/src/ts7/relay.ts index 9389bb3d..951610c0 100644 --- a/packages/graphqlsp/src/ts7/relay.ts +++ b/packages/graphqlsp/src/ts7/relay.ts @@ -1,6 +1,7 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; +import { createWindowsRelay } from './windowsRelay'; /** The relay's source, run as a CommonJS script by the sync client. * @@ -33,11 +34,21 @@ const BINARY_METHODS = new Set([ ]); const socket = net.createConnection(process.argv[2]); +let closingMethod; +socket.on('end', () => { if (process.env.GRAPHQLSP_RELAY_DEBUG) process.stderr.write('relay remote end\n'); }); +if (process.env.GRAPHQLSP_RELAY_DEBUG) socket.on('connect', () => process.stderr.write('relay connected\n')); socket.on('error', error => { process.stderr.write('[graphqlsp] API relay: ' + error.message + '\n'); process.exit(1); }); -socket.on('close', () => process.exit(0)); +socket.on('close', () => { + if (process.env.GRAPHQLSP_RELAY_DEBUG) process.stderr.write('relay remote close\n'); + if (!closingMethod) return process.exit(0); + // The remote JSON-RPC loop drains its handlers before closing its socket. + // Flush our acknowledgement before exiting the local binary relay. + writeTuple(MSG_RESPONSE, closingMethod, Buffer.from('null')); + process.stdout.end(() => process.exit(0)); +}); process.stdin.on('end', () => socket.end()); let seq = 0; @@ -81,6 +92,7 @@ const send = message => { }; const respond = (method, message) => { + if (process.env.GRAPHQLSP_RELAY_DEBUG) process.stderr.write('relay response ' + method + '\n'); if (message.error) { writeTuple(MSG_ERROR, method, Buffer.from(String(message.error.message))); } else if (BINARY_METHODS.has(method)) { @@ -162,6 +174,13 @@ process.stdin.on('data', chunk => { stdinBuffer = stdinBuffer.subarray(payload.end); if (type !== MSG_REQUEST) continue; const method = name.value.toString(); + if (method === '__graphqlspClose') { + if (process.env.GRAPHQLSP_RELAY_DEBUG) process.stderr.write('relay close requested\n'); + closingMethod = method; + socket.end(); + continue; + } + if (process.env.GRAPHQLSP_RELAY_DEBUG) process.stderr.write('relay request ' + method + '\n'); const id = ++seq; pending.set(id, method); const text = payload.value.toString(); @@ -178,6 +197,8 @@ process.stdin.on('data', chunk => { export interface APIRelay { /** Passed as the sync client's `tsserverPath`, which it spawns. */ tsserverPath: string; + /** Half-closes the API socket and waits for remote request-handler drain. */ + drain(client: { apiRequest(method: string, params: unknown): unknown }): void; dispose(): void; } @@ -192,27 +213,42 @@ const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; * extension host, that is Electron, hence `ELECTRON_RUN_AS_NODE`. */ export const createAPIRelay = (pipe: string): APIRelay => { - if (process.platform === 'win32') { - throw new Error( - "TypeScript 7.1's sync API client can't connect to API pipes on Windows yet." - ); - } const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'graphqlsp-')); const relayPath = path.join(directory, 'relay.cjs'); - const tsserverPath = path.join(directory, 'tsserver'); fs.writeFileSync(relayPath, relaySource); - fs.writeFileSync( - tsserverPath, - '#!/bin/sh\n' + - `ELECTRON_RUN_AS_NODE=1 exec ${quote(process.execPath)} ${quote( - relayPath - )} ${quote(pipe)}\n`, - { mode: 0o755 } - ); + let tsserverPath: string; + try { + tsserverPath = + process.platform === 'win32' + ? createWindowsRelay(directory, relayPath, pipe) + : path.join(directory, 'tsserver'); + if (process.platform !== 'win32') { + fs.writeFileSync( + tsserverPath, + '#!/bin/sh\n' + + `ELECTRON_RUN_AS_NODE=1 exec ${quote(process.execPath)} ${quote( + relayPath + )} ${quote(pipe)}\n`, + { mode: 0o755 } + ); + } + } catch (error) { + fs.rmSync(directory, { recursive: true, force: true }); + throw error; + } return { tsserverPath, + drain(client) { + client.apiRequest('__graphqlspClose', null); + }, dispose() { - fs.rmSync(directory, { recursive: true, force: true }); + // Windows may still be closing the launcher executable after api.close(). + fs.rmSync(directory, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 25, + }); }, }; }; diff --git a/packages/graphqlsp/src/ts7/windowsRelay.ts b/packages/graphqlsp/src/ts7/windowsRelay.ts new file mode 100644 index 00000000..b7e10223 --- /dev/null +++ b/packages/graphqlsp/src/ts7/windowsRelay.ts @@ -0,0 +1,89 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; + +/** Small .NET Framework launcher for the sync client's Windows pipe transport. + * It forwards the binary pipe to the same Node relay used on Unix. Keeping the + * source here lets universal npm tarballs build it without shipping an opaque exe. + */ +const source = String.raw` +using System; +using System.IO; +using System.IO.Pipes; +using System.Diagnostics; +using System.Threading.Tasks; +class Relay { + static void Pump(Stream input, Stream output) { + byte[] buffer = new byte[65536]; + int count; + while ((count = input.Read(buffer, 0, buffer.Length)) > 0) { + output.Write(buffer, 0, count); + output.Flush(); + } + } + static string Quote(string value) { + return "\"" + value + "\""; + } + static int Main(string[] args) { + Process child = null; + try { + string pipe = null; + for (int i = 0; i + 1 < args.Length; i++) if (args[i] == "--pipe") pipe = args[i + 1]; + if (pipe == null) throw new Exception("Missing --pipe"); + string[] config = File.ReadAllLines(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "launcher.config")); + using (var channel = new NamedPipeServerStream(pipe.Substring(9), PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous)) { + channel.WaitForConnection(); + var info = new ProcessStartInfo(config[0], Quote(config[1]) + " " + Quote(config[2])); + info.UseShellExecute = false; + info.CreateNoWindow = true; + info.RedirectStandardInput = true; + info.RedirectStandardOutput = true; + info.EnvironmentVariables["ELECTRON_RUN_AS_NODE"] = "1"; + child = Process.Start(info); + var input = Task.Factory.StartNew(() => Pump(channel, child.StandardInput.BaseStream)); + var output = Task.Factory.StartNew(() => Pump(child.StandardOutput.BaseStream, channel)); + Task.WaitAny(input, output); + } + return 0; + } catch (Exception error) { + Console.Error.WriteLine("[graphqlsp] Windows API relay: " + error.Message); + return 1; + } finally { + if (child != null) { try { if (!child.HasExited) child.Kill(); } catch {} child.Dispose(); } + } + } +} +`; + +export function createWindowsRelay( + directory: string, + relayPath: string, + pipe: string +): string { + const framework = path.join( + process.env.WINDIR || 'C:\\Windows', + 'Microsoft.NET' + ); + const compiler = ['Framework64', 'Framework'] + .map(architecture => + path.join(framework, architecture, 'v4.0.30319', 'csc.exe') + ) + .find(file => fs.existsSync(file)); + if (!compiler) + throw new Error( + 'GraphQLSP needs .NET Framework 4.x to build its Windows API relay.' + ); + const sourcePath = path.join(directory, 'launcher.cs'); + const executable = path.join(directory, 'tsserver.exe'); + fs.writeFileSync(sourcePath, source); + fs.writeFileSync( + path.join(directory, 'launcher.config'), + [process.execPath, relayPath, pipe].join('\n') + ); + execFileSync( + compiler, + ['/nologo', '/target:exe', `/out:${executable}`, sourcePath], + { windowsHide: true, stdio: 'pipe' } + ); + return executable; +} diff --git a/packages/vscode-graphqlsp/README.md b/packages/vscode-graphqlsp/README.md index 31a90697..da13fd0c 100644 --- a/packages/vscode-graphqlsp/README.md +++ b/packages/vscode-graphqlsp/README.md @@ -3,9 +3,12 @@ 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 5/6 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. +- **TypeScript 7.1+ integration** — connects to the native TypeScript extension's + API session for diagnostics, hover, completion, schema navigation, fragment + references and rename, quick fixes, and fragment extraction. - **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`). @@ -32,13 +35,17 @@ the configuration shared with your whole team and CI: } ``` +With TypeScript 7, also follow [TypeScript 7](#typescript-7) below to activate +the native integration. The npm package and tsconfig entry alone do not enable +GraphQLSP in the native server. + 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: +With TypeScript 5/6, the plugin can alternatively be configured through VSCode +settings, for example for projects whose tsconfig you don't control: ```jsonc { @@ -67,27 +74,109 @@ only the syntax highlighting is active. ## TypeScript 7 -TypeScript 7 doesn't load TypeScript server plugins. When -[TypeScript 7's extension](https://marketplace.visualstudio.com/items?itemName=TypeScriptTeam.native-preview) -runs its language server (7.1 or later), this extension instead connects to an API session of -it, and reports GraphQLSP's diagnostics for open JavaScript and TypeScript files, including -their unsaved changes. TypeScript 5/6 keep using the plugin. +TypeScript 7 does not load TypeScript server plugins. This extension connects +to the native server's API session instead. **TypeScript 7.1+** is required; +7.0 does not expose the API used by GraphQLSP. TypeScript 5/6 keep using the plugin. + +1. Install this extension's VSIX with **Extensions: Install from VSIX…**, and + install a compatible TypeScript native extension, such as + [TypeScript Native Preview](https://marketplace.visualstudio.com/items?itemName=TypeScriptTeam.native-preview). +2. Install TypeScript 7.1+ in your project. Local validation used + `npm install -D typescript@7.1.0-beta`. +3. Select that SDK and enable native mode. The locally tested `.vscode/settings.json` is: + + ```json + { + "js/ts.experimental.useTsgo": true, + "js/ts.tsdk.path": "./node_modules/typescript" + } + ``` + +4. Configure the plugin and schema in tsconfig as above. Restart the + TypeScript server, then open a JS/TS file belonging to that project. + +The VSIX includes GraphQLSP; installing its npm package in each project is +optional for this editor integration. The project still needs its own +GraphQL dependencies. GraphQLSP uses the same native server as the TypeScript +extension, including its unsaved document snapshots. With TypeScript 7, only projects that configure the plugin in their `tsconfig.json` are -checked; the `graphqlsp.*` settings don't apply, and hover information and completions -aren't provided yet. Windows isn't supported yet. +checked; the `graphqlsp.*` settings don't apply. The extension also provides hover, +completion, schema-field navigation, fragment references and rename, quick fixes, +and fragment extraction in call-expression mode. Local schema files are watched, +including deletion and recreation; save schema edits for the watcher to see them. +Paths resolve relative to the tsconfig declaring the plugin entry, including +inherited configurations. Restart the native server after changing the plugin +settings or schema paths. +Remote URL schemas load once per native API session; restart the server to +refresh them. Automatic watching applies to local schema files. + +Native server restarts reconnect automatically. Switching back to TS 5/6, or +resetting explicit native-mode enablement, disposes the native diagnostics and +providers. The deprecated `typescript.experimental.useTsgo` setting alias is +also recognized with the native extension's scope precedence. + +On Windows, the sync API relay builds an included launcher using the installed +**.NET Framework 4.x C# compiler**. No .NET SDK or download is needed. The TS 7 +path does not reproduce navigation from generated gql.tada turbo-cache result +types back into queries. This extension supplies syntax highlighting for standalone +`.graphql` files, but its native API language features target embedded GraphQL +in JS/TS; use a GraphQL language server for standalone files. + +Hover uses TypeScript's response middleware when available. Completion items +always belong to GraphQLSP's own provider so the native completion resolver +never receives them; middleware suppresses native suggestions in GraphQL contexts. Rename uses a provider so native +`prepareRename` errors inside strings cannot hide GraphQL fragment renames. + +### Using the npm package without this extension + +The npm package also ships `graphqlsp --stdio`, a TypeScript LSP proxy. Use an +LSP client configured to start it as your TypeScript server: + +```sh +node ./node_modules/@0no-co/graphqlsp/bin/graphqlsp.cjs --stdio +``` + +Run it with the project root as the working directory. It starts the native +TypeScript server itself. Use either this extension's native integration or +the proxy for a given set of files to avoid duplicate results. Stock VS Code +does not discover and start the proxy from an npm install or tsconfig entry. +See the [npm package's guide](https://github.com/0no-co/GraphQLSP/blob/main/packages/graphqlsp/README.md#without-a-vsix-npm-and-an-lsp-client) +for installation, client configuration, compiler selection, and flags. + +### Troubleshooting + +Open **View: Toggle Output** and select **GraphQLSP** to inspect native API +messages. If no GraphQL features appear, verify the native extension is active, +the server and selected SDK both use TypeScript 7.1+, and the file belongs to +a tsconfig project with a valid GraphQLSP schema. A project inferred without +a tsconfig entry is skipped. For older TypeScript extension APIs, explicitly +select the workspace SDK and allow that extension to use it. +This extension requires a trusted workspace; VS Code's Restricted Mode disables it. + +If `graphqlsp.*` settings have no effect under TS 7, move that configuration to +the tsconfig plugin entry. For Windows relay failures, inspect the Output +message and verify .NET Framework 4.x's `csc.exe` is installed. + +### Packaging and release status + +The extension is released separately from the npm package. CI builds a `.vsix` +artifact on Windows, macOS, and Linux; install it with **Extensions: Install from +VSIX…**. Marketplace publishing remains a separate maintainer action. +Locally generated packages and VSIX files are not automatically published. ## Development ```sh pnpm install +pnpm --filter @0no-co/graphqlsp build 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]`. +`[GraphQLSP]`. Native TS 7 messages appear in the **GraphQLSP** Output channel. To build an installable `.vsix`: @@ -98,3 +187,30 @@ 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. + +## Real-editor dogfooding + +After building the VSIX, run the isolated editor harness against an installed +VS Code executable and its CLI JavaScript entry point: + +```sh +node scripts/dogfood-vscode.mjs --code /path/to/Code --cli /path/to/resources/app/out/cli.js +``` + +The harness installs the built VSIX and Microsoft's TypeScript extension into a +fresh temporary profile, copies the TS 7 fixture, and runs actual VS Code editor +commands. It exercises unsaved diagnostics, hover, resolved completion items and +application, schema navigation, cross-file references/rename, quick fixes, +fragment extraction, schema edits/deletion/recreation, native server restart, +and switching between TypeScript 6 and 7. It writes a JSON evidence report +(`--results /path/to/results.json`) and retains the isolated profile and logs +for inspection. It does not change the user's normal profile. + +Run the command from the repository root after `pnpm install` and packaging. +The install phase needs Marketplace access; this is an actual editor test and +requires a desktop session. `--vsix /path/to/file.vsix` selects an artifact +explicitly; the default uses the extension manifest's current version. + +For architecture, regression commands, typechecks, packaging outputs, and +the distinction between local validation and configured cross-platform CI, +see [TypeScript 7 development and validation](https://github.com/0no-co/GraphQLSP/blob/main/docs/typescript-7-development.md). diff --git a/packages/vscode-graphqlsp/package.json b/packages/vscode-graphqlsp/package.json index ddcc3462..08aa54b2 100644 --- a/packages/vscode-graphqlsp/package.json +++ b/packages/vscode-graphqlsp/package.json @@ -1,7 +1,7 @@ { "name": "vscode-graphqlsp", "displayName": "GraphQLSP", - "version": "0.1.0", + "version": "0.2.1", "private": true, "description": "GraphQL syntax highlighting and schema-aware TypeScript support — diagnostics, auto-complete, hover information, and go-to-definition for GraphQL documents — powered by GraphQLSP.", "publisher": "0no-co", diff --git a/packages/vscode-graphqlsp/scripts/package.mjs b/packages/vscode-graphqlsp/scripts/package.mjs index 8b83c04c..29ad12aa 100644 --- a/packages/vscode-graphqlsp/scripts/package.mjs +++ b/packages/vscode-graphqlsp/scripts/package.mjs @@ -11,6 +11,7 @@ import { execFileSync } from 'node:child_process'; import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { createRequire } from 'node:module'; const extensionRoot = path.resolve( path.dirname(fileURLToPath(import.meta.url)), @@ -21,11 +22,27 @@ const stagingDir = path.join(extensionRoot, '.staging'); const run = (command, args, cwd) => { console.log(`> ${command} ${args.join(' ')}`); - execFileSync(command, args, { cwd, stdio: 'inherit' }); + // Windows .cmd shims need a shell. Invoke the JavaScript CLIs directly so + // paths containing spaces work without shell command construction. + if (command === 'pnpm' && process.env.npm_execpath) { + args = [process.env.npm_execpath, ...args]; + command = process.execPath; + } else if (command === 'npm' && process.platform === 'win32') { + args = [ + path.join( + path.dirname(process.execPath), + 'node_modules/npm/bin/npm-cli.js' + ), + ...args, + ]; + command = process.execPath; + } + execFileSync(command, args, { cwd, stdio: 'inherit', windowsHide: true }); }; fs.rmSync(stagingDir, { recursive: true, force: true }); fs.mkdirSync(stagingDir, { recursive: true }); +run('pnpm', ['--filter', '@0no-co/graphqlsp', 'build'], repoRoot); // Pack the workspace's graphqlsp into a tarball (this builds it through its // prepublishOnly script) @@ -139,8 +156,13 @@ const output = path.join( `vscode-graphqlsp-${manifest.version}.vsix` ); run( - path.join(extensionRoot, 'node_modules', '.bin', 'vsce'), - ['package', '--out', output], + process.execPath, + [ + createRequire(import.meta.url).resolve('@vscode/vsce/vsce'), + 'package', + '--out', + output, + ], stagingDir ); console.log(`\nPackaged ${output}`); diff --git a/packages/vscode-graphqlsp/src/typescript7/editVersions.ts b/packages/vscode-graphqlsp/src/typescript7/editVersions.ts new file mode 100644 index 00000000..90501aa7 --- /dev/null +++ b/packages/vscode-graphqlsp/src/typescript7/editVersions.ts @@ -0,0 +1,41 @@ +import { fileURLToPath } from 'node:url'; + +interface Document { + uri: { toString(): string }; + version: number; +} +const key = (uri: string): string => { + try { + const file = fileURLToPath(uri); + return process.platform === 'win32' ? file.toLowerCase() : file; + } catch { + return uri; + } +}; + +export const captureDocumentVersions = (documents: readonly Document[]) => + new Map( + documents.map(document => [ + key(document.uri.toString()), + { document, version: document.version }, + ]) + ); + +/** Prevent edits computed for a snapshot from changing a different version. */ +export function editsAreCurrent( + result: any, + before: ReturnType, + documents: readonly Document[] +): boolean { + const current = captureDocumentVersions(documents); + const changes = Array.isArray(result) + ? result.flatMap(action => Object.keys(action.edit?.changes || {})) + : Object.keys(result?.changes || {}); + return changes.every(uri => { + const previous = before.get(key(uri)), + now = current.get(key(uri)); + return ( + previous?.document === now?.document && previous?.version === now?.version + ); + }); +} diff --git a/packages/vscode-graphqlsp/src/typescript7/features.ts b/packages/vscode-graphqlsp/src/typescript7/features.ts new file mode 100644 index 00000000..380fd243 --- /dev/null +++ b/packages/vscode-graphqlsp/src/typescript7/features.ts @@ -0,0 +1,211 @@ +import * as vscode from 'vscode'; + +export interface MiddlewareAPI { + registerLspMiddleware?( + method: string, + transform: (result: any, context: { params: any }) => Promise + ): vscode.Disposable; +} + +type Request = ( + method: string, + document: vscode.TextDocument, + params: any +) => Promise; +const selector = [ + 'typescript', + 'typescriptreact', + 'javascript', + 'javascriptreact', +].map(language => ({ language, scheme: 'file' })); +const position = (p: any) => new vscode.Position(p.line, p.character); +const range = (r: any) => new vscode.Range(position(r.start), position(r.end)); +const edit = (value: any): vscode.WorkspaceEdit => { + const result = new vscode.WorkspaceEdit(); + for (const [uri, changes] of Object.entries(value.changes || {})) + result.set( + vscode.Uri.parse(uri), + changes.map( + change => new vscode.TextEdit(range(change.range), change.newText) + ) + ); + return result; +}; +const paramsAt = (document: vscode.TextDocument, p: vscode.Position) => ({ + textDocument: { uri: document.uri.toString() }, + position: { line: p.line, character: p.character }, +}); + +/** Rename uses a provider because response middleware cannot intercept an LSP error. */ +export function registerFeatures( + api: MiddlewareAPI, + request: Request +): vscode.Disposable { + const disposables: vscode.Disposable[] = []; + if (api.registerLspMiddleware) { + for (const method of ['textDocument/hover', 'textDocument/completion']) + disposables.push( + api.registerLspMiddleware(method, async (original, { params }) => { + const document = vscode.workspace.textDocuments.find( + document => document.uri.toString() === params.textDocument.uri + ); + if (!document) return original; + const result = await request(method, document, params); + if (result == null) return original; + // Native completion items are owned by TypeScript's resolver. Publish + // GraphQL items through our provider so resolving them never reaches it. + return method === 'textDocument/completion' + ? { isIncomplete: false, items: [] } + : result; + }) + ); + } else { + disposables.push( + vscode.languages.registerHoverProvider(selector, { + async provideHover(document, p, token) { + const result = await request( + 'textDocument/hover', + document, + paramsAt(document, p) + ); + return !token.isCancellationRequested && result + ? new vscode.Hover( + new vscode.MarkdownString(result.contents.value), + range(result.range) + ) + : undefined; + }, + }) + ); + } + disposables.push( + vscode.languages.registerCompletionItemProvider( + selector, + { + async provideCompletionItems(document, p, token) { + const result = await request( + 'textDocument/completion', + document, + paramsAt(document, p) + ); + if (!result || token.isCancellationRequested) return; + return new vscode.CompletionList( + result.items.map((item: any) => { + const completion = new vscode.CompletionItem( + item.label, + vscode.CompletionItemKind.Field + ); + completion.detail = item.detail; + completion.documentation = item.documentation; + completion.sortText = item.sortText; + completion.range = range(item.textEdit.range); + completion.insertText = item.textEdit.newText; + return completion; + }), + result.isIncomplete + ); + }, + }, + '.', + '@', + '$' + ), + vscode.languages.registerDefinitionProvider(selector, { + async provideDefinition(document, p, token) { + const result = await request( + 'textDocument/definition', + document, + paramsAt(document, p) + ); + return !token.isCancellationRequested && result + ? result.map( + (location: any) => + new vscode.Location( + vscode.Uri.parse(location.uri), + range(location.range) + ) + ) + : undefined; + }, + }), + vscode.languages.registerReferenceProvider(selector, { + async provideReferences(document, p, context, token) { + const result = await request('textDocument/references', document, { + ...paramsAt(document, p), + context, + }); + return !token.isCancellationRequested && result + ? result.map( + (location: any) => + new vscode.Location( + vscode.Uri.parse(location.uri), + range(location.range) + ) + ) + : undefined; + }, + }), + vscode.languages.registerRenameProvider(selector, { + async prepareRename(document, p, token) { + const result = await request( + 'textDocument/prepareRename', + document, + paramsAt(document, p) + ); + return !token.isCancellationRequested && result + ? { range: range(result.range), placeholder: result.placeholder } + : undefined; + }, + async provideRenameEdits(document, p, newName, token) { + const result = await request('textDocument/rename', document, { + ...paramsAt(document, p), + newName, + }); + return !token.isCancellationRequested && result + ? edit(result) + : undefined; + }, + }), + vscode.languages.registerCodeActionsProvider( + selector, + { + async provideCodeActions(document, selection, context, token) { + const diagnostics = context.diagnostics.map(diagnostic => ({ + source: diagnostic.source, + code: + typeof diagnostic.code === 'object' + ? diagnostic.code.value + : diagnostic.code, + })); + const result = await request('textDocument/codeAction', document, { + textDocument: { uri: document.uri.toString() }, + range: { start: selection.start, end: selection.end }, + context: { + diagnostics, + only: context.only ? [context.only.value] : undefined, + }, + }); + return !token.isCancellationRequested && result + ? result.map((action: any) => { + const converted = new vscode.CodeAction( + action.title, + action.kind === 'quickfix' + ? vscode.CodeActionKind.QuickFix + : vscode.CodeActionKind.RefactorExtract + ); + converted.edit = edit(action.edit); + return converted; + }) + : undefined; + }, + }, + { + providedCodeActionKinds: [ + vscode.CodeActionKind.QuickFix, + vscode.CodeActionKind.RefactorExtract, + ], + } + ) + ); + return vscode.Disposable.from(...disposables); +} diff --git a/packages/vscode-graphqlsp/src/typescript7/index.ts b/packages/vscode-graphqlsp/src/typescript7/index.ts index 2671cc64..31b1b283 100644 --- a/packages/vscode-graphqlsp/src/typescript7/index.ts +++ b/packages/vscode-graphqlsp/src/typescript7/index.ts @@ -1,3 +1,6 @@ +import { registerFeatures, type MiddlewareAPI } from './features'; +import { captureDocumentVersions, editsAreCurrent } from './editVersions'; +import { nativeModeSetting } from './nativeMode'; import * as vscode from 'vscode'; import { fork, type ChildProcess } from 'node:child_process'; import fs from 'node:fs'; @@ -32,7 +35,7 @@ interface TypeScriptSDK { initializeAPIConnection(pipe?: string): Promise; } -interface TypeScript7ExtensionAPI { +interface TypeScript7ExtensionAPI extends MiddlewareAPI { /** Before `TypeScriptSDK` (e.g. `TypeScriptTeam.native-preview@1.0`), the * listener is called without an argument. */ onLanguageServerInitialized( @@ -148,6 +151,31 @@ export function activateTypeScript7(context: vscode.ExtensionContext): void { let diagnostics: TypeScript7Diagnostics | undefined; context.subscriptions.push({ dispose: () => diagnostics?.dispose() }); + let stoppedByConfiguration = false; + const mode = () => + nativeModeSetting( + vscode.workspace + .getConfiguration('js/ts') + .inspect('experimental.useTsgo'), + vscode.workspace + .getConfiguration('typescript') + .inspect('experimental.useTsgo') + ); + context.subscriptions.push( + vscode.workspace.onDidChangeConfiguration(event => { + if ( + event.affectsConfiguration('js/ts.experimental.useTsgo') || + event.affectsConfiguration('typescript.experimental.useTsgo') + ) { + stoppedByConfiguration = mode() !== true; + if (!stoppedByConfiguration) return; + // The legacy extension API has no server-stopped event. Dispose before + // a switch to classic TypeScript so diagnostics/providers cannot linger. + diagnostics?.dispose(); + diagnostics = undefined; + } + }) + ); extension.activate().then( api => { @@ -161,6 +189,7 @@ export function activateTypeScript7(context: vscode.ExtensionContext): void { api.onLanguageServerInitialized(languageServer => { diagnostics?.dispose(); diagnostics = undefined; + if (stoppedByConfiguration || mode() === false) return; const sdk = languageServer || getLegacySDK(api); if (!sdk) { log(`${id} doesn't provide TypeScript 7's API connections.`); @@ -174,7 +203,8 @@ export function activateTypeScript7(context: vscode.ExtensionContext): void { diagnostics = new TypeScript7Diagnostics( context, sdk as TypeScriptSDK & { packageJsonPath: string }, - log + log, + api ); }) ); @@ -189,17 +219,15 @@ class TypeScript7Diagnostics { #disposables: vscode.Disposable[] = [this.#collection]; #server: Promise; #timers = new Map(); - #pending = new Map< - number, - (message: ServerMessage & { type: 'result' }) => void - >(); + #pending = new Map void>(); #seq = 0; #disposed = false; constructor( context: vscode.ExtensionContext, sdk: TypeScriptSDK & { packageJsonPath: string }, - private log: (message: string) => void + private log: (message: string) => void, + api: MiddlewareAPI ) { this.#server = sdk.initializeAPIConnection().then( pipe => this.#startServer(context, sdk.packageJsonPath, pipe), @@ -226,6 +254,11 @@ class TypeScript7Diagnostics { this.#collection.delete(document.uri); }) ); + this.#disposables.push( + registerFeatures(api, (method, document, params) => + this.#feature(method, document, params) + ) + ); this.#checkAll(); } @@ -281,6 +314,65 @@ class TypeScript7Diagnostics { return server; } + async #feature( + method: string, + document: vscode.TextDocument, + params: any + ): Promise { + const version = document.version; + const versions = captureDocumentVersions(vscode.workspace.textDocuments); + for (let retry = 0; retry < maxStaleRetries; retry++) { + const server = await this.#server; + if ( + !server?.connected || + this.#disposed || + document.isClosed || + document.version !== version + ) + return; + const id = ++this.#seq; + const message: any = await new Promise(resolve => { + const timeout = setTimeout(() => { + this.#pending.delete(id); + resolve({ error: 'Feature request timed out' }); + }, 10000); + this.#pending.set(id, message => { + clearTimeout(timeout); + resolve(message); + }); + server.send({ + type: 'feature', + id, + fileName: document.uri.fsPath, + method, + params, + } satisfies ServerRequest); + }); + if (this.#disposed || document.isClosed || document.version !== version) + return; + if (message.error) { + this.log(message.error); + return; + } + if (!message.result) return; + if ( + !editsAreCurrent( + message.result.result, + versions, + vscode.workspace.textDocuments + ) + ) { + this.log( + 'Discarded GraphQL edits because a referenced document changed.' + ); + return; + } + if (message.result.text === document.getText()) + return message.result.result ?? undefined; + await new Promise(resolve => setTimeout(resolve, staleRetryDelay)); + } + } + #checkAll() { for (const document of vscode.workspace.textDocuments) this.#schedule(document, 0); @@ -346,27 +438,41 @@ class TypeScript7Diagnostics { this.#collection.set( document.uri, - message.result.diagnostics.map(diagnostic => { - const result = new vscode.Diagnostic( - new vscode.Range( - document.positionAt(diagnostic.start), - document.positionAt(diagnostic.start + diagnostic.length) - ), - diagnostic.message, - severities[diagnostic.severity] - ); - result.code = diagnostic.code; - result.source = 'graphqlsp'; - return result; - }) + message.result.diagnostics.map( + (diagnostic: import('@0no-co/graphqlsp/check').CheckDiagnostic) => { + const result = new vscode.Diagnostic( + new vscode.Range( + document.positionAt(diagnostic.start), + document.positionAt(diagnostic.start + diagnostic.length) + ), + diagnostic.message, + severities[diagnostic.severity] + ); + result.code = diagnostic.code; + result.source = 'graphqlsp'; + return result; + } + ) ); } dispose() { this.#disposed = true; + for (const resolve of this.#pending.values()) + resolve({ error: 'GraphQLSP session ended' }); + this.#pending.clear(); for (const timer of this.#timers.values()) clearTimeout(timer); this.#timers.clear(); for (const disposable of this.#disposables) disposable.dispose(); - this.#server.then(server => server?.kill()); + this.#server.then(server => { + if (!server) return; + if (server.connected) + server.send({ type: 'stop' } satisfies ServerRequest, error => { + if (error) server.kill(); + }); + const timeout = setTimeout(() => server.kill(), 2000); + timeout.unref(); + server.once('exit', () => clearTimeout(timeout)); + }); } } diff --git a/packages/vscode-graphqlsp/src/typescript7/nativeMode.ts b/packages/vscode-graphqlsp/src/typescript7/nativeMode.ts new file mode 100644 index 00000000..59c48681 --- /dev/null +++ b/packages/vscode-graphqlsp/src/typescript7/nativeMode.ts @@ -0,0 +1,39 @@ +interface Inspection { + defaultValue?: boolean; + globalValue?: boolean; + workspaceValue?: boolean; + workspaceFolderValue?: boolean; + globalLanguageValue?: boolean; + workspaceLanguageValue?: boolean; + workspaceFolderLanguageValue?: boolean; +} + +/** Mirrors native-preview's precedence between its new key and legacy alias. */ +export function nativeModeSetting( + current?: Inspection, + legacy?: Inspection +): boolean | undefined { + const value = (inspection?: Inspection): boolean | undefined => + inspection && + (inspection.workspaceFolderLanguageValue ?? + inspection.workspaceLanguageValue ?? + inspection.globalLanguageValue ?? + inspection.workspaceFolderValue ?? + inspection.workspaceValue ?? + inspection.globalValue); + const scope = (inspection?: Inspection) => + inspection?.workspaceFolderValue !== undefined + ? 3 + : inspection?.workspaceValue !== undefined + ? 2 + : inspection?.globalValue !== undefined + ? 1 + : undefined; + const currentValue = value(current), + legacyValue = value(legacy); + if (currentValue === undefined && legacyValue === undefined) return undefined; + const currentScope = scope(current), + legacyScope = scope(legacy); + const winner = Math.max(currentScope ?? 1, legacyScope ?? 1); + return currentScope === winner ? currentValue : legacyValue; +} diff --git a/packages/vscode-graphqlsp/src/typescript7/server.ts b/packages/vscode-graphqlsp/src/typescript7/server.ts index df897dbe..08e5ddfc 100644 --- a/packages/vscode-graphqlsp/src/typescript7/server.ts +++ b/packages/vscode-graphqlsp/src/typescript7/server.ts @@ -10,19 +10,33 @@ import type { LanguageServerSession, } from '@0no-co/graphqlsp/check'; -export type ServerRequest = { type: 'check'; id: number; fileName: string }; +export type ServerRequest = + | { type: 'stop' } + | { type: 'check'; id: number; fileName: string } + | { + type: 'feature'; + id: number; + fileName: string; + method: string; + params: any; + }; export type ServerMessage = | { type: 'result'; id: number; - result?: LanguageServerCheckResult | null; + result?: any; error?: string; } | { type: 'schemaChange' } | { type: 'log'; message: string }; -const send = (message: ServerMessage) => process.send!(message); +const send = (message: ServerMessage) => { + if (!process.connected) return; + try { + process.send!(message, () => {}); + } catch {} +}; const log = (message: string) => send({ type: 'log', message }); const packageJsonPath = process.env.GRAPHQLSP_TS7_PACKAGE_JSON!; @@ -49,16 +63,41 @@ session.then( error => log(`Failed to connect to TypeScript's API session: ${error}`) ); -process.on('message', async (request: ServerRequest) => { - if (request.type !== 'check') return; - try { - const result = await (await session).check(request.fileName); - send({ type: 'result', id: request.id, result }); - } catch (error) { - send({ type: 'result', id: request.id, error: String(error) }); +let closing = false; +let work: Promise = Promise.resolve(); +const stop = () => { + if (closing) return; + closing = true; + void work + .then(() => session) + .then(session => session.dispose()) + .catch(error => process.stderr.write(String(error))) + .finally(() => process.exit(0)); +}; +process.on('message', (request: ServerRequest) => { + if (request.type === 'stop') { + stop(); + return; } + if (closing) return; + if (request.type !== 'check' && request.type !== 'feature') return; + work = work.then(async () => { + try { + const active = await session; + const result = + request.type === 'check' + ? await active.check(request.fileName) + : await active.request( + request.method, + request.fileName, + request.params + ); + if (!closing) send({ type: 'result', id: request.id, result }); + } catch (error) { + if (!closing) + send({ type: 'result', id: request.id, error: String(error) }); + } + }); }); -process.on('disconnect', () => { - session.then(session => session.dispose()).finally(() => process.exit(0)); -}); +process.on('disconnect', stop); diff --git a/scripts/dogfood-vscode.mjs b/scripts/dogfood-vscode.mjs new file mode 100644 index 00000000..a3319b9e --- /dev/null +++ b/scripts/dogfood-vscode.mjs @@ -0,0 +1,149 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import os from 'node:os'; +import { spawn, spawnSync } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { fileURLToPath } from 'node:url'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const args = process.argv.slice(2); +const option = name => args[args.indexOf(name) + 1]; +if (!args.includes('--code') || !args.includes('--cli')) { + throw new Error( + 'Usage: node scripts/dogfood-vscode.mjs --code /path/to/Code --cli /path/to/resources/app/out/cli.js [--vsix /path/to/graphqlsp.vsix] [--results /path/to/results.json]' + ); +} +const code = path.resolve(option('--code')), + cli = path.resolve(option('--cli')); +const base = fs.mkdtempSync(path.join(os.tmpdir(), 'graphqlsp-vscode-')); +const project = path.join(base, 'workspace'), + userData = path.join(base, 'user-data'), + extensions = path.join(base, 'extensions'); +const fixture = path.join(root, 'test/e2e/fixture-project-ts7'); +const require = createRequire(path.join(fixture, 'package.json')); +const native = path.dirname( + fs.realpathSync(require.resolve('typescript-7/package.json')) +); +const legacy = path.dirname( + fs.realpathSync( + createRequire(path.join(root, 'package.json')).resolve( + 'typescript/package.json' + ) + ) +); +const version = JSON.parse( + fs.readFileSync(path.join(root, 'packages/vscode-graphqlsp/package.json')) +).version; +const vsix = args.includes('--vsix') + ? path.resolve(option('--vsix')) + : path.join( + root, + `packages/vscode-graphqlsp/vscode-graphqlsp-${version}.vsix` + ); +const results = args.includes('--results') + ? path.resolve(option('--results')) + : path.join(root, '.tmp/dogfood-results.json'); +fs.mkdirSync(path.dirname(results), { recursive: true }); +fs.mkdirSync(path.join(project, 'node_modules'), { recursive: true }); +fs.mkdirSync(path.join(userData, 'User'), { recursive: true }); +for (const entry of ['src', 'schema.graphql', 'tsconfig.json']) + fs.cpSync(path.join(fixture, entry), path.join(project, entry), { + recursive: true, + }); +for (const [name, target] of [ + ['typescript', native], + [ + 'gql.tada', + path.dirname(fs.realpathSync(require.resolve('gql.tada/package.json'))), + ], +]) + fs.symlinkSync( + target, + path.join(project, 'node_modules', name), + process.platform === 'win32' ? 'junction' : 'dir' + ); +fs.writeFileSync( + path.join(project, 'package.json'), + JSON.stringify({ name: 'graphqlsp-dogfood', private: true, type: 'module' }) +); +fs.writeFileSync( + path.join(userData, 'User/settings.json'), + JSON.stringify({ + 'js/ts.experimental.useTsgo': true, + 'js/ts.tsdk.path': native, + 'security.workspace.trust.enabled': false, + 'telemetry.telemetryLevel': 'off', + 'extensions.autoUpdate': false, + 'update.mode': 'none', + 'workbench.startupEditor': 'none', + }) +); +fs.writeFileSync( + path.join(base, 'config.json'), + JSON.stringify({ project, native, legacy, results }) +); +fs.cpSync( + path.join(root, 'test/vscode/extension'), + path.join(base, 'test-extension'), + { recursive: true } +); +const common = [ + `--user-data-dir=${userData}`, + `--extensions-dir=${extensions}`, +]; +for (const extension of ['TypeScriptTeam.native-preview', vsix]) { + const result = spawnSync( + code, + [cli, ...common, '--install-extension', extension, '--force'], + { + env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }, + stdio: 'inherit', + windowsHide: true, + } + ); + if (result.status !== 0) + throw new Error( + `Failed to install ${extension}: ${result.error || result.status}` + ); +} +const env = { ...process.env }; +delete env.ELECTRON_RUN_AS_NODE; +const log = fs.openSync(path.join(base, 'vscode.log'), 'w'); +console.log(`Isolated editor files and logs: ${base}`); +const child = spawn( + code, + [ + ...common, + '--new-window', + '--disable-gpu', + '--skip-welcome', + '--skip-release-notes', + '--disable-workspace-trust', + `--extensionDevelopmentPath=${path.join(base, 'test-extension')}`, + `--extensionTestsPath=${path.join(base, 'test-extension/tests.cjs')}`, + project, + ], + { env, windowsHide: true, stdio: ['ignore', log, log] } +); +await new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill(); + reject(new Error(`Editor tests timed out; logs: ${base}`)); + }, 240000); + child.once('error', error => { + clearTimeout(timeout); + reject(error); + }); + child.once('exit', () => { + clearTimeout(timeout); + resolve(); + }); +}); +const report = JSON.parse(fs.readFileSync(results, 'utf8')); +console.log( + `${report.tests.filter(test => test.passed).length}/${report.tests.length} real-editor checks passed. Results: ${results}` +); +if (!report.passed) + throw new Error( + report.tests.find(test => !test.passed)?.error || 'Editor tests failed' + ); diff --git a/test/e2e/extract-fragment.test.ts b/test/e2e/extract-fragment.test.ts index 5d62cfce..a8dbc18b 100644 --- a/test/e2e/extract-fragment.test.ts +++ b/test/e2e/extract-fragment.test.ts @@ -217,7 +217,7 @@ describe('Extract to fragment refactor', () => { it('extracts selected fields into a new fragment appended to the fragment array', async () => { const editInfo = await getEditsForExtractFragment(validSelection); expect(editInfo.edits).toHaveLength(1); - expect(editInfo.edits[0]!.fileName).toBe(outfile); + expect(path.normalize(editInfo.edits[0]!.fileName)).toBe(outfile); const result = applyEdits(fixtureContent, editInfo.edits[0]!.textChanges); expect(result).toMatchInlineSnapshot(` diff --git a/test/e2e/lsp-proxy.test.ts b/test/e2e/lsp-proxy.test.ts new file mode 100644 index 00000000..57ed02a1 --- /dev/null +++ b/test/e2e/lsp-proxy.test.ts @@ -0,0 +1,262 @@ +import { afterAll, beforeAll, expect, it } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { + readMessages, + writeMessage, +} from '../../packages/graphqlsp/src/lspProtocol'; +import { + offsetAt, + positionAt, +} from '../../packages/graphqlsp/src/languageFeatures'; + +const root = path.resolve('test/e2e/fixture-project-ts7'); +const fileName = path.join(root, 'src/valid.ts'); +const uri = pathToFileURL(fileName).href; +const savedText = fs.readFileSync(fileName, 'utf8'); +let server: ChildProcess; +let seq = 0, + version = 0; +const requests = new Map< + number, + { + resolve: (value: any) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + } +>(); +const notifications: any[] = []; +const send = (method: string, params: any) => + writeMessage(server.stdin!, { jsonrpc: '2.0', method, params }); +const request = (method: string, params: any): Promise => + new Promise((resolve, reject) => { + const id = ++seq; + const timer = setTimeout(() => { + requests.delete(id); + reject(new Error(`Timed out: ${method}`)); + }, 15000); + requests.set(id, { resolve, reject, timer }); + writeMessage(server.stdin!, { jsonrpc: '2.0', id, method, params }); + }); +const at = (text: string, offset: number) => ({ + textDocument: { uri }, + position: positionAt(text, offset), +}); +const change = (text: string) => + send('textDocument/didChange', { + textDocument: { uri, version: ++version }, + contentChanges: [{ text }], + }); + +beforeAll(async () => { + server = spawn( + process.execPath, + [ + path.resolve('packages/graphqlsp/bin/graphqlsp.cjs'), + '--stdio', + '--no-persist', + '--typescript', + path.join(root, 'node_modules/typescript-7'), + ], + { cwd: root, stdio: ['pipe', 'pipe', 'inherit'] } + ); + readMessages( + server.stdout!, + message => { + if (message.method && message.id != null) + writeMessage(server.stdin!, { + jsonrpc: '2.0', + id: message.id, + result: message.method === 'workspace/configuration' ? [] : null, + }); + else if (typeof message.id === 'number') { + const entry = requests.get(message.id); + if (entry) { + clearTimeout(entry.timer); + requests.delete(message.id); + message.error + ? entry.reject(new Error(message.error.message)) + : entry.resolve(message.result); + } + } else if (message.method) notifications.push(message); + }, + error => { + throw error; + } + ); + const result = await request('initialize', { + processId: process.pid, + rootUri: pathToFileURL(root).href, + capabilities: {}, + workspaceFolders: [{ name: 'fixture', uri: pathToFileURL(root).href }], + }); + expect(result.capabilities.renameProvider.prepareProvider).toBe(true); + expect(result.capabilities.completionProvider.triggerCharacters).toEqual( + expect.arrayContaining(['.', '"', "'", '/', '<', '@', '$']) + ); + expect(result.capabilities.completionProvider.resolveProvider).toBe(true); + expect(result.capabilities.codeActionProvider.codeActionKinds).toEqual( + expect.arrayContaining([ + 'quickfix', + 'source.organizeImports.ts', + 'source.removeUnusedImports.ts', + 'source.sortImports.ts', + 'source.fixAll.ts', + 'refactor.extract', + ]) + ); + send('initialized', {}); + send('textDocument/didOpen', { + textDocument: { + uri, + languageId: 'typescript', + version: ++version, + text: savedText, + }, + }); +}, 30000); + +afterAll(async () => { + try { + if (server?.exitCode == null) { + await request('shutdown', null); + send('exit', null); + } + } finally { + server?.kill(); + for (const entry of requests.values()) { + clearTimeout(entry.timer); + entry.reject(new Error('Test ended')); + } + requests.clear(); + } +}); + +it('forwards TypeScript hovers and adds GraphQL hovers and completions', async () => { + const normal = await request( + 'textDocument/hover', + at(savedText, savedText.indexOf('graphql') + 2) + ); + expect(normal).not.toBeNull(); + const hover = await request( + 'textDocument/hover', + at(savedText, savedText.indexOf(' id') + 9) + ); + expect(hover.contents.value).toContain('Pokemon.id'); + const text = savedText.replace(' id', ' maxC'); + change(text); + const completion = await request( + 'textDocument/completion', + at(text, text.indexOf('maxC') + 4) + ); + const field = completion.items.find((item: any) => item.label === 'maxCP'); + expect(field.textEdit.newText).toBe('maxCP'); + const start = offsetAt(text, field.textEdit.range.start), + end = offsetAt(text, field.textEdit.range.end); + expect(text.slice(start, end)).toBe('maxC'); + expect(await request('completionItem/resolve', field)).toEqual(field); +}, 30000); + +it('keeps native TypeScript completion resolution working', async () => { + const text = + savedText + '\nconst value = { nativeCompletion: 1 }; value.nati'; + change(text); + const completion = await request( + 'textDocument/completion', + at(text, text.length) + ); + const item = completion.items.find( + (item: any) => item.label === 'nativeCompletion' + ); + expect(item).toBeTruthy(); + const resolved = await request('completionItem/resolve', item); + expect(resolved.label).toBe('nativeCompletion'); +}, 30000); + +it('overrides native prepareRename errors and renames fragments across files', async () => { + change(savedText); + const params = at(savedText, savedText.indexOf('...pokemonFields') + 4); + const prepare = await request('textDocument/prepareRename', params); + expect(prepare.placeholder).toBe('pokemonFields'); + const edits = await request('textDocument/rename', { + ...params, + newName: 'PokemonDetails', + }); + expect(Object.keys(edits.changes)).toHaveLength(2); +}, 30000); + +it('cancels feature requests and rejects results for changed documents', async () => { + change(savedText); + const params = at(savedText, savedText.indexOf(' id') + 9); + const cancelled = request('textDocument/hover', params); + send('$/cancelRequest', { id: seq }); + await expect(cancelled).rejects.toThrow(/cancel/i); + const stale = request('textDocument/hover', params); + change(savedText + '\n// changed'); + await expect(stale).rejects.toThrow(/changed/i); + change(savedText); +}, 30000); + +it('publishes diagnostics after incremental edits and clears them after a fix', async () => { + const text = savedText.replace(' id', ' nmae'); + // Use an incremental change, including CRLF and a surrogate pair in a comment. + const offset = savedText.indexOf(' id') + 8; + send('textDocument/didChange', { + textDocument: { uri, version: ++version }, + contentChanges: [ + { + range: { + start: positionAt(savedText, offset), + end: positionAt(savedText, offset + 2), + }, + text: 'nmae', + }, + ], + }); + const waitFor = async (predicate: (message: any) => boolean) => { + for (let attempt = 0; attempt < 100; attempt++) { + const result = notifications.findLast( + message => + message.method === 'textDocument/publishDiagnostics' && + message.params.uri === uri && + message.params.version === version && + predicate(message) + ); + if (result) return result; + await new Promise(resolve => setTimeout(resolve, 50)); + } + throw new Error('No matching diagnostics notification'); + }; + const errors = await waitFor(message => + message.params.diagnostics.some( + (diagnostic: any) => + diagnostic.source === 'graphqlsp' && diagnostic.message.includes('nmae') + ) + ); + const diagnostic = errors.params.diagnostics.find( + (diagnostic: any) => + diagnostic.source === 'graphqlsp' && diagnostic.message.includes('nmae') + ); + expect( + text.slice( + offsetAt(text, diagnostic.range.start), + offsetAt(text, diagnostic.range.end) + ) + ).toContain('nmae'); + const fixes = await request('textDocument/codeAction', { + textDocument: { uri }, + range: diagnostic.range, + context: { diagnostics: [diagnostic] }, + }); + expect(fixes.some((fix: any) => fix.title.includes('name'))).toBe(true); + change(savedText); + await waitFor( + message => + !message.params.diagnostics.some( + (diagnostic: any) => diagnostic.source === 'graphqlsp' + ) + ); + send('textDocument/didClose', { textDocument: { uri } }); +}, 30000); diff --git a/test/e2e/lsp-shutdown.test.ts b/test/e2e/lsp-shutdown.test.ts new file mode 100644 index 00000000..0f97cdc2 --- /dev/null +++ b/test/e2e/lsp-shutdown.test.ts @@ -0,0 +1,134 @@ +import { expect, it } from 'vitest'; +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs'; +import { pathToFileURL } from 'node:url'; +import { + readMessages, + writeMessage, +} from '../../packages/graphqlsp/src/lspProtocol'; +import { positionAt } from '../../packages/graphqlsp/src/languageFeatures'; + +const project = path.resolve('test/e2e/fixture-project-ts7'); +const file = path.join(project, 'src/valid.ts'); +const uri = pathToFileURL(file).href; +const text = fs.readFileSync(file, 'utf8'); + +it.each( + [0, 249, 260, 300, 500, 700].flatMap(pause => + [true, false].map(close => ({ pause, close })) + ) +)( + 'closes its API session before native shutdown after $pause ms (didClose=$close)', + async ({ pause, close }) => { + const child = spawn( + process.execPath, + [ + path.resolve('packages/graphqlsp/bin/graphqlsp.cjs'), + '--stdio', + '--no-persist', + '--typescript', + path.join(project, 'node_modules/typescript-7'), + ], + { cwd: project, stdio: ['pipe', 'pipe', 'inherit'] } + ); + let seq = 0; + const pending = new Map< + number, + { + resolve: (value: any) => void; + reject: (error: Error) => void; + timer: NodeJS.Timeout; + } + >(); + const send = (method: string, params?: any) => + writeMessage(child.stdin!, { jsonrpc: '2.0', method, params }); + const request = (method: string, params?: any) => + new Promise((resolve, reject) => { + const id = ++seq; + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`Timed out: ${method}`)); + }, 7000); + pending.set(id, { resolve, reject, timer }); + writeMessage(child.stdin!, { jsonrpc: '2.0', id, method, params }); + }); + child.stdin!.on('error', () => {}); + readMessages( + child.stdout!, + message => { + if (message.method && message.id != null) + writeMessage(child.stdin!, { + jsonrpc: '2.0', + id: message.id, + result: message.method === 'workspace/configuration' ? [] : null, + }); + else if (typeof message.id === 'number') { + const entry = pending.get(message.id); + if (entry) { + clearTimeout(entry.timer); + pending.delete(message.id); + message.error + ? entry.reject(new Error(message.error.message)) + : entry.resolve(message.result); + } + } + }, + error => { + for (const entry of pending.values()) entry.reject(error); + } + ); + try { + await request('initialize', { + processId: process.pid, + rootUri: pathToFileURL(project).href, + capabilities: {}, + }); + send('initialized', {}); + send('textDocument/didOpen', { + textDocument: { uri, languageId: 'typescript', version: 1, text }, + }); + const hover = await request('textDocument/hover', { + textDocument: { uri }, + position: positionAt(text, text.indexOf(' id') + 9), + }); + expect(hover.contents.value).toContain('Pokemon.id'); + send('textDocument/didChange', { + textDocument: { uri, version: 2 }, + contentChanges: [{ text: text.replace(' id', ' nmae') }], + }); + await new Promise(resolve => setTimeout(resolve, pause)); + if (close) send('textDocument/didClose', { textDocument: { uri } }); + expect(await request('shutdown')).toBeNull(); + await new Promise(resolve => setTimeout(resolve, 50)); + expect(child.exitCode).toBeNull(); + await expect( + request('textDocument/hover', { + textDocument: { uri }, + position: { line: 0, character: 0 }, + }) + ).rejects.toThrow('shutting down'); + const exited = new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error('Proxy did not exit')), + 3000 + ); + child.once('exit', code => { + clearTimeout(timeout); + resolve(code); + }); + }); + send('exit'); + expect(await exited).toBe(0); + } finally { + child.stdin!.end(); + child.kill(); + for (const entry of pending.values()) { + clearTimeout(entry.timer); + entry.reject(new Error('Test ended')); + } + pending.clear(); + } + }, + 15000 +); diff --git a/test/e2e/ts7-content-mapper.test.ts b/test/e2e/ts7-content-mapper.test.ts index 83945648..baa2f8eb 100644 --- a/test/e2e/ts7-content-mapper.test.ts +++ b/test/e2e/ts7-content-mapper.test.ts @@ -31,7 +31,7 @@ const check = async (runExternalCode: boolean) => { return { files: result.files.map(file => path.relative(projectPath, file)).sort(), diagnostics: result.diagnostics.map(diagnostic => ({ - file: path.relative(projectPath, diagnostic.file), + file: path.relative(projectPath, diagnostic.file).replace(/\\/g, '/'), line: diagnostic.line, column: diagnostic.column, code: diagnostic.code, diff --git a/test/e2e/ts7-session.test.ts b/test/e2e/ts7-session.test.ts index 6d03a3fd..91e1fcf3 100644 --- a/test/e2e/ts7-session.test.ts +++ b/test/e2e/ts7-session.test.ts @@ -28,6 +28,9 @@ class LanguageServer { cwd: projectPath, stdio: ['pipe', 'pipe', 'inherit'], }); + this.#process.stdin!.on('error', error => { + if ((error as NodeJS.ErrnoException).code !== 'EPIPE') throw error; + }); this.#process.stdout!.on('data', (chunk: Buffer) => { this.#buffer = Buffer.concat([this.#buffer, chunk]); for (;;) { @@ -148,14 +151,16 @@ const servers = [ }, ].filter( server => - !process.env.GRAPHQLSP_TS7_COMPILER || - process.env.GRAPHQLSP_TS7_COMPILER === server.name + (server.name !== 'tsrs' || process.platform !== 'win32') && + (!process.env.GRAPHQLSP_TS7_COMPILER || + process.env.GRAPHQLSP_TS7_COMPILER === server.name) ); describe.each(servers)('language server session with $label', server => { const fileName = path.join(projectPath, 'src/valid.ts'); let languageServer: LanguageServer; let session: LanguageServerSession | undefined; + let schemaChanges = 0; // Set when the language server doesn't provide API sessions let unsupported: string | undefined; const requireSession = (context: TestContext) => { @@ -203,6 +208,9 @@ describe.each(servers)('language server session with $label', server => { packageJsonPath: server.packageJsonPath, pipe, persist: false, + onSchemaChange: () => { + schemaChanges++; + }, }); }, 60_000); @@ -217,7 +225,7 @@ describe.each(servers)('language server session with $label', server => { expect(result!.text).toContain('nmae'); expect( result!.diagnostics.map(({ file, ...diagnostic }) => ({ - file: path.relative(projectPath, file), + file: path.relative(projectPath, file).replace(/\\/g, '/'), ...diagnostic, })) ).toMatchInlineSnapshot(` @@ -249,10 +257,290 @@ describe.each(servers)('language server session with $label', server => { expect(result!.diagnostics).toEqual([]); }, 60_000); + let version = 2; + const changeText = async (session: LanguageServerSession, text: string) => { + languageServer.change(fileName, ++version, text); + await languageServer.request('textDocument/hover', { + textDocument: { uri: url.pathToFileURL(fileName).href }, + position: { line: 0, character: 0 }, + }); + for (let attempt = 0; attempt < 30; attempt++) { + if ((await session.check(fileName))?.text === text) return; + await new Promise(resolve => setTimeout(resolve, 50)); + } + const actual = (await session.check(fileName))?.text; + throw new Error( + `Language server did not apply the edit: ${JSON.stringify({ actual, expected: text })}` + ); + }; + const savedText = fs.readFileSync(fileName, 'utf8'); + const positionAt = (text: string, offset: number) => { + const lines = text.slice(0, offset).split('\n'); + return { + line: lines.length - 1, + character: lines[lines.length - 1]!.length, + }; + }; + + it('provides hover and completions on unsaved documents with replacement ranges', async context => { + const session = requireSession(context); + const text = savedText.replace(' id', ' name\n maxC'); + await changeText(session, text); + const hover = await session.hover( + fileName, + positionAt(text, text.indexOf(' name') + 10) + ); + expect(hover?.text).toBe(text); + expect(hover?.result.contents.value).toContain('Pokemon.name'); + const offset = text.indexOf('maxC') + 4; + const completion = await session.complete( + fileName, + positionAt(text, offset) + ); + const maxCP = completion?.result.items.find( + (item: any) => item.label === 'maxCP' + ); + expect(maxCP?.textEdit.newText).toBe('maxCP'); + expect(maxCP?.textEdit.range).toEqual({ + start: positionAt(text, offset - 4), + end: positionAt(text, offset), + }); + }, 60_000); + + it('finds schema definitions, fragment references and rename edits across files', async context => { + const session = requireSession(context); + await changeText(session, savedText); + const fieldPosition = positionAt( + savedText, + savedText.indexOf(' id') + 9 + ); + const definition = await session.request( + 'textDocument/definition', + fileName, + { position: fieldPosition } + ); + expect(definition?.result[0].uri).toBe( + url.pathToFileURL(path.join(projectPath, 'schema.graphql')).href + ); + const position = positionAt( + savedText, + savedText.indexOf('...pokemonFields') + 4 + ); + const prepare = await session.request( + 'textDocument/prepareRename', + fileName, + { position } + ); + expect(prepare?.result.placeholder).toBe('pokemonFields'); + const references = await session.request( + 'textDocument/references', + fileName, + { position, context: { includeDeclaration: true } } + ); + expect(references?.result).toHaveLength(2); + const renamed = await session.request('textDocument/rename', fileName, { + position, + newName: 'PokemonDetails', + }); + expect(Object.keys(renamed?.result.changes)).toHaveLength(2); + expect( + Object.values(renamed?.result.changes) + .flat() + .every(edit => edit.newText === 'PokemonDetails') + ).toBe(true); + expect( + ( + await session.request('textDocument/rename', fileName, { + position, + newName: 'bad-name', + }) + )?.result + ).toBeNull(); + }, 60_000); + + it('offers typo fixes and an extract-to-fragment refactor', async context => { + const session = requireSession(context); + const text = savedText.replace(' id', ' nmae'); + await changeText(session, text); + const start = positionAt(text, text.indexOf('nmae')), + end = positionAt(text, text.indexOf('nmae') + 4); + const fixes = await session.request('textDocument/codeAction', fileName, { + range: { start, end }, + context: { + diagnostics: [{ source: 'graphqlsp', code: 52001 }], + only: ['quickfix'], + }, + }); + expect( + fixes?.result.some((action: any) => action.title.includes('name')) + ).toBe(true); + await changeText(session, savedText); + const offset = savedText.indexOf(' id') + 8; + const extracted = await session.request( + 'textDocument/codeAction', + fileName, + { + range: { + start: positionAt(savedText, offset), + end: positionAt(savedText, offset + 2), + }, + context: { diagnostics: [], only: ['refactor.extract'] }, + } + ); + expect(extracted?.result[0].title).toBe('Extract to fragment'); + expect( + extracted?.result[0].edit.changes[url.pathToFileURL(fileName).href] + ).toHaveLength(3); + }, 60_000); + + it('leaves ordinary TypeScript positions alone and serializes concurrent calls', async context => { + const session = requireSession(context); + const [hover, completion] = await Promise.all([ + session.hover(fileName, { line: 0, character: 10 }), + session.complete(fileName, { line: 0, character: 10 }), + ]); + expect(hover?.result).toBeNull(); + expect(completion?.result).toBeNull(); + }, 60_000); + it('reloads changed schemas and notifies the editor', async context => { + const active = requireSession(context); + const schemaPath = path.join(projectPath, 'schema.graphql'); + const original = fs.readFileSync(schemaPath, 'utf8'); + const previous = schemaChanges; + try { + fs.writeFileSync( + schemaPath, + original.replace( + ' name: String!', + ' newEditorField: String\n name: String!' + ) + ); + for ( + let attempt = 0; + attempt < 100 && schemaChanges === previous; + attempt++ + ) + await new Promise(resolve => setTimeout(resolve, 50)); + expect(schemaChanges).toBeGreaterThan(previous); + const text = savedText.replace(' id', ' newEditorField'); + await changeText(active, text); + const hover = await active.hover( + fileName, + positionAt(text, text.indexOf('newEditorField') + 3) + ); + expect(hover?.result.contents.value).toContain('Pokemon.newEditorField'); + } finally { + fs.writeFileSync(schemaPath, original); + await changeText(active, savedText); + } + }, 60_000); + + it('awaits named schemas on the first request without a second editor change', async context => { + requireSession(context); + const directory = fs.mkdtempSync(path.join(projectPath, '.named-')); + let fresh: LanguageServerSession | undefined; + try { + fs.writeFileSync( + path.join(directory, 'tsconfig.json'), + JSON.stringify({ + compilerOptions: { + target: 'es2022', + module: 'nodenext', + moduleResolution: 'nodenext', + skipLibCheck: true, + plugins: [ + { + name: '@0no-co/graphqlsp', + schemas: [ + { + name: 'one', + schema: '../schema.graphql', + tadaOutputLocation: './introspection.d.ts', + }, + ], + }, + ], + }, + include: ['*.ts'], + }) + ); + fs.writeFileSync( + path.join(directory, 'introspection.d.ts'), + fs + .readFileSync( + path.join(projectPath, 'src/introspection.d.ts'), + 'utf8' + ) + .replace(/^ name: never;$/m, " name: 'one';") + ); + const text = + "import { initGraphQLTada } from 'gql.tada';\nimport type { introspection } from './introspection.js';\nconst graphql = initGraphQLTada<{ introspection: introspection }>();\nconst query = graphql(`query { pokemons(limit: 1) { nmae } }`);\n"; + const namedFile = path.join(directory, 'query.ts'); + fs.writeFileSync(namedFile, text); + languageServer.open(namedFile, text); + await languageServer.request('textDocument/hover', { + textDocument: { uri: url.pathToFileURL(namedFile).href }, + position: { line: 0, character: 0 }, + }); + const { pipe } = await languageServer.request( + 'custom/initializeAPISession', + {} + ); + fresh = await createLanguageServerSession({ + compiler: server.name, + packageJsonPath: server.packageJsonPath, + pipe, + persist: false, + }); + const result = await fresh.check(namedFile); + expect(result?.diagnostics).toEqual( + expect.arrayContaining([ + expect.objectContaining({ message: expect.stringContaining('nmae') }), + ]) + ); + } finally { + fresh?.dispose(); + fs.rmSync(directory, { recursive: true, force: true }); + } + }, 60_000); + it('returns null for files outside of GraphQLSP projects', async context => { const session = requireSession(context); const outside = path.resolve(__dirname, 'ts7.test.ts'); languageServer.open(outside, fs.readFileSync(outside, 'utf8')); expect(await session.check(outside)).toBeNull(); }, 60_000); + + it('reconnects after the language server restarts', async context => { + requireSession(context).dispose(); + await languageServer.dispose(); + const manifest = JSON.parse( + fs.readFileSync(server.packageJsonPath, 'utf8') + ); + const bin = path.resolve( + path.dirname(server.packageJsonPath), + Object.values(manifest.bin)[0]! + ); + languageServer = new LanguageServer(process.execPath, [ + bin, + ...server.args, + ]); + await languageServer.initialize(); + languageServer.open(fileName, savedText); + const { pipe } = await languageServer.request( + 'custom/initializeAPISession', + {} + ); + session = await createLanguageServerSession({ + compiler: server.name, + packageJsonPath: server.packageJsonPath, + pipe, + persist: false, + }); + const hover = await session.hover( + fileName, + positionAt(savedText, savedText.indexOf(' id') + 9) + ); + expect(hover?.result.contents.value).toContain('Pokemon.id'); + }, 60_000); }); diff --git a/test/e2e/ts7.test.ts b/test/e2e/ts7.test.ts index 5a1aef26..7926d00f 100644 --- a/test/e2e/ts7.test.ts +++ b/test/e2e/ts7.test.ts @@ -39,14 +39,15 @@ const compilers: Compiler[] = [ }, ].filter( compiler => - !process.env.GRAPHQLSP_TS7_COMPILER || - process.env.GRAPHQLSP_TS7_COMPILER === compiler.name + (compiler.name !== 'tsrs' || process.platform !== 'win32') && + (!process.env.GRAPHQLSP_TS7_COMPILER || + process.env.GRAPHQLSP_TS7_COMPILER === compiler.name) ); const toSnapshot = (diagnostics: CheckDiagnostic[]) => diagnostics .map(({ file, ...diagnostic }) => ({ - file: path.relative(projectPath, file), + file: path.relative(projectPath, file).replace(/\\/g, '/'), ...diagnostic, })) .sort((a, b) => a.file.localeCompare(b.file) || a.start - b.start); @@ -140,8 +141,11 @@ describe.each(compilers)('checkProject with $label', compiler => { }); if (compiler.name === 'typescript') expect(parseInt(result.compiler.version, 10)).toBeGreaterThanOrEqual(7); - expect(result.files.map(file => path.relative(projectPath, file)).sort()) - .toMatchInlineSnapshot(` + expect( + result.files + .map(file => path.relative(projectPath, file).replace(/\\/g, '/')) + .sort() + ).toMatchInlineSnapshot(` [ "src/colocation.ts", "src/field-usage.ts", diff --git a/test/unit/editVersions.test.ts b/test/unit/editVersions.test.ts new file mode 100644 index 00000000..43d5297e --- /dev/null +++ b/test/unit/editVersions.test.ts @@ -0,0 +1,45 @@ +import { expect, it } from 'vitest'; +import { + captureDocumentVersions, + editsAreCurrent, +} from '../../packages/vscode-graphqlsp/src/typescript7/editVersions'; + +it('rejects cross-file edits if a referenced document changes, closes or reopens', () => { + const first = { + uri: { toString: () => 'file:///project/query.ts' }, + version: 1, + }; + const other = { + uri: { toString: () => 'file:///project/fragment.ts' }, + version: 2, + }; + const edits = { + changes: { 'file:///project/fragment.ts': [{ newText: 'Details' }] }, + }; + const captured = captureDocumentVersions([first, other]); + expect(editsAreCurrent(edits, captured, [first, other])).toBe(true); + other.version++; + expect(editsAreCurrent(edits, captured, [first, other])).toBe(false); + other.version--; + expect(editsAreCurrent(edits, captured, [first])).toBe(false); + expect(editsAreCurrent(edits, captured, [first, { ...other }])).toBe(false); + expect( + editsAreCurrent([{ edit: edits }], captured, [first, { ...other }]) + ).toBe(false); +}); + +it('normalizes escaped URI forms and ignores unrelated document edits', () => { + const document = { + uri: { toString: () => 'file:///project/my%20fragment.ts' }, + version: 1, + }; + const before = captureDocumentVersions([document]); + expect( + editsAreCurrent( + { changes: { 'file:///project/my fragment.ts': [] } }, + before, + [document] + ) + ).toBe(true); + expect(editsAreCurrent({ contents: 'hover' }, before, [])).toBe(true); +}); diff --git a/test/unit/lspProtocol.test.ts b/test/unit/lspProtocol.test.ts new file mode 100644 index 00000000..ae4d4263 --- /dev/null +++ b/test/unit/lspProtocol.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from 'vitest'; +import { PassThrough } from 'node:stream'; +import { + readMessages, + writeMessage, +} from '../../packages/graphqlsp/src/lspProtocol'; +import { + offsetAt, + positionAt, +} from '../../packages/graphqlsp/src/languageFeatures'; + +describe('LSP transport', () => { + it('handles fragmented frames, multiple messages and UTF-8 lengths', () => { + const wire = new PassThrough(); + const chunks: Buffer[] = []; + wire.on('data', chunk => chunks.push(chunk)); + writeMessage(wire, { id: 1, result: 'Pokémon 🦊' }); + writeMessage(wire, { id: 'two', result: null }); + const input = new PassThrough(), + received: any[] = []; + readMessages( + input, + message => received.push(message), + error => { + throw error; + } + ); + const data = Buffer.concat(chunks); + for (let index = 0; index < data.length; index += 3) + input.write(data.subarray(index, index + 3)); + expect(received).toEqual([ + { id: 1, result: 'Pokémon 🦊' }, + { id: 'two', result: null }, + ]); + }); + it('rejects malformed frame lengths', () => { + const input = new PassThrough(); + const errors: Error[] = []; + readMessages( + input, + () => { + throw new Error('Unexpected message'); + }, + error => errors.push(error) + ); + input.write('Content-Length: -1\r\n\r\n'); + expect(errors[0]?.message).toBe('Invalid LSP Content-Length'); + }); + it('maps UTF-16 positions and CRLF correctly', () => { + const text = '// 🦊\r\nquery { name }\r\n'; + const offset = text.indexOf('name'); + expect(positionAt(text, offset)).toEqual({ line: 1, character: 8 }); + expect(offsetAt(text, { line: 1, character: 8 })).toBe(offset); + expect(offsetAt(text, { line: 0, character: 999 })).toBe(5); + }); +}); diff --git a/test/unit/nativeMode.test.ts b/test/unit/nativeMode.test.ts new file mode 100644 index 00000000..b93b35db --- /dev/null +++ b/test/unit/nativeMode.test.ts @@ -0,0 +1,29 @@ +import { expect, it } from 'vitest'; +import { nativeModeSetting } from '../../packages/vscode-graphqlsp/src/typescript7/nativeMode'; + +it('lets a workspace legacy setting override a global current setting', () => { + expect( + nativeModeSetting({ globalValue: true }, { workspaceValue: false }) + ).toBe(false); + expect( + nativeModeSetting({ globalValue: false }, { workspaceValue: true }) + ).toBe(true); +}); +it('lets workspace-folder settings win and current keys win ties', () => { + expect( + nativeModeSetting({ workspaceValue: false }, { workspaceFolderValue: true }) + ).toBe(true); + expect( + nativeModeSetting({ workspaceValue: false }, { workspaceValue: true }) + ).toBe(false); + expect(nativeModeSetting({ globalValue: true }, { globalValue: false })).toBe( + true + ); +}); +it('uses explicit language values and does not mistake defaults for an override', () => { + expect( + nativeModeSetting({ globalValue: true, globalLanguageValue: false }) + ).toBe(false); + expect(nativeModeSetting({ defaultValue: false })).toBeUndefined(); + expect(nativeModeSetting()).toBeUndefined(); +}); diff --git a/test/unit/schemaRecovery.test.ts b/test/unit/schemaRecovery.test.ts new file mode 100644 index 00000000..255a746b --- /dev/null +++ b/test/unit/schemaRecovery.test.ts @@ -0,0 +1,67 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import ts from 'typescript'; +import { expect, it } from 'vitest'; +import { init } from '../../packages/graphqlsp/src/ts'; +import { loadSchema } from '../../packages/graphqlsp/src/graphql/getSchema'; + +it('recovers created schemas and invalidates deleted or replaced schemas', async () => { + init({ typescript: ts }); + const directory = await fs.mkdtemp( + path.join(os.tmpdir(), 'graphqlsp-recovery-') + ); + const file = path.join(directory, 'schema.graphql'); + let changes = 0; + const schema = loadSchema( + { + config: { schema: './schema.graphql' }, + project: { + getProjectName: () => path.join(directory, 'tsconfig.json'), + projectService: { logger: { info: () => {} } }, + }, + languageService: { getProgram: () => undefined }, + }, + () => {}, + { + rootPath: directory, + watch: false, + persist: false, + onChange: () => changes++, + } + ); + const changed = async (operation: () => Promise) => { + const previous = changes; + await operation(); + schema.checkStale(true); + for (let attempt = 0; attempt < 100 && changes === previous; attempt++) + await new Promise(resolve => setTimeout(resolve, 10)); + expect(changes).toBeGreaterThan(previous); + }; + try { + await schema.ready; + expect(schema.current).toBeNull(); + expect(schema.errors.load.size).toBeGreaterThan(0); + await changed(() => fs.writeFile(file, 'type Query { first: String }')); + expect( + schema.current?.schema.getQueryType()?.getFields().first + ).toBeTruthy(); + expect(schema.errors.load.size).toBe(0); + await changed(() => fs.unlink(file)); + expect(schema.current).toBeNull(); + expect(schema.errors.load.size).toBeGreaterThan(0); + await changed(() => fs.writeFile(file, 'type Query { second: String }')); + expect( + schema.current?.schema.getQueryType()?.getFields().second + ).toBeTruthy(); + await changed(async () => { + await fs.writeFile(file, 'type Query { third: String }'); + await fs.utimes(file, new Date('2020-01-01'), new Date('2020-01-01')); + }); + expect( + schema.current?.schema.getQueryType()?.getFields().third + ).toBeTruthy(); + } finally { + await fs.rm(directory, { recursive: true, force: true }); + } +}); diff --git a/test/unit/vscode-ts7-features.test.ts b/test/unit/vscode-ts7-features.test.ts new file mode 100644 index 00000000..0bca3e68 --- /dev/null +++ b/test/unit/vscode-ts7-features.test.ts @@ -0,0 +1,224 @@ +import { beforeEach, expect, it, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + providers: new Map(), + registrations: [] as any[], + documents: [] as any[], +})); +vi.mock('vscode', () => { + class Position { + constructor( + public line: number, + public character: number + ) {} + } + class Range { + constructor( + public start: Position, + public end: Position + ) {} + } + class WorkspaceEdit { + changes = new Map(); + set(uri: any, edits: any) { + this.changes.set(uri, edits); + } + } + class Disposable { + static from(...items: any[]) { + return { dispose: () => items.forEach(item => item.dispose()) }; + } + } + const register = + (name: string) => + (...args: any[]) => { + mocks.providers.set(name, args[1]); + const dispose = vi.fn(); + mocks.registrations.push({ name, args, dispose }); + return { dispose }; + }; + return { + Position, + Range, + WorkspaceEdit, + Disposable, + Uri: { parse: (value: string) => value }, + Hover: class { + constructor( + public contents: any, + public range: any + ) {} + }, + MarkdownString: class { + constructor(public value: string) {} + }, + CompletionItem: class { + constructor( + public label: string, + public kind: any + ) {} + }, + CompletionList: class { + constructor( + public items: any[], + public isIncomplete: boolean + ) {} + }, + TextEdit: class { + constructor( + public range: any, + public newText: string + ) {} + }, + Location: class { + constructor( + public uri: string, + public range: any + ) {} + }, + CodeAction: class { + constructor( + public title: string, + public kind: any + ) {} + }, + CodeActionKind: { + QuickFix: 'quickfix', + RefactorExtract: 'refactor.extract', + }, + CompletionItemKind: { Field: 4 }, + workspace: { textDocuments: mocks.documents }, + languages: Object.fromEntries( + [ + 'Hover', + 'CompletionItem', + 'Definition', + 'Reference', + 'Rename', + 'CodeActions', + ].map(name => ['register' + name + 'Provider', register(name)]) + ), + }; +}); + +import { registerFeatures } from '../../packages/vscode-graphqlsp/src/typescript7/features'; + +const uri = 'file:///project/query.ts'; +const document = { uri: { toString: () => uri } } as any; +const position = { line: 1, character: 3 } as any; +const range = { start: { line: 1, character: 0 }, end: position }; +const token = { isCancellationRequested: false } as any; + +beforeEach(() => { + mocks.providers.clear(); + mocks.registrations.length = 0; + mocks.documents.splice(0, Infinity, document); +}); + +it('augments middleware responses and preserves TypeScript results elsewhere', async () => { + const middleware = new Map(); + const request = vi + .fn() + .mockResolvedValueOnce({ + contents: { kind: 'markdown', value: 'Pokemon.name' }, + }) + .mockResolvedValueOnce(undefined); + const disposable = registerFeatures( + { + registerLspMiddleware: (method, transform) => { + middleware.set(method, transform); + return { dispose: vi.fn() }; + }, + }, + request + ); + const params = { textDocument: { uri }, position }; + expect(await middleware.get('textDocument/hover')(null, { params })).toEqual({ + contents: { kind: 'markdown', value: 'Pokemon.name' }, + }); + const original = { items: [{ label: 'TypeScript' }] }; + expect( + await middleware.get('textDocument/completion')(original, { params }) + ).toBe(original); + expect(mocks.providers.has('Hover')).toBe(false); + expect(mocks.providers.has('CompletionItem')).toBe(true); + expect( + mocks.providers.get('CompletionItem').resolveCompletionItem + ).toBeUndefined(); + // Rename is always registered directly, independently of response middleware. + expect(mocks.providers.has('Rename')).toBe(true); + disposable.dispose(); + expect( + mocks.registrations.every( + registration => registration.dispose.mock.calls.length === 1 + ) + ).toBe(true); +}); + +it('suppresses native completions even when a recognized GraphQL context has no suggestions', async () => { + const middleware = new Map(); + const request = vi.fn().mockResolvedValue({ isIncomplete: false, items: [] }); + registerFeatures( + { + registerLspMiddleware: (method, transform) => { + middleware.set(method, transform); + return { dispose: vi.fn() }; + }, + }, + request + ); + expect( + await middleware.get('textDocument/completion')( + { items: [{ label: 'native' }] }, + { params: { textDocument: { uri }, position } } + ) + ).toEqual({ isIncomplete: false, items: [] }); +}); + +it('registers hover and completion fallbacks for older TypeScript extensions', async () => { + const request = vi + .fn() + .mockResolvedValueOnce({ contents: { value: 'Pokemon.name' }, range }) + .mockResolvedValueOnce({ + items: [{ label: 'name', textEdit: { range, newText: 'name' } }], + isIncomplete: false, + }); + registerFeatures({}, request); + const hover = await mocks.providers + .get('Hover') + .provideHover(document, position, token); + expect(hover.contents.value).toBe('Pokemon.name'); + const completion = await mocks.providers + .get('CompletionItem') + .provideCompletionItems(document, position, token); + expect(completion.items[0].insertText).toBe('name'); + expect(completion.items[0].range.start.character).toBe(0); +}); + +it('converts fragment rename edits across files and respects cancellation', async () => { + const changes = { + [uri]: [{ range, newText: 'Details' }], + 'file:///project/fragment.ts': [{ range, newText: 'Details' }], + }; + const request = vi + .fn() + .mockResolvedValueOnce({ range, placeholder: 'Fields' }) + .mockResolvedValue({ changes }); + registerFeatures({}, request); + const provider = mocks.providers.get('Rename'); + expect( + (await provider.prepareRename(document, position, token)).placeholder + ).toBe('Fields'); + const edits = await provider.provideRenameEdits( + document, + position, + 'Details', + token + ); + expect(edits.changes.size).toBe(2); + expect( + await provider.provideRenameEdits(document, position, 'Details', { + isCancellationRequested: true, + }) + ).toBeUndefined(); +}); diff --git a/test/vscode/extension/extension.cjs b/test/vscode/extension/extension.cjs new file mode 100644 index 00000000..d3076d70 --- /dev/null +++ b/test/vscode/extension/extension.cjs @@ -0,0 +1 @@ +exports.activate = () => {}; diff --git a/test/vscode/extension/package.json b/test/vscode/extension/package.json new file mode 100644 index 00000000..fbdef338 --- /dev/null +++ b/test/vscode/extension/package.json @@ -0,0 +1,12 @@ +{ + "name": "graphqlsp-dogfood", + "publisher": "local", + "version": "0.0.1", + "engines": { + "vscode": "^1.80.0" + }, + "main": "./extension.cjs", + "activationEvents": [ + "*" + ] +} diff --git a/test/vscode/extension/tests.cjs b/test/vscode/extension/tests.cjs new file mode 100644 index 00000000..862ead64 --- /dev/null +++ b/test/vscode/extension/tests.cjs @@ -0,0 +1,488 @@ +const vscode = require('vscode'), + fs = require('node:fs'), + path = require('node:path'), + assert = require('node:assert/strict'); +const config = require('../config.json'); +const results = { + startedAt: new Date().toISOString(), + environment: { vscode: vscode.version, platform: process.platform }, + tests: [], +}; +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); +const save = () => + fs.writeFileSync(config.results, JSON.stringify(results, null, 2)); +const uri = file => vscode.Uri.file(path.join(config.project, file)); +const diagnostics = document => + vscode.languages + .getDiagnostics(document.uri) + .filter(d => d.source === 'graphqlsp'); +const pos = (document, text, delta = 1) => + document.positionAt(document.getText().indexOf(text) + delta); +async function poll(label, fn, timeout = 30000) { + const start = Date.now(); + let last; + while (Date.now() - start < timeout) { + try { + const value = await fn(); + if (value) return value; + } catch (error) { + last = error; + } + await delay(100); + } + throw new Error(`Timed out: ${label}${last ? ' (' + last.stack + ')' : ''}`); +} +async function replace(document, text) { + const edit = new vscode.WorkspaceEdit(); + edit.replace( + document.uri, + new vscode.Range( + document.positionAt(0), + document.positionAt(document.getText().length) + ), + text + ); + assert.equal(await vscode.workspace.applyEdit(edit), true); +} +const hoverText = hovers => + (hovers || []) + .flatMap(hover => + hover.contents.map(content => + typeof content === 'string' ? content : content.value + ) + ) + .join('\n'); +async function test(name, fn) { + const started = Date.now(); + try { + const evidence = await fn(); + results.tests.push({ + name, + passed: true, + durationMs: Date.now() - started, + evidence, + }); + save(); + } catch (error) { + results.tests.push({ + name, + passed: false, + durationMs: Date.now() - started, + error: error.stack, + }); + save(); + throw error; + } +} +const restartClassic = () => + poll('classic restart command registered', async () => { + try { + await vscode.commands.executeCommand('typescript.restartTsServer'); + return true; + } catch (error) { + if (String(error).includes('not found')) return false; + throw error; + } + }); + +exports.run = async () => { + try { + const native = vscode.extensions.getExtension( + 'TypeScriptTeam.native-preview' + ); + const graphqlsp = vscode.extensions.getExtension('0no-co.vscode-graphqlsp'); + assert(native && graphqlsp, 'Both installed extensions must exist'); + results.environment.nativeExtension = native.packageJSON.version; + results.environment.graphqlsp = graphqlsp.packageJSON.version; + const api = await native.activate(); + results.environment.nativeApiKeys = Object.keys(api || {}); + await graphqlsp.activate(); + let initializations = 0; + const subscription = api.onLanguageServerInitialized( + () => initializations++ + ); + const doc = await vscode.workspace.openTextDocument(uri('src/invalid.ts')); + await vscode.window.showTextDocument(doc); + const savedInvalid = doc.getText(), + savedValid = fs.readFileSync( + path.join(config.project, 'src/valid.ts'), + 'utf8' + ); + await test('installed VSIX publishes GraphQL diagnostics with native TypeScript', async () => { + const errors = await poll('GraphQL diagnostics', () => { + const ds = diagnostics(doc); + return ds.some(d => d.message.includes('nmae')) && ds; + }); + return errors.map(d => ({ + source: d.source, + code: d.code, + message: d.message, + range: [d.range.start.line, d.range.start.character], + })); + }); + await test('unsaved fixes clear diagnostics and new typos reappear', async () => { + await replace(doc, savedValid); + await poll('clear errors', () => diagnostics(doc).length === 0); + const start = Date.now(); + await replace(doc, savedValid.replace(' id', ' nmae')); + await poll('unsaved typo', () => + diagnostics(doc).some(d => d.message.includes('nmae')) + ); + const latencyMs = Date.now() - start; + await replace(doc, savedValid); + await poll('clear again', () => diagnostics(doc).length === 0); + return { dirty: doc.isDirty, latencyMs }; + }); + await test('GraphQL hover through the real editor provider pipeline', async () => { + await replace(doc, savedValid.replace(' id', ' name')); + const content = await poll('GraphQL hover', async () => { + const text = hoverText( + await vscode.commands.executeCommand( + 'vscode.executeHoverProvider', + doc.uri, + pos(doc, ' name', 10) + ) + ); + return text.includes('Pokemon.name') && text; + }); + return { contents: content }; + }); + await test('GraphQL completion replaces a partial field correctly', async () => { + await replace(doc, savedValid.replace(' id', ' maxC')); + const p = pos(doc, 'maxC', 4); + const item = await poll('completion maxCP', async () => { + const list = await vscode.commands.executeCommand( + 'vscode.executeCompletionItemProvider', + doc.uri, + p, + undefined, + 20 + ); + return list?.items.find( + item => + (typeof item.label === 'string' ? item.label : item.label.label) === + 'maxCP' + ); + }); + let range = item.range; + if (range && !('start' in range)) range = range.replacing; + range = range || item.textEdit?.range; + assert(range, 'Completion replacement range is required'); + assert.equal(doc.getText(range), 'maxC'); + const insert = + typeof item.insertText === 'string' + ? item.insertText + : item.insertText?.value || 'maxCP'; + const edit = new vscode.WorkspaceEdit(); + edit.replace(doc.uri, range, insert); + assert(await vscode.workspace.applyEdit(edit)); + assert(doc.getText().includes(' maxCP')); + await poll( + 'completion clears typo', + () => !diagnostics(doc).some(d => d.message.includes('maxC')) + ); + return { label: item.label, insertText: insert, replacement: 'maxC' }; + }); + await test('schema definition and cross-file fragment references', async () => { + await replace(doc, savedValid); + const locations = await poll('definition', async () => { + const result = await vscode.commands.executeCommand( + 'vscode.executeDefinitionProvider', + doc.uri, + pos(doc, ' id', 9) + ); + return ( + result?.some(location => + (location.targetUri || location.uri).fsPath.endsWith( + 'schema.graphql' + ) + ) && result + ); + }); + const references = await poll('references', async () => { + const refs = await vscode.commands.executeCommand( + 'vscode.executeReferenceProvider', + doc.uri, + pos(doc, '...pokemonFields', 4) + ); + return refs?.length >= 2 && refs; + }); + return { + definitions: locations.map( + location => (location.targetUri || location.uri).fsPath + ), + references: references.map(location => location.uri.fsPath), + }; + }); + await test('fragment rename through the editor and across documents', async () => { + const fragment = await vscode.workspace.openTextDocument( + uri('src/fragment.ts') + ); + const saved = fragment.getText(); + const commands = await vscode.commands.getCommands(true); + results.environment.renameCommands = commands.filter(command => + /prepareRename|executeDocumentRename/i.test(command) + ); + const position = pos(doc, '...pokemonFields', 4); + if (commands.includes('vscode.prepareRename')) { + const prepared = await vscode.commands.executeCommand( + 'vscode.prepareRename', + doc.uri, + position + ); + assert(prepared, 'Prepare rename failed'); + } + const edit = await vscode.commands.executeCommand( + 'vscode.executeDocumentRenameProvider', + doc.uri, + position, + 'PokemonDetails' + ); + assert(edit, 'No fragment rename edits'); + const entries = edit.entries(); + assert(entries.length >= 2, 'Rename must include both files'); + assert(await vscode.workspace.applyEdit(edit)); + assert(doc.getText().includes('...PokemonDetails')); + assert(fragment.getText().includes('fragment PokemonDetails')); + const evidence = entries.map(([uri, edits]) => ({ + file: uri.fsPath, + edits: edits.length, + })); + await replace(fragment, saved); + await replace(doc, savedValid); + return evidence; + }); + await test('quick fix and fragment extraction are offered and applicable', async () => { + await replace(doc, savedValid.replace(' id', ' nmae')); + const diagnostic = await poll('typo diagnostic', () => + diagnostics(doc).find(d => d.message.includes('nmae')) + ); + const fixes = await vscode.commands.executeCommand( + 'vscode.executeCodeActionProvider', + doc.uri, + diagnostic.range, + 'quickfix' + ); + const fix = fixes?.find( + action => action.title.includes('name') && action.edit + ); + assert(fix, 'No GraphQL typo fix'); + assert(await vscode.workspace.applyEdit(fix.edit)); + assert(doc.getText().includes(' name')); + await replace(doc, savedValid); + const start = pos(doc, ' id', 8), + end = start.translate(0, 2); + const actions = await vscode.commands.executeCommand( + 'vscode.executeCodeActionProvider', + doc.uri, + new vscode.Range(start, end), + 'refactor.extract' + ); + const extract = actions?.find( + action => action.title === 'Extract to fragment' + ); + assert(extract?.edit, 'No extraction action'); + assert(await vscode.workspace.applyEdit(extract.edit)); + assert(doc.getText().includes('fragment PokemonFields')); + const evidence = { + quickFix: fix.title, + refactor: extract.title, + result: doc.getText(), + }; + await replace(doc, savedValid); + return evidence; + }); + await test('schema changes recheck unchanged editor documents', async () => { + const schema = await vscode.workspace.openTextDocument( + uri('schema.graphql') + ); + const original = schema.getText(); + try { + await replace( + doc, + savedValid.replace(' id', ' newEditorField') + ); + await poll('unknown new field', () => + diagnostics(doc).some(d => d.message.includes('newEditorField')) + ); + await replace( + schema, + original.replace( + ' name: String!', + ' newEditorField: String\n name: String!' + ) + ); + await schema.save(); + await poll( + 'schema reload clears error', + () => + !diagnostics(doc).some(d => d.message.includes('newEditorField')) + ); + return { schemaReloaded: true }; + } finally { + await replace(schema, original); + await schema.save(); + await replace(doc, savedValid); + } + }); + await test('native server restart restores diagnostics without duplicates', async () => { + await replace(doc, savedInvalid); + await poll('baseline typo', () => + diagnostics(doc).some(d => d.message.includes('nmae')) + ); + const before = initializations; + await vscode.commands.executeCommand('typescript.native-preview.restart'); + await poll('native restarted', () => initializations > before); + const errors = await poll('diagnostics after restart', () => { + const ds = diagnostics(doc); + return ds.some(d => d.message.includes('nmae')) && ds; + }); + const keys = errors.map( + d => `${d.code}:${d.range.start.line}:${d.range.start.character}` + ); + assert.equal( + new Set(keys).size, + keys.length, + 'Duplicate diagnostics after restart' + ); + return { initializations, diagnosticCount: errors.length }; + }); + await test('schema deletion and recreation recover in the editor', async () => { + await replace(doc, savedInvalid); + const schemaPath = path.join(config.project, 'schema.graphql'), + original = fs.readFileSync(schemaPath, 'utf8'); + try { + fs.unlinkSync(schemaPath); + await poll('deleted schema error', () => + diagnostics(doc).some(d => d.message.includes('Failed to load')) + ); + fs.writeFileSync(schemaPath, original); + await poll( + 'recreated schema recovery', + () => + diagnostics(doc).some(d => d.message.includes('nmae')) && + !diagnostics(doc).some(d => d.message.includes('Failed to load')) + ); + return { recovered: true }; + } finally { + fs.writeFileSync(schemaPath, original); + } + }); + await test('switching to classic TypeScript and back avoids stale or duplicate diagnostics', async () => { + const settings = vscode.workspace.getConfiguration('js/ts'); + await settings.update( + 'tsdk.path', + path.join(config.legacy, 'lib'), + vscode.ConfigurationTarget.Global + ); + await settings.update( + 'experimental.useTsgo', + false, + vscode.ConfigurationTarget.Global + ); + await restartClassic(); + await replace(doc, savedValid); + await replace(doc, savedInvalid); + const legacy = await poll('classic TypeScript diagnostic', () => { + const ds = vscode.languages.getDiagnostics(doc.uri); + return ( + ds.some(d => d.source === 'ts' && d.message.includes('nmae')) && ds + ); + }); + assert.equal( + legacy.filter(d => d.source === 'graphqlsp').length, + 0, + 'Native diagnostics must clear when switching to TypeScript 6' + ); + await settings.update( + 'tsdk.path', + config.native, + vscode.ConfigurationTarget.Global + ); + const before = initializations; + await settings.update( + 'experimental.useTsgo', + true, + vscode.ConfigurationTarget.Global + ); + await poll( + 'native TypeScript re-enabled', + () => initializations > before + ); + await poll('native GraphQL diagnostics restored', () => + diagnostics(doc).some(d => d.message.includes('nmae')) + ); + return { classic: true, nativeRestored: true }; + }); + await test('workspace legacy setting takes precedence over global native mode', async () => { + const current = vscode.workspace.getConfiguration('js/ts'), + legacy = vscode.workspace.getConfiguration('typescript'); + await legacy.update( + 'experimental.useTsgo', + false, + vscode.ConfigurationTarget.Workspace + ); + await poll( + 'native diagnostics cleared under workspace alias', + () => diagnostics(doc).length === 0 + ); + assert.equal( + diagnostics(doc).length, + 0, + 'Workspace legacy disable must clear native GraphQL diagnostics' + ); + const before = initializations; + await legacy.update( + 'experimental.useTsgo', + undefined, + vscode.ConfigurationTarget.Workspace + ); + await poll( + 'native restored after workspace override removal', + () => + initializations > before && + diagnostics(doc).some(d => d.message.includes('nmae')) + ); + return { scopePrecedence: true, nativeRestored: true }; + }); + await test('resetting native mode to unset clears the native integration', async () => { + const settings = vscode.workspace.getConfiguration('js/ts'); + await settings.update( + 'experimental.useTsgo', + undefined, + vscode.ConfigurationTarget.Global + ); + await poll( + 'native diagnostics cleared after resetting setting', + () => diagnostics(doc).length === 0 + ); + assert.equal( + diagnostics(doc).length, + 0, + 'Resetting the only explicit setting must clear GraphQL native diagnostics' + ); + const before = initializations; + await settings.update( + 'experimental.useTsgo', + true, + vscode.ConfigurationTarget.Global + ); + await poll( + 'native restored after explicit enable', + () => + initializations > before && + diagnostics(doc).some(d => d.message.includes('nmae')) + ); + return { unsetHandled: true, nativeRestored: true }; + }); + subscription.dispose(); + results.passed = true; + results.finishedAt = new Date().toISOString(); + save(); + } catch (error) { + results.passed = false; + results.finishedAt = new Date().toISOString(); + save(); + throw error; + } +}; diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..0d31d149 --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from 'vite'; +import { createRequire } from 'node:module'; +import path from 'node:path'; + +// @gql.tada/internal builds schemas through require('graphql'). Use that same +// instance when testing source modules, rather than Vite's preferred ESM build. +const require = createRequire(path.resolve('packages/graphqlsp/package.json')); +export default defineConfig({ + resolve: { + alias: [{ find: /^graphql$/, replacement: require.resolve('graphql') }], + }, +}); From aa25882b1292a8c6d957c6e1ed0658cac41832c9 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Wed, 28 Oct 2026 20:08:29 +0100 Subject: [PATCH 2/2] fix: drop the Windows API relay The relay compiled a C# launcher with csc.exe into a temp directory at runtime. Restore the explicit Windows error until TypeScript's sync client can connect to API pipes, and drop Windows from CI and the docs. --- .changeset/ts7-language-features.md | 2 +- .github/workflows/package-vscode.yaml | 2 +- README.md | 17 ++--- docs/typescript-7-development.md | 28 +++---- packages/graphqlsp/README.md | 17 ++--- packages/graphqlsp/src/ts7/relay.ts | 43 ++++------- packages/graphqlsp/src/ts7/windowsRelay.ts | 89 ---------------------- packages/vscode-graphqlsp/README.md | 8 +- 8 files changed, 46 insertions(+), 160 deletions(-) delete mode 100644 packages/graphqlsp/src/ts7/windowsRelay.ts diff --git a/.changeset/ts7-language-features.md b/.changeset/ts7-language-features.md index a1df9612..d7e69266 100644 --- a/.changeset/ts7-language-features.md +++ b/.changeset/ts7-language-features.md @@ -2,7 +2,7 @@ '@0no-co/graphqlsp': minor --- -Expose hover, completion, schema definitions, fragment references and rename, quick fixes, and fragment extraction through TypeScript 7.1 language-server API sessions. Add the `graphqlsp --stdio` LSP proxy for other editors, and support the synchronous API relay on Windows using .NET Framework 4.x. +Expose hover, completion, schema definitions, fragment references and rename, quick fixes, and fragment extraction through TypeScript 7.1 language-server API sessions. Add the `graphqlsp --stdio` LSP proxy for other editors. Preserve native TypeScript capabilities, keep GraphQL completion resolution local, reject stale cross-file edits, recover local schemas after deletion/recreation, and await named schemas before the first feature request. The VS Code integration also disposes native providers when switching back to classic TypeScript or resetting native enablement. diff --git a/.github/workflows/package-vscode.yaml b/.github/workflows/package-vscode.yaml index 59b9e41a..589a6f6d 100644 --- a/.github/workflows/package-vscode.yaml +++ b/.github/workflows/package-vscode.yaml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest, macos-latest] + os: [ubuntu-latest, macos-latest] steps: - uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3 - uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 diff --git a/README.md b/README.md index 198413f6..09fc8bb8 100644 --- a/README.md +++ b/README.md @@ -152,8 +152,7 @@ For projects with `contentMappers` in their tsconfig, pass `.graphql` files used by `@gql.tada/content-mapper`. Mappers run external processes, as with `tsc --runExternalCode`. GraphQLSP checks the JS/TS consumers; the mappers report diagnostics for the mapped files themselves. The tsrs API -does not run content mappers yet, and its current package does not provide a -Windows binary. The native TypeScript path is the locally verified Windows path. +does not run content mappers yet. ### Connecting to an existing language server @@ -272,9 +271,8 @@ as follows (the field names depend on the editor): } ``` -This direct Node command also works on Windows clients that cannot launch -`.cmd` shims. The equivalent executable, when `node_modules/.bin` is on the -client's PATH, is `graphqlsp --stdio`. +The equivalent executable, when `node_modules/.bin` is on the client's PATH, +is `graphqlsp --stdio`. Attach this server to the editor's JavaScript, JavaScript React, TypeScript, and TypeScript React language IDs (JS/JSX/TS/TSX files). It replaces your @@ -361,10 +359,10 @@ restarting the server creates a fresh API session. - The TS 7 path does not reproduce navigation from generated gql.tada turbo-cache result types back into queries. Ordinary native TypeScript navigation remains available at those positions. -- On Windows, the sync API relay requires the installed **.NET Framework 4.x - C# compiler**. It builds a small included launcher locally; no .NET SDK or - download is needed. Unix uses a shell launcher. The relay is bypassed when - TypeScript supports synchronous pipe connections directly. +- Windows isn't supported yet. TypeScript 7.1's sync API client can't connect + to the language server's API pipe there, and GraphQLSP's relay is Unix-only. + The relay is bypassed when TypeScript supports synchronous pipe connections + directly. - TypeScript 7.1 can hang during native API-session cleanup. The proxy drains its GraphQL worker first and requests clean native shutdown. If the native child does not reply within two seconds, the proxy terminates that child @@ -381,7 +379,6 @@ restarting the server creates a fresh API session. | No GraphQL results for a file | Ensure it belongs to a configured tsconfig project, has a valid schema, and uses the configured `graphql()` or tagged-template form. Inferred projects without a tsconfig entry are skipped. | | Native VS Code integration cannot find the API package | Select the same workspace SDK that the native server uses and allow the TypeScript extension to use it. Older extension APIs discover the package through SDK settings. | | Settings changed but behavior did not | Restart the TypeScript server after config edits. Save schema-file edits so watchers see them. | -| Windows API relay cannot start | Check that .NET Framework 4.x's `csc.exe` is installed; inspect the logged launcher/compiler error. | | Duplicate diagnostics or rename edits | Ensure only one TypeScript LSP handles each buffer; use either the extension integration or proxy. | For the proxy, capture stderr without mixing it into stdout. For VS Code, diff --git a/docs/typescript-7-development.md b/docs/typescript-7-development.md index 13e4777d..257bb401 100644 --- a/docs/typescript-7-development.md +++ b/docs/typescript-7-development.md @@ -27,8 +27,8 @@ references and rename, quick fixes, and fragment extraction. Native-mode changes dispose providers and diagnostics; server restarts create a fresh connection. - `packages/graphqlsp/src/ts7/relay.ts` bridges the synchronous API client when - direct pipe connections are unavailable. On Windows, `windowsRelay.ts` embeds - source compiled with .NET Framework 4.x; Unix uses a shell launcher. + direct pipe connections are unavailable. It uses a Unix shell launcher; + Windows isn't supported until TypeScript's sync client connects to pipes. Results include the source text they were computed against. Integrations must discard stale results. Cross-file edits additionally require validation of the @@ -52,8 +52,7 @@ pnpm --filter vscode-graphqlsp build The workspace compiler remains TS 5/6 for building and compatibility tests. The native fixture installs `typescript-7` as an alias of TypeScript 7.1.0-beta, so -native testing does not replace the workspace compiler. The tsrs fixture has no -Windows binary and is skipped there; use the native TypeScript implementation. +native testing does not replace the workspace compiler. ## Regression tests and typechecks @@ -120,9 +119,7 @@ After packaging, run the isolated editor harness from the repository root: node scripts/dogfood-vscode.mjs --code /absolute/path/to/Code --cli /absolute/path/to/resources/app/out/cli.js --results .tmp/dogfood-results.json ``` -Use the installed VS Code executable and its matching CLI JavaScript file. On -Windows, quote paths containing spaces; versioned installations can place -`resources/app/out/cli.js` under a version directory beside `Code.exe`. The +Use the installed VS Code executable and its matching CLI JavaScript file. The optional `--vsix /absolute/path/to/file.vsix` selects an artifact explicitly. The harness needs a desktop session and network access to install Microsoft's @@ -154,18 +151,17 @@ This contains the native cleanup race; it does not repair it upstream. ## Validation scope and release status -The Windows implementation was exercised with VS Code 1.136.2, -TypeScriptTeam.native-preview 1.0.1, and TypeScript 7.1.0-beta. Before the -documentation pass, 149 regression tests in 28 files passed, 13 real-editor -checks passed, both packages typechecked, and an independent sub-agent's -14-case native shutdown stress run passed. These are local validation results, -not claims about every TypeScript or editor extension version. +The editor features were first exercised on Windows with VS Code 1.136.2, +TypeScriptTeam.native-preview 1.0.1, and TypeScript 7.1.0-beta, through a +Windows relay that has since been removed. Those results don't cover the +supported macOS and Linux paths; rerun the regression suite and the editor +harness there before release. These are local validation results, not claims +about every TypeScript or editor extension version. The released native extension uses its older API shape; the newer middleware adapter is covered by unit tests, not an actual released-editor run. The -`package-vscode.yaml` workflow is configured to build/test/package on Windows, -macOS, and Linux. macOS/Linux jobs were not executed in this local session, -and that workflow does not run the actual desktop-editor harness. +`package-vscode.yaml` workflow builds, tests and packages on macOS and Linux. +It does not run the actual desktop-editor harness. The npm changeset and extension version are independent. This local work does not publish either artifact; CI packaging and Marketplace publishing are diff --git a/packages/graphqlsp/README.md b/packages/graphqlsp/README.md index 76b3b79a..0fa76a69 100644 --- a/packages/graphqlsp/README.md +++ b/packages/graphqlsp/README.md @@ -152,8 +152,7 @@ For projects with `contentMappers` in their tsconfig, pass `.graphql` files used by `@gql.tada/content-mapper`. Mappers run external processes, as with `tsc --runExternalCode`. GraphQLSP checks the JS/TS consumers; the mappers report diagnostics for the mapped files themselves. The tsrs API -does not run content mappers yet, and its current package does not provide a -Windows binary. The native TypeScript path is the locally verified Windows path. +does not run content mappers yet. ### Connecting to an existing language server @@ -272,9 +271,8 @@ as follows (the field names depend on the editor): } ``` -This direct Node command also works on Windows clients that cannot launch -`.cmd` shims. The equivalent executable, when `node_modules/.bin` is on the -client's PATH, is `graphqlsp --stdio`. +The equivalent executable, when `node_modules/.bin` is on the client's PATH, +is `graphqlsp --stdio`. Attach this server to the editor's JavaScript, JavaScript React, TypeScript, and TypeScript React language IDs (JS/JSX/TS/TSX files). It replaces your @@ -361,10 +359,10 @@ restarting the server creates a fresh API session. - The TS 7 path does not reproduce navigation from generated gql.tada turbo-cache result types back into queries. Ordinary native TypeScript navigation remains available at those positions. -- On Windows, the sync API relay requires the installed **.NET Framework 4.x - C# compiler**. It builds a small included launcher locally; no .NET SDK or - download is needed. Unix uses a shell launcher. The relay is bypassed when - TypeScript supports synchronous pipe connections directly. +- Windows isn't supported yet. TypeScript 7.1's sync API client can't connect + to the language server's API pipe there, and GraphQLSP's relay is Unix-only. + The relay is bypassed when TypeScript supports synchronous pipe connections + directly. - TypeScript 7.1 can hang during native API-session cleanup. The proxy drains its GraphQL worker first and requests clean native shutdown. If the native child does not reply within two seconds, the proxy terminates that child @@ -381,7 +379,6 @@ restarting the server creates a fresh API session. | No GraphQL results for a file | Ensure it belongs to a configured tsconfig project, has a valid schema, and uses the configured `graphql()` or tagged-template form. Inferred projects without a tsconfig entry are skipped. | | Native VS Code integration cannot find the API package | Select the same workspace SDK that the native server uses and allow the TypeScript extension to use it. Older extension APIs discover the package through SDK settings. | | Settings changed but behavior did not | Restart the TypeScript server after config edits. Save schema-file edits so watchers see them. | -| Windows API relay cannot start | Check that .NET Framework 4.x's `csc.exe` is installed; inspect the logged launcher/compiler error. | | Duplicate diagnostics or rename edits | Ensure only one TypeScript LSP handles each buffer; use either the extension integration or proxy. | For the proxy, capture stderr without mixing it into stdout. For VS Code, diff --git a/packages/graphqlsp/src/ts7/relay.ts b/packages/graphqlsp/src/ts7/relay.ts index 951610c0..b8627df3 100644 --- a/packages/graphqlsp/src/ts7/relay.ts +++ b/packages/graphqlsp/src/ts7/relay.ts @@ -1,7 +1,6 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { createWindowsRelay } from './windowsRelay'; /** The relay's source, run as a CommonJS script by the sync client. * @@ -213,42 +212,30 @@ const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; * extension host, that is Electron, hence `ELECTRON_RUN_AS_NODE`. */ export const createAPIRelay = (pipe: string): APIRelay => { + if (process.platform === 'win32') { + throw new Error( + "TypeScript 7.1's sync API client can't connect to API pipes on Windows yet." + ); + } const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'graphqlsp-')); const relayPath = path.join(directory, 'relay.cjs'); + const tsserverPath = path.join(directory, 'tsserver'); fs.writeFileSync(relayPath, relaySource); - let tsserverPath: string; - try { - tsserverPath = - process.platform === 'win32' - ? createWindowsRelay(directory, relayPath, pipe) - : path.join(directory, 'tsserver'); - if (process.platform !== 'win32') { - fs.writeFileSync( - tsserverPath, - '#!/bin/sh\n' + - `ELECTRON_RUN_AS_NODE=1 exec ${quote(process.execPath)} ${quote( - relayPath - )} ${quote(pipe)}\n`, - { mode: 0o755 } - ); - } - } catch (error) { - fs.rmSync(directory, { recursive: true, force: true }); - throw error; - } + fs.writeFileSync( + tsserverPath, + '#!/bin/sh\n' + + `ELECTRON_RUN_AS_NODE=1 exec ${quote(process.execPath)} ${quote( + relayPath + )} ${quote(pipe)}\n`, + { mode: 0o755 } + ); return { tsserverPath, drain(client) { client.apiRequest('__graphqlspClose', null); }, dispose() { - // Windows may still be closing the launcher executable after api.close(). - fs.rmSync(directory, { - recursive: true, - force: true, - maxRetries: 20, - retryDelay: 25, - }); + fs.rmSync(directory, { recursive: true, force: true }); }, }; }; diff --git a/packages/graphqlsp/src/ts7/windowsRelay.ts b/packages/graphqlsp/src/ts7/windowsRelay.ts deleted file mode 100644 index b7e10223..00000000 --- a/packages/graphqlsp/src/ts7/windowsRelay.ts +++ /dev/null @@ -1,89 +0,0 @@ -import fs from 'node:fs'; -import path from 'node:path'; -import { execFileSync } from 'node:child_process'; - -/** Small .NET Framework launcher for the sync client's Windows pipe transport. - * It forwards the binary pipe to the same Node relay used on Unix. Keeping the - * source here lets universal npm tarballs build it without shipping an opaque exe. - */ -const source = String.raw` -using System; -using System.IO; -using System.IO.Pipes; -using System.Diagnostics; -using System.Threading.Tasks; -class Relay { - static void Pump(Stream input, Stream output) { - byte[] buffer = new byte[65536]; - int count; - while ((count = input.Read(buffer, 0, buffer.Length)) > 0) { - output.Write(buffer, 0, count); - output.Flush(); - } - } - static string Quote(string value) { - return "\"" + value + "\""; - } - static int Main(string[] args) { - Process child = null; - try { - string pipe = null; - for (int i = 0; i + 1 < args.Length; i++) if (args[i] == "--pipe") pipe = args[i + 1]; - if (pipe == null) throw new Exception("Missing --pipe"); - string[] config = File.ReadAllLines(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "launcher.config")); - using (var channel = new NamedPipeServerStream(pipe.Substring(9), PipeDirection.InOut, 1, PipeTransmissionMode.Byte, PipeOptions.Asynchronous)) { - channel.WaitForConnection(); - var info = new ProcessStartInfo(config[0], Quote(config[1]) + " " + Quote(config[2])); - info.UseShellExecute = false; - info.CreateNoWindow = true; - info.RedirectStandardInput = true; - info.RedirectStandardOutput = true; - info.EnvironmentVariables["ELECTRON_RUN_AS_NODE"] = "1"; - child = Process.Start(info); - var input = Task.Factory.StartNew(() => Pump(channel, child.StandardInput.BaseStream)); - var output = Task.Factory.StartNew(() => Pump(child.StandardOutput.BaseStream, channel)); - Task.WaitAny(input, output); - } - return 0; - } catch (Exception error) { - Console.Error.WriteLine("[graphqlsp] Windows API relay: " + error.Message); - return 1; - } finally { - if (child != null) { try { if (!child.HasExited) child.Kill(); } catch {} child.Dispose(); } - } - } -} -`; - -export function createWindowsRelay( - directory: string, - relayPath: string, - pipe: string -): string { - const framework = path.join( - process.env.WINDIR || 'C:\\Windows', - 'Microsoft.NET' - ); - const compiler = ['Framework64', 'Framework'] - .map(architecture => - path.join(framework, architecture, 'v4.0.30319', 'csc.exe') - ) - .find(file => fs.existsSync(file)); - if (!compiler) - throw new Error( - 'GraphQLSP needs .NET Framework 4.x to build its Windows API relay.' - ); - const sourcePath = path.join(directory, 'launcher.cs'); - const executable = path.join(directory, 'tsserver.exe'); - fs.writeFileSync(sourcePath, source); - fs.writeFileSync( - path.join(directory, 'launcher.config'), - [process.execPath, relayPath, pipe].join('\n') - ); - execFileSync( - compiler, - ['/nologo', '/target:exe', `/out:${executable}`, sourcePath], - { windowsHide: true, stdio: 'pipe' } - ); - return executable; -} diff --git a/packages/vscode-graphqlsp/README.md b/packages/vscode-graphqlsp/README.md index da13fd0c..a195adcb 100644 --- a/packages/vscode-graphqlsp/README.md +++ b/packages/vscode-graphqlsp/README.md @@ -116,8 +116,7 @@ resetting explicit native-mode enablement, disposes the native diagnostics and providers. The deprecated `typescript.experimental.useTsgo` setting alias is also recognized with the native extension's scope precedence. -On Windows, the sync API relay builds an included launcher using the installed -**.NET Framework 4.x C# compiler**. No .NET SDK or download is needed. The TS 7 +Windows isn't supported yet with TS 7. The TS 7 path does not reproduce navigation from generated gql.tada turbo-cache result types back into queries. This extension supplies syntax highlighting for standalone `.graphql` files, but its native API language features target embedded GraphQL @@ -155,13 +154,12 @@ select the workspace SDK and allow that extension to use it. This extension requires a trusted workspace; VS Code's Restricted Mode disables it. If `graphqlsp.*` settings have no effect under TS 7, move that configuration to -the tsconfig plugin entry. For Windows relay failures, inspect the Output -message and verify .NET Framework 4.x's `csc.exe` is installed. +the tsconfig plugin entry. ### Packaging and release status The extension is released separately from the npm package. CI builds a `.vsix` -artifact on Windows, macOS, and Linux; install it with **Extensions: Install from +artifact on macOS and Linux; install it with **Extensions: Install from VSIX…**. Marketplace publishing remains a separate maintainer action. Locally generated packages and VSIX files are not automatically published.