diff --git a/.changeset/language-server-session-typescript-7.md b/.changeset/language-server-session-typescript-7.md new file mode 100644 index 00000000..b56e88e0 --- /dev/null +++ b/.changeset/language-server-session-typescript-7.md @@ -0,0 +1,5 @@ +--- +'@0no-co/graphqlsp': minor +--- + +Add `createLanguageServerSession` to `@0no-co/graphqlsp/check`, which reports GraphQLSP's diagnostics for files in a TypeScript 7.1 language server's projects, including editors' unsaved changes, through an API session of the language server diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3147e2ab..0a5f947f 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -124,6 +124,6 @@ jobs: run: pnpm --filter ./test/e2e/fixture-project-ts7 add --save-dev ${{ matrix.package }} - name: Test - run: pnpm exec vitest run test/e2e/ts7.test.ts + run: pnpm exec vitest run test/e2e/ts7.test.ts test/e2e/ts7-session.test.ts env: GRAPHQLSP_TS7_COMPILER: ${{ matrix.compiler }} diff --git a/README.md b/README.md index 9cb8630b..d2e7bbf7 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,25 @@ files can be resolved, e.g. by `graphql.persisted()`. Mapped files themselves ar 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. + +```ts +import { createLanguageServerSession } from '@0no-co/graphqlsp/check'; + +const session = await createLanguageServerSession({ + packageJsonPath: sdk.packageJsonPath, + pipe: await sdk.initializeAPIConnection(), +}); +// `text` is the file's text the diagnostics were computed for +const { text, diagnostics } = (await session.check(fileName)) ?? {}; +``` + ## 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/src/check.ts b/packages/graphqlsp/src/check.ts index 3cebba63..9e0e9a39 100644 --- a/packages/graphqlsp/src/check.ts +++ b/packages/graphqlsp/src/check.ts @@ -18,28 +18,17 @@ import { createTypeScriptNamespace } from './ts7/namespace'; import { createChecker } from './ts7/checker'; import { readPluginConfig } from './ts7/config'; import type { ReadConfigFile } from './ts7/config'; +import { toCheckDiagnostics } from './ts7/report'; +import type { CheckDiagnostic } from './ts7/report'; export type { CompilerName }; - -export type CheckSeverity = 'error' | 'warning' | 'suggestion' | 'message'; - -/** A GraphQLSP diagnostic. Plain data, so it can be serialized as-is. */ -export interface CheckDiagnostic { - /** Absolute path of the file the diagnostic is reported in. */ - file: string; - /** Start offset (in UTF-16 code units) into the file's text. */ - start: number; - /** Length (in UTF-16 code units) of the reported span. */ - length: number; - /** 1-based line of `start`. */ - line: number; - /** 1-based column (in UTF-16 code units) of `start`. */ - column: number; - message: string; - /** GraphQLSP's diagnostic code, e.g. `52001` for schema validation errors. */ - code: number; - severity: CheckSeverity; -} +export type { CheckDiagnostic, CheckSeverity } from './ts7/report'; +export { createLanguageServerSession } from './session'; +export type { + LanguageServerCheckResult, + LanguageServerSession, + LanguageServerSessionOptions, +} from './session'; export interface CheckProjectOptions { /** Path of the `tsconfig.json` to check. */ @@ -72,13 +61,6 @@ export interface CheckProjectResult { diagnostics: CheckDiagnostic[]; } -const severities: Record = { - 0: 'warning', - 1: 'error', - 2: 'suggestion', - 3: 'message', -}; - // Content-mapped files (e.g. `.graphql`) are part of the program as virtual // TypeScript, but their mappers report their diagnostics, so only TypeScript // and JavaScript files are checked @@ -89,43 +71,6 @@ const isCheckedFile = (fileName: string) => !/\.d\.[cm]?ts$/.test(fileName) && !/[\\/]node_modules[\\/]/.test(fileName); -const flattenMessage = ( - message: string | ts.DiagnosticMessageChain -): string => { - if (typeof message === 'string') return message; - let text = message.messageText; - for (const next of message.next || []) - text += '\n ' + flattenMessage(next).replace(/\n/g, '\n '); - return text; -}; - -const getLineStarts = (text: string) => { - const lineStarts = [0]; - for (let idx = 0; idx < text.length; idx++) { - const char = text.charCodeAt(idx); - if (char === 13 /*'\r'*/ && text.charCodeAt(idx + 1) === 10 /*'\n'*/) { - continue; - } else if (char === 10 || char === 13) { - lineStarts.push(idx + 1); - } - } - return lineStarts; -}; - -const toLineAndColumn = (lineStarts: number[], position: number) => { - let low = 0; - let high = lineStarts.length - 1; - while (low < high) { - const mid = (low + high + 1) >> 1; - if (lineStarts[mid]! <= position) { - low = mid; - } else { - high = mid - 1; - } - } - return { line: low + 1, column: position - lineStarts[low]! + 1 }; -}; - // GraphQLSP's modules share a single `ts` namespace, so checks run one at a time let queue: Promise = Promise.resolve(); @@ -310,25 +255,9 @@ async function runCheck( }); } - for (const diagnostic of fileDiagnostics || []) { - const source = diagnostic.file; - const fileName = source ? source.fileName : file; - const start = diagnostic.start || 0; - let lineStarts = lineStartsByFile.get(fileName); - if (!lineStarts) { - lineStarts = getLineStarts(source ? source.text : ''); - lineStartsByFile.set(fileName, lineStarts); - } - diagnostics.push({ - file: fileName, - start, - length: diagnostic.length || 0, - ...toLineAndColumn(lineStarts, start), - message: flattenMessage(diagnostic.messageText), - code: diagnostic.code, - severity: severities[diagnostic.category] || 'error', - }); - } + diagnostics.push( + ...toCheckDiagnostics(file, fileDiagnostics || [], lineStartsByFile) + ); } return { diff --git a/packages/graphqlsp/src/graphql/getSchema.ts b/packages/graphqlsp/src/graphql/getSchema.ts index 8356615b..5df1a333 100644 --- a/packages/graphqlsp/src/graphql/getSchema.ts +++ b/packages/graphqlsp/src/graphql/getSchema.ts @@ -177,6 +177,8 @@ export interface LoadSchemaOptions { /** Whether to write gql.tada typings (`tadaOutputLocation`) files. * @defaultValue `true` */ persist?: boolean; + /** Called after schemas were reloaded, e.g. once their files changed. */ + onChange?: () => void; } /** Falls back to the `current` schema for single-schema setups or unnamed documents. */ @@ -378,6 +380,7 @@ export const loadSchema = ( logger('Schema files changed without watcher events; reloading...'); await revalidate(true); persistAll(); + if (options.onChange) options.onChange(); })().catch(error => { logger(`Failed to check schemas for staleness: ${error}`); }); @@ -400,7 +403,9 @@ export const loadSchema = ( (inner.autoupdate as AutoupdateWithErrors)( { rootPath }, (schemaRef, value) => { - revalidate(); + revalidate().then(() => { + if (options.onChange) options.onChange(); + }); if (!value) return; const found = value.name diff --git a/packages/graphqlsp/src/session.ts b/packages/graphqlsp/src/session.ts new file mode 100644 index 00000000..526a4b4e --- /dev/null +++ b/packages/graphqlsp/src/session.ts @@ -0,0 +1,202 @@ +import path from 'node:path'; + +import { ts, init } from './ts'; +import { templates } from './ast/templates'; +import { loadSchema } from './graphql/getSchema'; +import type { SchemaRef } from './graphql/getSchema'; +import { getGraphQLDiagnostics } from './diagnostics'; +import type { GraphQLSPHost, HostProgram } from './host'; +import { loadCompiler } from './ts7/modules'; +import type { CompilerName } from './ts7/modules'; +import { createTypeScriptNamespace } from './ts7/namespace'; +import { createChecker } from './ts7/checker'; +import { readPluginConfig } from './ts7/config'; +import { createAPIRelay } from './ts7/relay'; +import type { APIRelay } from './ts7/relay'; +import { toCheckDiagnostics } from './ts7/report'; +import type { CheckDiagnostic } from './ts7/report'; + +export interface LanguageServerSessionOptions { + /** Which compiler the language server runs: `typescript` (7.1+) or `tsrs`. + * @defaultValue `'typescript'` */ + compiler?: CompilerName; + /** The `package.json` of the TypeScript installation the language server runs, + * e.g. the TypeScript 7 VS Code extension's `TypeScriptSDK.packageJsonPath`. */ + packageJsonPath: string; + /** An API pipe of the language server, e.g. from the TypeScript 7 VS Code + * extension's `TypeScriptSDK.initializeAPIConnection()`, or from its + * `custom/initializeAPISession` request. */ + pipe: string; + /** Receives GraphQLSP's log messages. */ + logger?: (message: string) => void; + /** Whether to write gql.tada typings (`tadaOutputLocation`), as the + * TypeScript 5/6 plugin does in editors. + * @defaultValue `true` */ + persist?: boolean; + /** Called after a project's schemas were reloaded, since diagnostics of + * unchanged files may then change too. */ + onSchemaChange?: () => void; +} + +export interface LanguageServerCheckResult { + /** The text of the file the diagnostics were computed for. When it doesn't + * match an editor's text, the language server hasn't caught up with the + * editor's changes yet, and the diagnostics are outdated. */ + text: string; + diagnostics: CheckDiagnostic[]; +} + +export interface LanguageServerSession { + /** 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. */ + check(fileName: string): Promise; + dispose(): void; +} + +interface ProjectState { + host: GraphQLSPHost; + schema: SchemaRef; + /** Swapped for the program of the snapshot a file is checked in. */ + program: HostProgram | undefined; +} + +/** Runs GraphQLSP's diagnostics on a TypeScript 7.1 language server's + * projects, through an API session connected to the language server. + * + * @remarks + * TypeScript 7's language server doesn't load language service plugins, so + * editor integrations can use this to report GraphQLSP's diagnostics next to + * TypeScript's. Its API is synchronous, so this blocks while checking, and + * should run in a separate process or worker, rather than e.g. in VS Code's + * extension host. + */ +export async function createLanguageServerSession( + options: LanguageServerSessionOptions +): Promise { + const logger = options.logger || (() => {}); + const compiler = await loadCompiler(options.compiler || 'typescript', { + from: options.packageJsonPath, + packagePath: options.packageJsonPath, + }); + if (compiler.kind !== 'api') { + throw new Error( + `TypeScript ${compiler.version} doesn't provide the TypeScript 7.1 API.` + ); + } + + const { API } = compiler.sync; + let relay: APIRelay | undefined; + let api: any; + try { + api = API.fromLSPConnection({ pipe: options.pipe }); + } catch (error) { + // TypeScript 7.1's sync client only spawns API servers, and doesn't + // connect to pipes yet, so it's connected to the pipe through a relay + if (!/not yet supported/i.test(String(error))) throw error; + relay = createAPIRelay(options.pipe); + try { + api = API.fromLSPConnection({ tsserverPath: relay.tsserverPath }); + } catch (error) { + relay.dispose(); + throw error; + } + } + const typescript = createTypeScriptNamespace(compiler); + const projects = new Map(); + + const getProjectState = (configFileName: string): ProjectState | null => { + let state = projects.get(configFileName); + if (state !== undefined) return state; + + const plugin = readPluginConfig( + configPath => api.readConfigFile(configPath), + configFileName + ); + if (!plugin) { + projects.set(configFileName, null); + return null; + } + + if (plugin.config.template) templates.add(plugin.config.template); + const host: GraphQLSPHost = { + config: plugin.config, + languageService: { + getProgram: () => (state ? state.program : undefined), + }, + project: { + getProjectName: () => configFileName, + projectService: { logger: { info: logger } }, + }, + }; + state = { + host, + program: undefined, + schema: loadSchema(host, logger, { + rootPath: path.dirname(plugin.configPath), + // Schemas are checked for changes on every check instead + watch: false, + persist: options.persist !== false, + onChange: options.onSchemaChange, + }), + }; + 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; + + 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(); + + 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 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; + } + } finally { + snapshot.dispose(); + } + }, + + dispose() { + projects.clear(); + try { + api.close(); + } finally { + if (relay) relay.dispose(); + } + }, + }; +} diff --git a/packages/graphqlsp/src/ts7/relay.ts b/packages/graphqlsp/src/ts7/relay.ts new file mode 100644 index 00000000..9389bb3d --- /dev/null +++ b/packages/graphqlsp/src/ts7/relay.ts @@ -0,0 +1,218 @@ +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; + +/** The relay's source, run as a CommonJS script by the sync client. + * + * @remarks + * The sync client speaks MessagePack tuples (`[type, method, payload]`) over + * the stdio of a process it spawns, while API sessions of TypeScript 7's + * language server speak JSON-RPC over a pipe. The relay forwards the sync + * client's requests to the pipe, and its responses back, decoding the + * base64 that JSON-RPC sessions use for binary responses (source files). + * + * This is kept as plain JavaScript, rather than compiled, so that it only + * depends on Node's built-in modules. + */ +const relaySource = String.raw`'use strict'; +const net = require('node:net'); + +const MSG_REQUEST = 1; +const MSG_RESPONSE = 4; +const MSG_ERROR = 5; +// Methods that respond with raw binary in the MessagePack protocol, +// see isSourceFileResponseMethod in tsc/internal/api/session.go +const BINARY_METHODS = new Set([ + 'createSourceFile', + 'createSourceFileFromFile', + 'getSourceFile', + 'getCachedSourceFile', + 'getConfigSourceFile', + 'typeToTypeNode', + 'signatureToSignatureDeclaration', +]); + +const socket = net.createConnection(process.argv[2]); +socket.on('error', error => { + process.stderr.write('[graphqlsp] API relay: ' + error.message + '\n'); + process.exit(1); +}); +socket.on('close', () => process.exit(0)); +process.stdin.on('end', () => socket.end()); + +let seq = 0; +const pending = new Map(); +let socketBuffer = Buffer.alloc(0); +socket.on('data', chunk => { + socketBuffer = Buffer.concat([socketBuffer, chunk]); + for (;;) { + const headerEnd = socketBuffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) return; + const match = /Content-Length: *(\d+)/i.exec( + socketBuffer.subarray(0, headerEnd).toString() + ); + const length = match ? Number(match[1]) : 0; + if (socketBuffer.length < headerEnd + 4 + length) return; + const message = JSON.parse( + socketBuffer.subarray(headerEnd + 4, headerEnd + 4 + length).toString() + ); + socketBuffer = socketBuffer.subarray(headerEnd + 4 + length); + if (message.method !== undefined) { + // The relay doesn't provide callbacks, e.g. for file system access + if (message.id !== undefined) { + send({ + jsonrpc: '2.0', + id: message.id, + error: { code: -32601, message: 'Unsupported callback' }, + }); + } + } else if (pending.has(message.id)) { + const method = pending.get(message.id); + pending.delete(message.id); + respond(method, message); + } + } +}); + +const send = message => { + const body = Buffer.from(JSON.stringify(message)); + socket.write('Content-Length: ' + body.length + '\r\n\r\n'); + socket.write(body); +}; + +const respond = (method, message) => { + if (message.error) { + writeTuple(MSG_ERROR, method, Buffer.from(String(message.error.message))); + } else if (BINARY_METHODS.has(method)) { + const data = message.result && message.result.data; + writeTuple( + MSG_RESPONSE, + method, + data ? Buffer.from(data, 'base64') : Buffer.alloc(0) + ); + } else { + const json = JSON.stringify(message.result); + writeTuple(MSG_RESPONSE, method, Buffer.from(json === undefined ? 'null' : json)); + } +}; + +const binHeader = length => { + const header = Buffer.alloc(5); + header[0] = 0xc6; + header.writeUInt32BE(length, 1); + return header; +}; + +const writeTuple = (type, method, payload) => { + const name = Buffer.from(method); + process.stdout.write( + Buffer.concat([ + Buffer.from([0x93, type]), + binHeader(name.length), + name, + binHeader(payload.length), + payload, + ]) + ); +}; + +let stdinBuffer = Buffer.alloc(0); +const readBin = offset => { + const marker = stdinBuffer[offset]; + let size; + let start; + if (marker === 0xc4) { + if (stdinBuffer.length < offset + 2) return null; + size = stdinBuffer[offset + 1]; + start = offset + 2; + } else if (marker === 0xc5) { + if (stdinBuffer.length < offset + 3) return null; + size = stdinBuffer.readUInt16BE(offset + 1); + start = offset + 3; + } else if (marker === 0xc6) { + if (stdinBuffer.length < offset + 5) return null; + size = stdinBuffer.readUInt32BE(offset + 1); + start = offset + 5; + } else if (marker === undefined) { + return null; + } else { + throw new Error('Expected MessagePack bin, received: 0x' + marker.toString(16)); + } + if (stdinBuffer.length < start + size) return null; + return { value: stdinBuffer.subarray(start, start + size), end: start + size }; +}; + +process.stdin.on('data', chunk => { + stdinBuffer = Buffer.concat([stdinBuffer, chunk]); + for (;;) { + if (stdinBuffer.length < 2) return; + if (stdinBuffer[0] !== 0x93) { + throw new Error('Expected MessagePack tuple, received: 0x' + stdinBuffer[0].toString(16)); + } + let offset = 1; + let type = stdinBuffer[offset++]; + if (type === 0xcc) { + if (stdinBuffer.length < 3) return; + type = stdinBuffer[offset++]; + } + const name = readBin(offset); + if (!name) return; + const payload = readBin(name.end); + if (!payload) return; + stdinBuffer = stdinBuffer.subarray(payload.end); + if (type !== MSG_REQUEST) continue; + const method = name.value.toString(); + const id = ++seq; + pending.set(id, method); + const text = payload.value.toString(); + const params = text.length ? JSON.parse(text) : null; + send( + params == null + ? { jsonrpc: '2.0', id: id, method: method } + : { jsonrpc: '2.0', id: id, method: method, params: params } + ); + } +}); +`; + +export interface APIRelay { + /** Passed as the sync client's `tsserverPath`, which it spawns. */ + tsserverPath: string; + dispose(): void; +} + +const quote = (value: string) => `'${value.replace(/'/g, `'\\''`)}'`; + +/** Lets TypeScript 7.1's sync client connect to an API pipe, e.g. of a + * language server, which it doesn't support yet. + * + * @remarks + * The sync client spawns its `tsserverPath` instead, and a shell script + * runs the relay with the current Node executable in its place. In VS Code's + * 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 } + ); + return { + tsserverPath, + dispose() { + fs.rmSync(directory, { recursive: true, force: true }); + }, + }; +}; diff --git a/packages/graphqlsp/src/ts7/report.ts b/packages/graphqlsp/src/ts7/report.ts new file mode 100644 index 00000000..286a5abd --- /dev/null +++ b/packages/graphqlsp/src/ts7/report.ts @@ -0,0 +1,94 @@ +import type { ts } from '../ts'; + +export type CheckSeverity = 'error' | 'warning' | 'suggestion' | 'message'; + +/** A GraphQLSP diagnostic. Plain data, so it can be serialized as-is. */ +export interface CheckDiagnostic { + /** Absolute path of the file the diagnostic is reported in. */ + file: string; + /** Start offset (in UTF-16 code units) into the file's text. */ + start: number; + /** Length (in UTF-16 code units) of the reported span. */ + length: number; + /** 1-based line of `start`. */ + line: number; + /** 1-based column (in UTF-16 code units) of `start`. */ + column: number; + message: string; + /** GraphQLSP's diagnostic code, e.g. `52001` for schema validation errors. */ + code: number; + severity: CheckSeverity; +} + +const severities: Record = { + 0: 'warning', + 1: 'error', + 2: 'suggestion', + 3: 'message', +}; + +const flattenMessage = ( + message: string | ts.DiagnosticMessageChain +): string => { + if (typeof message === 'string') return message; + let text = message.messageText; + for (const next of message.next || []) + text += '\n ' + flattenMessage(next).replace(/\n/g, '\n '); + return text; +}; + +const getLineStarts = (text: string) => { + const lineStarts = [0]; + for (let idx = 0; idx < text.length; idx++) { + const char = text.charCodeAt(idx); + if (char === 13 /*'\r'*/ && text.charCodeAt(idx + 1) === 10 /*'\n'*/) { + continue; + } else if (char === 10 || char === 13) { + lineStarts.push(idx + 1); + } + } + return lineStarts; +}; + +const toLineAndColumn = (lineStarts: number[], position: number) => { + let low = 0; + let high = lineStarts.length - 1; + while (low < high) { + const mid = (low + high + 1) >> 1; + if (lineStarts[mid]! <= position) { + low = mid; + } else { + high = mid - 1; + } + } + return { line: low + 1, column: position - lineStarts[low]! + 1 }; +}; + +/** Converts TypeScript diagnostics to plain `CheckDiagnostic`s. + * + * @param lineStartsByFile - caches line starts across calls, keyed by file name + */ +export const toCheckDiagnostics = ( + file: string, + diagnostics: readonly ts.Diagnostic[], + lineStartsByFile = new Map() +): CheckDiagnostic[] => + diagnostics.map(diagnostic => { + const source = diagnostic.file; + const fileName = source ? source.fileName : file; + const start = diagnostic.start || 0; + let lineStarts = lineStartsByFile.get(fileName); + if (!lineStarts) { + lineStarts = getLineStarts(source ? source.text : ''); + lineStartsByFile.set(fileName, lineStarts); + } + return { + file: fileName, + start, + length: diagnostic.length || 0, + ...toLineAndColumn(lineStarts, start), + message: flattenMessage(diagnostic.messageText), + code: diagnostic.code, + severity: severities[diagnostic.category] || 'error', + }; + }); diff --git a/packages/vscode-graphqlsp/README.md b/packages/vscode-graphqlsp/README.md index 2ec4caf4..31a90697 100644 --- a/packages/vscode-graphqlsp/README.md +++ b/packages/vscode-graphqlsp/README.md @@ -65,6 +65,18 @@ only the syntax highlighting is active. > **“TypeScript: Select TypeScript Version…”** command and ensure the workspace version is > used; the plugin is enabled for both the bundled and workspace versions. +## 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. + +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. + ## Development ```sh diff --git a/packages/vscode-graphqlsp/src/extension.ts b/packages/vscode-graphqlsp/src/extension.ts index bd89d77e..a71ced01 100644 --- a/packages/vscode-graphqlsp/src/extension.ts +++ b/packages/vscode-graphqlsp/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from 'vscode'; import { getGraphQLDocumentSymbols } from './documentSymbols'; +import { activateTypeScript7 } from './typescript7'; /** Must match the `typescriptServerPlugins` contribution in package.json. */ const pluginName = '@0no-co/graphqlsp'; @@ -87,6 +88,10 @@ export async function activate( dispose: () => documentSymbolRegistration.dispose(), }); + // TypeScript 7 doesn't load the tsserver plugin, so GraphQLSP's + // diagnostics are reported through its API instead while it runs + activateTypeScript7(context); + const tsExtension = vscode.extensions.getExtension( 'vscode.typescript-language-features' diff --git a/packages/vscode-graphqlsp/src/typescript7/index.ts b/packages/vscode-graphqlsp/src/typescript7/index.ts new file mode 100644 index 00000000..2671cc64 --- /dev/null +++ b/packages/vscode-graphqlsp/src/typescript7/index.ts @@ -0,0 +1,372 @@ +import * as vscode from 'vscode'; +import { fork, type ChildProcess } from 'node:child_process'; +import fs from 'node:fs'; +import path from 'node:path'; + +import type { CheckDiagnostic } from '@0no-co/graphqlsp/check'; +import type { ServerMessage, ServerRequest } from './server'; + +/** TypeScript 7's VS Code extensions: the release, and nightly builds. */ +const typescript7ExtensionIds = [ + 'TypeScriptTeam.native-preview', + 'TypeScriptTeam.vscode-typescript-nightly', +]; + +const languageIds = new Set([ + 'javascript', + 'javascriptreact', + 'typescript', + 'typescriptreact', +]); + +/** Changes are checked once typing pauses for this long. */ +const changeDelay = 250; +/** Retries while TypeScript hasn't received an editor's latest changes yet. */ +const staleRetryDelay = 100; +const maxStaleRetries = 20; + +// A subset of the `ExtensionAPI` and `TypeScriptSDK` types of `typescript/vscode` +interface TypeScriptSDK { + readonly version: string; + readonly packageJsonPath: string | undefined; + initializeAPIConnection(pipe?: string): Promise; +} + +interface TypeScript7ExtensionAPI { + /** Before `TypeScriptSDK` (e.g. `TypeScriptTeam.native-preview@1.0`), the + * listener is called without an argument. */ + onLanguageServerInitialized( + listener: (sdk?: TypeScriptSDK) => unknown + ): vscode.Disposable; + /** Only on the extension API before `TypeScriptSDK`. */ + initializeAPIConnection?(pipe?: string): Promise; +} + +/** The settings TypeScript 7's extension reads its TypeScript installation from, in order. */ +const tsdkSettings = [ + ['js/ts', 'tsdk.path'], + ['typescript', 'tsdk'], + ['typescript.native-preview', 'tsdk'], +] as const; + +/** Finds the `package.json` of the TypeScript 7 installation the language server likely runs. + * + * @remarks + * Older versions of TypeScript 7's extension don't tell which installation + * they run, so this resolves it from the same settings, and falls back to + * the workspace's own `typescript` package. These may not match when the + * extension ignores a workspace's setting, e.g. because it hasn't been + * allowed to use it yet. + */ +const findTypeScriptPackageJson = (): string | undefined => { + const folders = (vscode.workspace.workspaceFolders || []).map( + folder => folder.uri.fsPath + ); + const candidates: string[] = []; + for (const [section, key] of tsdkSettings) { + const value = vscode.workspace.getConfiguration(section).get(key); + if (!value) continue; + if (path.isAbsolute(value)) candidates.push(value); + else + for (const folder of folders) candidates.push(path.join(folder, value)); + } + for (const folder of folders) + candidates.push(path.join(folder, 'node_modules', 'typescript')); + + for (const candidate of candidates) { + // Settings may point at the package or at its `lib` directory + for (const directory of [candidate, path.dirname(candidate)]) { + const packageJsonPath = path.join(directory, 'package.json'); + try { + const packageJson = JSON.parse( + fs.readFileSync(packageJsonPath, 'utf8') + ); + if ( + packageJson.name === 'typescript' && + parseInt(packageJson.version, 10) >= 7 + ) { + return packageJsonPath; + } + } catch {} + } + } + return undefined; +}; + +/** Describes the language server like a `TypeScriptSDK`, for extension APIs before it. */ +const getLegacySDK = ( + api: TypeScript7ExtensionAPI +): TypeScriptSDK | undefined => { + const { initializeAPIConnection } = api; + if (typeof initializeAPIConnection !== 'function') return undefined; + const packageJsonPath = findTypeScriptPackageJson(); + let version = 'unknown'; + try { + if (packageJsonPath) + version = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')).version; + } catch {} + return { + version, + packageJsonPath, + initializeAPIConnection: pipe => initializeAPIConnection.call(api, pipe), + }; +}; + +const severities: Record< + CheckDiagnostic['severity'], + vscode.DiagnosticSeverity +> = { + error: vscode.DiagnosticSeverity.Error, + warning: vscode.DiagnosticSeverity.Warning, + suggestion: vscode.DiagnosticSeverity.Hint, + message: vscode.DiagnosticSeverity.Information, +}; + +/** Reports GraphQLSP's diagnostics while TypeScript 7's language server runs. + * + * @remarks + * TypeScript 7 doesn't load TypeScript server plugins, so the plugin this + * extension contributes doesn't run with it. Instead, once TypeScript 7's + * extension initializes its language server, this connects to an API session + * of it, which shares the language server's projects and the editors' + * unsaved changes, and publishes GraphQLSP's diagnostics for open files. + * + * TODO: Hover information and completions, e.g. through TypeScript 7's + * `registerLspMiddleware` for `textDocument/hover` and `textDocument/completion`. + */ +export function activateTypeScript7(context: vscode.ExtensionContext): void { + let output: vscode.OutputChannel | undefined; + const log = (message: string) => { + output ??= vscode.window.createOutputChannel('GraphQLSP'); + output.appendLine(message); + }; + + for (const id of typescript7ExtensionIds) { + const extension = + vscode.extensions.getExtension(id); + if (!extension) continue; + + let diagnostics: TypeScript7Diagnostics | undefined; + context.subscriptions.push({ dispose: () => diagnostics?.dispose() }); + + extension.activate().then( + api => { + if (typeof api?.onLanguageServerInitialized !== 'function') { + log(`${id} doesn't provide TypeScript 7's extension API.`); + return; + } + context.subscriptions.push( + // Fires after every (re)start of the language server, after which + // API sessions have to be recreated + api.onLanguageServerInitialized(languageServer => { + diagnostics?.dispose(); + diagnostics = undefined; + const sdk = languageServer || getLegacySDK(api); + if (!sdk) { + log(`${id} doesn't provide TypeScript 7's API connections.`); + return; + } else if (!sdk.packageJsonPath) { + log( + `TypeScript ${sdk.version} has no API package, so GraphQLSP's diagnostics are unavailable.` + ); + return; + } + diagnostics = new TypeScript7Diagnostics( + context, + sdk as TypeScriptSDK & { packageJsonPath: string }, + log + ); + }) + ); + }, + error => log(`Failed to activate ${id}: ${error}`) + ); + } +} + +class TypeScript7Diagnostics { + #collection = vscode.languages.createDiagnosticCollection('graphqlsp'); + #disposables: vscode.Disposable[] = [this.#collection]; + #server: Promise; + #timers = new Map(); + #pending = new Map< + number, + (message: ServerMessage & { type: 'result' }) => void + >(); + #seq = 0; + #disposed = false; + + constructor( + context: vscode.ExtensionContext, + sdk: TypeScriptSDK & { packageJsonPath: string }, + private log: (message: string) => void + ) { + this.#server = sdk.initializeAPIConnection().then( + pipe => this.#startServer(context, sdk.packageJsonPath, pipe), + error => { + log( + `Failed to open an API session of TypeScript ${sdk.version}: ${error}` + ); + return undefined; + } + ); + + this.#disposables.push( + vscode.workspace.onDidOpenTextDocument(document => + this.#schedule(document, 0) + ), + vscode.workspace.onDidChangeTextDocument(event => + this.#schedule(event.document, changeDelay) + ), + vscode.workspace.onDidSaveTextDocument(document => + this.#schedule(document, 0) + ), + vscode.workspace.onDidCloseTextDocument(document => { + this.#cancel(document); + this.#collection.delete(document.uri); + }) + ); + this.#checkAll(); + } + + #startServer( + context: vscode.ExtensionContext, + packageJsonPath: string, + pipe: string + ): ChildProcess | undefined { + if (this.#disposed) return undefined; + const server = fork( + context.asAbsolutePath(path.join('dist', 'typescript7', 'server.js')), + [], + { + cwd: path.dirname(packageJsonPath), + env: { + ...process.env, + ELECTRON_RUN_AS_NODE: '1', + // Resolves `typescript` from the language server's installation + NODE_PATH: [ + path.dirname(path.dirname(packageJsonPath)), + process.env.NODE_PATH, + ] + .filter(Boolean) + .join(path.delimiter), + GRAPHQLSP_TS7_PACKAGE_JSON: packageJsonPath, + GRAPHQLSP_TS7_PIPE: pipe, + }, + execArgv: [], + stdio: ['ignore', 'pipe', 'pipe', 'ipc'], + } + ); + server.stdout!.on('data', chunk => this.log(String(chunk).trimEnd())); + server.stderr!.on('data', chunk => this.log(String(chunk).trimEnd())); + server.on('message', (message: ServerMessage) => { + if (message.type === 'log') { + this.log(message.message); + } else if (message.type === 'schemaChange') { + this.#checkAll(); + } else if (message.type === 'result') { + this.#pending.get(message.id)?.(message); + this.#pending.delete(message.id); + } + }); + server.on('exit', code => { + for (const resolve of this.#pending.values()) + resolve({ type: 'result', id: -1, error: `exited with ${code}` }); + this.#pending.clear(); + if (!this.#disposed) { + this.log(`GraphQLSP's TypeScript 7 server exited (${code}).`); + this.#collection.clear(); + } + }); + return server; + } + + #checkAll() { + for (const document of vscode.workspace.textDocuments) + this.#schedule(document, 0); + } + + #cancel(document: vscode.TextDocument) { + const key = document.uri.toString(); + clearTimeout(this.#timers.get(key)); + this.#timers.delete(key); + } + + #schedule(document: vscode.TextDocument, delay: number, retries = 0) { + if ( + this.#disposed || + document.uri.scheme !== 'file' || + !languageIds.has(document.languageId) + ) { + return; + } + this.#cancel(document); + const key = document.uri.toString(); + this.#timers.set( + key, + setTimeout(() => { + this.#timers.delete(key); + this.#check(document, retries); + }, delay) + ); + } + + async #check(document: vscode.TextDocument, retries: number) { + const server = await this.#server; + if (!server || !server.connected || this.#disposed) return; + + const version = document.version; + const id = ++this.#seq; + const message = await new Promise( + resolve => { + this.#pending.set(id, resolve); + server.send({ + type: 'check', + id, + fileName: document.uri.fsPath, + } satisfies ServerRequest); + } + ); + + // Newer changes have been scheduled to be checked already + if (this.#disposed || document.isClosed || document.version !== version) { + return; + } else if (message.error) { + this.log(`Failed to check ${document.uri.fsPath}: ${message.error}`); + return; + } else if (!message.result) { + this.#collection.delete(document.uri); + return; + } else if (message.result.text !== document.getText()) { + // TypeScript hasn't received the editor's latest changes yet + if (retries < maxStaleRetries) + this.#schedule(document, staleRetryDelay, retries + 1); + return; + } + + 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; + }) + ); + } + + dispose() { + this.#disposed = true; + 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()); + } +} diff --git a/packages/vscode-graphqlsp/src/typescript7/server.ts b/packages/vscode-graphqlsp/src/typescript7/server.ts new file mode 100644 index 00000000..df897dbe --- /dev/null +++ b/packages/vscode-graphqlsp/src/typescript7/server.ts @@ -0,0 +1,64 @@ +/* Runs GraphQLSP's diagnostics on TypeScript 7's language server, through an + * API session of it. + * + * TypeScript 7's API client is synchronous, so this runs in its own process + * rather than in VS Code's extension host, and receives files to check from + * the extension over Node's IPC channel. */ + +import type { + LanguageServerCheckResult, + LanguageServerSession, +} from '@0no-co/graphqlsp/check'; + +export type ServerRequest = { type: 'check'; id: number; fileName: string }; + +export type ServerMessage = + | { + type: 'result'; + id: number; + result?: LanguageServerCheckResult | null; + error?: string; + } + | { type: 'schemaChange' } + | { type: 'log'; message: string }; + +const send = (message: ServerMessage) => process.send!(message); +const log = (message: string) => send({ type: 'log', message }); + +const packageJsonPath = process.env.GRAPHQLSP_TS7_PACKAGE_JSON!; +const pipe = process.env.GRAPHQLSP_TS7_PIPE!; + +// GraphQLSP's dependencies require `typescript`, which is resolved from +// TypeScript 7's installation through NODE_PATH. That only applies to +// CommonJS, so GraphQLSP's CommonJS build is required here +const session: Promise = new Promise(resolve => { + const { createLanguageServerSession } = + require('@0no-co/graphqlsp/check') as typeof import('@0no-co/graphqlsp/check'); + resolve( + createLanguageServerSession({ + packageJsonPath, + pipe, + logger: log, + onSchemaChange: () => send({ type: 'schemaChange' }), + }) + ); +}); + +session.then( + () => log(`Connected to TypeScript's API session (${packageJsonPath})`), + 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) }); + } +}); + +process.on('disconnect', () => { + session.then(session => session.dispose()).finally(() => process.exit(0)); +}); diff --git a/packages/vscode-graphqlsp/tsconfig.json b/packages/vscode-graphqlsp/tsconfig.json index dfa66e39..1000ebc0 100644 --- a/packages/vscode-graphqlsp/tsconfig.json +++ b/packages/vscode-graphqlsp/tsconfig.json @@ -3,6 +3,7 @@ "compilerOptions": { "target": "ES2020", "lib": ["ES2020"], + "types": ["node"], "module": "Node16", "moduleResolution": "Node16", "rootDir": "src", diff --git a/test/e2e/ts7-session.test.ts b/test/e2e/ts7-session.test.ts new file mode 100644 index 00000000..6d03a3fd --- /dev/null +++ b/test/e2e/ts7-session.test.ts @@ -0,0 +1,258 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import type { TestContext } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { createRequire } from 'node:module'; +import fs from 'node:fs'; +import path from 'node:path'; +import url from 'node:url'; + +import { + createLanguageServerSession, + type LanguageServerSession, +} from '../../packages/graphqlsp/src/check'; + +const __dirname = path.dirname(url.fileURLToPath(import.meta.url)); + +const projectPath = path.resolve(__dirname, 'fixture-project-ts7'); +const require = createRequire(path.join(projectPath, 'tsconfig.json')); + +/** A minimal LSP client speaking to a TypeScript 7 language server over stdio. */ +class LanguageServer { + #process: ChildProcess; + #buffer = Buffer.alloc(0); + #seq = 0; + #pending = new Map void>(); + + constructor(command: string, args: string[]) { + this.#process = spawn(command, args, { + cwd: projectPath, + stdio: ['pipe', 'pipe', 'inherit'], + }); + this.#process.stdout!.on('data', (chunk: Buffer) => { + this.#buffer = Buffer.concat([this.#buffer, chunk]); + for (;;) { + const headerEnd = this.#buffer.indexOf('\r\n\r\n'); + if (headerEnd < 0) break; + const header = this.#buffer.subarray(0, headerEnd).toString(); + const length = Number(/Content-Length: (\d+)/i.exec(header)![1]); + if (this.#buffer.length < headerEnd + 4 + length) break; + const body = this.#buffer.subarray( + headerEnd + 4, + headerEnd + 4 + length + ); + this.#buffer = this.#buffer.subarray(headerEnd + 4 + length); + this.#receive(JSON.parse(body.toString())); + } + }); + } + + #receive(message: any) { + if (message.method && message.id != null) { + // Requests from the server, e.g. `workspace/configuration` + this.#write({ + jsonrpc: '2.0', + id: message.id, + result: message.method === 'workspace/configuration' ? [] : null, + }); + } else if (message.id != null && this.#pending.has(message.id)) { + this.#pending.get(message.id)!(message); + this.#pending.delete(message.id); + } + } + + #write(message: object) { + const body = Buffer.from(JSON.stringify(message)); + this.#process.stdin!.write(`Content-Length: ${body.length}\r\n\r\n`); + this.#process.stdin!.write(body); + } + + request(method: string, params?: unknown): Promise { + const id = ++this.#seq; + return new Promise((resolve, reject) => { + this.#pending.set(id, message => + message.error + ? reject(new Error(message.error.message)) + : resolve(message.result) + ); + this.#write( + params === undefined + ? { jsonrpc: '2.0', id, method } + : { jsonrpc: '2.0', id, method, params } + ); + }); + } + + notify(method: string, params?: unknown) { + this.#write( + params === undefined + ? { jsonrpc: '2.0', method } + : { jsonrpc: '2.0', method, params } + ); + } + + async initialize() { + await this.request('initialize', { + processId: process.pid, + rootUri: url.pathToFileURL(projectPath).href, + workspaceFolders: [ + { uri: url.pathToFileURL(projectPath).href, name: 'fixture' }, + ], + capabilities: {}, + }); + this.notify('initialized', {}); + } + + open(fileName: string, text: string) { + this.notify('textDocument/didOpen', { + textDocument: { + uri: url.pathToFileURL(fileName).href, + languageId: 'typescript', + version: 1, + text, + }, + }); + } + + change(fileName: string, version: number, text: string) { + this.notify('textDocument/didChange', { + textDocument: { uri: url.pathToFileURL(fileName).href, version }, + contentChanges: [{ text }], + }); + } + + async dispose() { + try { + await Promise.race([ + this.request('shutdown'), + new Promise(resolve => setTimeout(resolve, 2_000)), + ]); + this.notify('exit'); + } finally { + this.#process.kill(); + } + } +} + +const servers = [ + { + label: 'TypeScript 7', + name: 'typescript' as const, + packageJsonPath: require.resolve('typescript-7/package.json'), + args: ['--lsp', '--stdio'], + }, + { + label: 'tsrs', + name: 'tsrs' as const, + packageJsonPath: require.resolve('tsrs/package.json'), + args: ['--lsp', '-stdio'], + }, +].filter( + server => + !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; + // Set when the language server doesn't provide API sessions + let unsupported: string | undefined; + const requireSession = (context: TestContext) => { + if (unsupported) context.skip(unsupported); + return session!; + }; + + beforeAll(async () => { + const packageJson = JSON.parse( + fs.readFileSync(server.packageJsonPath, 'utf8') + ); + const bin = path.resolve( + path.dirname(server.packageJsonPath), + Object.values(packageJson.bin)[0]! + ); + languageServer = new LanguageServer(process.execPath, [ + bin, + ...server.args, + ]); + await languageServer.initialize(); + + // The editor's unsaved text introduces a schema error into the otherwise valid file + languageServer.open( + fileName, + fs + .readFileSync(fileName, 'utf8') + .replace('...pokemonFields', '...pokemonFields\n nmae') + ); + + let pipe: string; + try { + ({ pipe } = await languageServer.request( + 'custom/initializeAPISession', + {} + )); + } catch (error) { + // tsrs doesn't implement API sessions yet + if (!/not ported|MethodNotFound|not found/i.test(String(error))) + throw error; + unsupported = `${server.label}: ${error}`; + return; + } + session = await createLanguageServerSession({ + compiler: server.name, + packageJsonPath: server.packageJsonPath, + pipe, + persist: false, + }); + }, 60_000); + + afterAll(async () => { + session?.dispose(); + await languageServer?.dispose(); + }); + + it("reports diagnostics for the editor's unsaved text", async context => { + const result = await requireSession(context).check(fileName); + expect(result).not.toBeNull(); + expect(result!.text).toContain('nmae'); + expect( + result!.diagnostics.map(({ file, ...diagnostic }) => ({ + file: path.relative(projectPath, file), + ...diagnostic, + })) + ).toMatchInlineSnapshot(` + [ + { + "code": 52001, + "column": 9, + "file": "src/valid.ts", + "length": 5, + "line": 11, + "message": "Cannot query field "nmae" on type "Pokemon". Did you mean "name"?", + "severity": "error", + "start": 332, + }, + ] + `); + }, 60_000); + + it("follows the editor's changes", async context => { + const session = requireSession(context); + languageServer.change(fileName, 2, fs.readFileSync(fileName, 'utf8')); + // The language server applies changes asynchronously + let result = await session.check(fileName); + for (let i = 0; i < 50 && result!.text.includes('nmae'); i++) { + await new Promise(resolve => setTimeout(resolve, 100)); + result = await session.check(fileName); + } + expect(result!.text).not.toContain('nmae'); + expect(result!.diagnostics).toEqual([]); + }, 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); +});