From 86925a49b2642ec63453078b9222e37f49312d50 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Sat, 24 Oct 2026 10:59:43 +0200 Subject: [PATCH 1/2] feat(vscode): report diagnostics through TypeScript 7's API sessions Adds createLanguageServerSession to @0no-co/graphqlsp/check, which runs GraphQLSP's diagnostics on a TypeScript 7.1 language server's snapshot (including unsaved editor changes) through an API session pipe. Since the 7.1 sync client can't connect to pipes yet, it's bridged by a small relay that translates the sync client's MessagePack stdio protocol to the session's JSON-RPC. The VS Code extension uses it when TypeScript 7's extension initializes its language server, publishing diagnostics through a DiagnosticCollection from a forked process. The TypeScript 5/6 plugin path is unchanged. --- .../language-server-session-typescript-7.md | 5 + .github/workflows/ci.yaml | 2 +- README.md | 19 ++ packages/graphqlsp/src/check.ts | 95 +----- packages/graphqlsp/src/graphql/getSchema.ts | 7 +- packages/graphqlsp/src/session.ts | 202 ++++++++++++ packages/graphqlsp/src/ts7/relay.ts | 218 +++++++++++++ packages/graphqlsp/src/ts7/report.ts | 94 ++++++ packages/vscode-graphqlsp/README.md | 12 + packages/vscode-graphqlsp/src/extension.ts | 5 + .../vscode-graphqlsp/src/typescript7/index.ts | 293 ++++++++++++++++++ .../src/typescript7/server.ts | 64 ++++ packages/vscode-graphqlsp/tsconfig.json | 1 + test/e2e/ts7-session.test.ts | 258 +++++++++++++++ 14 files changed, 1190 insertions(+), 85 deletions(-) create mode 100644 .changeset/language-server-session-typescript-7.md create mode 100644 packages/graphqlsp/src/session.ts create mode 100644 packages/graphqlsp/src/ts7/relay.ts create mode 100644 packages/graphqlsp/src/ts7/report.ts create mode 100644 packages/vscode-graphqlsp/src/typescript7/index.ts create mode 100644 packages/vscode-graphqlsp/src/typescript7/server.ts create mode 100644 test/e2e/ts7-session.test.ts 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..ceefe579 --- /dev/null +++ b/packages/vscode-graphqlsp/src/typescript7/index.ts @@ -0,0 +1,293 @@ +import * as vscode from 'vscode'; +import { fork, type ChildProcess } from 'node:child_process'; +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 { + onLanguageServerInitialized( + listener: (sdk: TypeScriptSDK) => unknown + ): vscode.Disposable; +} + +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(sdk => { + diagnostics?.dispose(); + diagnostics = undefined; + 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); +}); From b38bf771ce87ea2826caab8ebe8d4454c3357957 Mon Sep 17 00:00:00 2001 From: Jovi De Croock Date: Sat, 24 Oct 2026 12:00:52 +0200 Subject: [PATCH 2/2] fix(vscode): support TypeScript 7 extensions without TypeScriptSDK TypeScriptTeam.native-preview 1.0 calls onLanguageServerInitialized's listener without an argument and exposes initializeAPIConnection on its extension API instead. The TypeScript installation is then resolved from the same tsdk settings, falling back to the workspace's typescript@>=7. --- .../vscode-graphqlsp/src/typescript7/index.ts | 85 ++++++++++++++++++- 1 file changed, 82 insertions(+), 3 deletions(-) diff --git a/packages/vscode-graphqlsp/src/typescript7/index.ts b/packages/vscode-graphqlsp/src/typescript7/index.ts index ceefe579..2671cc64 100644 --- a/packages/vscode-graphqlsp/src/typescript7/index.ts +++ b/packages/vscode-graphqlsp/src/typescript7/index.ts @@ -1,5 +1,6 @@ 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'; @@ -32,11 +33,85 @@ interface TypeScriptSDK { } interface TypeScript7ExtensionAPI { + /** Before `TypeScriptSDK` (e.g. `TypeScriptTeam.native-preview@1.0`), the + * listener is called without an argument. */ onLanguageServerInitialized( - listener: (sdk: TypeScriptSDK) => unknown + 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 @@ -83,10 +158,14 @@ export function activateTypeScript7(context: vscode.ExtensionContext): void { context.subscriptions.push( // Fires after every (re)start of the language server, after which // API sessions have to be recreated - api.onLanguageServerInitialized(sdk => { + api.onLanguageServerInitialized(languageServer => { diagnostics?.dispose(); diagnostics = undefined; - if (!sdk.packageJsonPath) { + 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.` );