From f784f0dd92ca83334292c5ea2f444d2c25e4c4db Mon Sep 17 00:00:00 2001 From: Oleksandra Kordonets Date: Thu, 20 Nov 2025 19:10:14 -0500 Subject: [PATCH 1/9] Add tsserver watch-events protocol and client support --- src/ts-client.ts | 10 ++++++++++ src/ts-protocol.ts | 10 +++++++++- src/tsServer/spawner.ts | 4 ++++ src/typescriptService.ts | 1 + src/utils/configuration.ts | 1 + 5 files changed, 25 insertions(+), 1 deletion(-) diff --git a/src/ts-client.ts b/src/ts-client.ts index c3d06d8b..55b0ba30 100644 --- a/src/ts-client.ts +++ b/src/ts-client.ts @@ -155,6 +155,7 @@ export interface TsClientOptions { onEvent?: (event: ts.server.protocol.Event) => void; onExit?: (exitCode: number | null, signal: NodeJS.Signals | null) => void; useSyntaxServer: SyntaxServerConfiguration; + canUseWatchEvents?: boolean; } export class TsClient implements ITypeScriptServiceClient { @@ -173,6 +174,7 @@ export class TsClient implements ITypeScriptServiceClient { private useSyntaxServer: SyntaxServerConfiguration = SyntaxServerConfiguration.Auto; private onEvent?: (event: ts.server.protocol.Event) => void; private onExit?: (exitCode: number | null, signal: NodeJS.Signals | null) => void; + private canUseWatchEvents: boolean = false; constructor( onCaseInsensitiveFileSystem: boolean, @@ -346,6 +348,13 @@ export class TsClient implements ITypeScriptServiceClient { } } + public sendWatchChanges(args: ts.server.protocol.WatchChangeRequestArgs | readonly ts.server.protocol.WatchChangeRequestArgs[]): void { + if (!this.canUseWatchEvents) { + return; + } + this.executeWithoutWaitingForResponse(CommandTypes.WatchChange, args); + } + start( workspaceRoot: string | undefined, options: TsClientOptions, @@ -356,6 +365,7 @@ export class TsClient implements ITypeScriptServiceClient { this.tracer = new Tracer(this.tsserverLogger, options.trace); this.workspaceFolders = workspaceRoot ? [{ uri: URI.file(workspaceRoot) }] : []; this.useSyntaxServer = options.useSyntaxServer; + this.canUseWatchEvents = Boolean(options.canUseWatchEvents && this.apiVersion.gte(API.v540)); this.onEvent = options.onEvent; this.onExit = options.onExit; this.pluginManager.setPlugins(options.plugins); diff --git a/src/ts-protocol.ts b/src/ts-protocol.ts index a7f382a8..2bf3821b 100644 --- a/src/ts-protocol.ts +++ b/src/ts-protocol.ts @@ -96,7 +96,8 @@ export enum CommandTypes { PrepareCallHierarchy = 'prepareCallHierarchy', ProvideCallHierarchyIncomingCalls = 'provideCallHierarchyIncomingCalls', ProvideCallHierarchyOutgoingCalls = 'provideCallHierarchyOutgoingCalls', - ProvideInlayHints = 'provideInlayHints' + ProvideInlayHints = 'provideInlayHints', + WatchChange = 'watchChange', } export enum HighlightSpanKind { @@ -288,6 +289,9 @@ export const enum EventName { surveyReady = 'surveyReady', projectLoadingStart = 'projectLoadingStart', projectLoadingFinish = 'projectLoadingFinish', + createFileWatcher = 'createFileWatcher', + createDirectoryWatcher = 'createDirectoryWatcher', + closeFileWatcher = 'closeFileWatcher', } export class KindModifiers { @@ -421,6 +425,10 @@ interface TsserverOptions { * @default 'auto' */ useSyntaxServer?: 'auto' | 'never'; + /** + * Whether to opt in to tsserver's watch events mode (using the `--canUseWatchEvents` flag) when supported by the client and TypeScript version. + */ + canUseWatchEvents?: boolean; } export type TypeScriptInitializeParams = lsp.InitializeParams & { diff --git a/src/tsServer/spawner.ts b/src/tsServer/spawner.ts index e634c92d..0ca8090a 100644 --- a/src/tsServer/spawner.ts +++ b/src/tsServer/spawner.ts @@ -221,6 +221,10 @@ export class TypeScriptServerSpawner { args.push('--npmLocation', `"${npmLocation}"`); } + if (configuration.canUseWatchEvents && apiVersion.gte(API.v540)) { + args.push('--canUseWatchEvents'); + } + args.push('--locale', locale || 'en'); // args.push('--noGetErrOnBackgroundUpdate'); args.push('--validateDefaultNpmLocation'); diff --git a/src/typescriptService.ts b/src/typescriptService.ts index 88151c06..1d6d771c 100644 --- a/src/typescriptService.ts +++ b/src/typescriptService.ts @@ -201,6 +201,7 @@ export interface NoResponseTsServerRequests { [CommandTypes.Configure]: [ts.server.protocol.ConfigureRequestArguments, ts.server.protocol.ConfigureResponse]; [CommandTypes.ConfigurePlugin]: [ts.server.protocol.ConfigurePluginRequestArguments, ts.server.protocol.ConfigurePluginResponse]; [CommandTypes.Open]: [ts.server.protocol.OpenRequestArgs, null]; + [CommandTypes.WatchChange]: [ts.server.protocol.WatchChangeRequestArgs | readonly ts.server.protocol.WatchChangeRequestArgs[], null]; } export interface AsyncTsServerRequests { diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts index ede34656..1b5a13ab 100644 --- a/src/utils/configuration.ts +++ b/src/utils/configuration.ts @@ -51,6 +51,7 @@ export namespace TsServerLogLevel { export interface LspServerConfiguration { readonly logger: Logger; readonly lspClient: LspClient; + readonly canUseWatchEvents?: boolean; } export const enum SyntaxServerConfiguration { From fe6106be4b8455f4239994930ead50c4cba79f5f Mon Sep 17 00:00:00 2001 From: Oleksandra Kordonets Date: Thu, 20 Nov 2025 19:13:55 -0500 Subject: [PATCH 2/9] Handle tsserver watch events via dynamic LSP file watchers --- src/lsp-client.ts | 5 + src/lsp-connection.ts | 3 + src/lsp-server.ts | 47 ++++++ src/watchEventManager.ts | 338 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 393 insertions(+) create mode 100644 src/watchEventManager.ts diff --git a/src/lsp-client.ts b/src/lsp-client.ts index d3a5f184..7ea4db13 100644 --- a/src/lsp-client.ts +++ b/src/lsp-client.ts @@ -25,6 +25,7 @@ export interface LspClient { rename(args: lsp.TextDocumentPositionParams): Promise; sendNotification

(type: lsp.NotificationType

, params: P): Promise; getWorkspaceConfiguration(scopeUri: string, section: string): Promise; + registerFileWatcher(watchers: lsp.FileSystemWatcher[]): Promise; } // Hack around the LSP library that makes it otherwise impossible to differentiate between Null and Client-initiated reporter. @@ -82,4 +83,8 @@ export class LspClientImpl implements LspClient { async sendNotification

(type: lsp.NotificationType

, params: P): Promise { await this.connection.sendNotification(type, params); } + + async registerFileWatcher(watchers: lsp.FileSystemWatcher[]): Promise { + return await this.connection.client.register(lsp.DidChangeWatchedFilesNotification.type, { watchers }); + } } diff --git a/src/lsp-connection.ts b/src/lsp-connection.ts index a1ce12a7..f5b466f6 100644 --- a/src/lsp-connection.ts +++ b/src/lsp-connection.ts @@ -12,6 +12,7 @@ import { LspClientImpl } from './lsp-client.js'; export interface LspConnectionOptions { showMessageLevel: lsp.MessageType; + canUseWatchEvents?: boolean; } export function createLspConnection(options: LspConnectionOptions): lsp.Connection { @@ -21,6 +22,7 @@ export function createLspConnection(options: LspConnectionOptions): lsp.Connecti const server: LspServer = new LspServer({ logger, lspClient, + canUseWatchEvents: options.canUseWatchEvents, }); connection.onInitialize(server.initialize.bind(server)); @@ -31,6 +33,7 @@ export function createLspConnection(options: LspConnectionOptions): lsp.Connecti connection.onDidSaveTextDocument(server.didSaveTextDocument.bind(server)); connection.onDidCloseTextDocument(server.didCloseTextDocument.bind(server)); connection.onDidChangeTextDocument(server.didChangeTextDocument.bind(server)); + connection.onDidChangeWatchedFiles(server.didChangeWatchedFiles.bind(server)); connection.onCodeAction(server.codeAction.bind(server)); connection.onCodeActionResolve(server.codeActionResolve.bind(server)); diff --git a/src/lsp-server.ts b/src/lsp-server.ts index 3176611f..0e3d6ec4 100644 --- a/src/lsp-server.ts +++ b/src/lsp-server.ts @@ -45,6 +45,7 @@ import { Position, Range } from './utils/typeConverters.js'; import { CodeActionKind } from './utils/types.js'; import { CommandManager } from './commands/commandManager.js'; import { CodeActionManager } from './features/codeActions/codeActionManager.js'; +import { WatchEventManager } from './watchEventManager.js'; export class LspServer { private tsClient: TsClient; @@ -61,6 +62,7 @@ export class LspServer { private cachedNavTreeResponse = new CachedResponse(); private implementationsCodeLensProvider: TypeScriptImplementationsCodeLensProvider | null = null; private referencesCodeLensProvider: TypeScriptReferencesCodeLensProvider | null = null; + private watchEventManager: WatchEventManager | null = null; constructor(private options: LspServerConfiguration) { this.logger = new PrefixingLogger(options.logger, '[lspserver]'); @@ -91,6 +93,8 @@ export class LspServer { } shutdown(): void { + this.watchEventManager?.dispose(); + this.watchEventManager = null; this.tsClient.shutdown(); } @@ -148,6 +152,20 @@ export class LspServer { useLabelDetailsInCompletionEntries: this.features.completionLabelDetails, }); + const supportsFileWatcherRegistration = Boolean(workspace?.didChangeWatchedFiles?.dynamicRegistration); + const supportsRelativePatterns = workspace?.didChangeWatchedFiles?.relativePatternSupport !== false; + const requestedWatchEvents = userInitializationOptions.tsserver?.canUseWatchEvents ?? this.options.canUseWatchEvents ?? false; + const typescriptSupportsWatchEvents = typescriptVersion.version?.gte(API.v540); + const canUseWatchEvents = Boolean(requestedWatchEvents && supportsFileWatcherRegistration && supportsRelativePatterns && typescriptSupportsWatchEvents); + + if (requestedWatchEvents && !supportsFileWatcherRegistration) { + this.logger.logIgnoringVerbosity(LogLevel.Warning, 'Client does not support dynamic file watcher registration; tsserver watch events will stay disabled.'); + } else if (requestedWatchEvents && !supportsRelativePatterns) { + this.logger.logIgnoringVerbosity(LogLevel.Warning, 'Client does not support relative file watcher patterns; tsserver watch events will stay disabled.'); + } else if (requestedWatchEvents && !typescriptSupportsWatchEvents) { + this.logger.logIgnoringVerbosity(LogLevel.Warning, 'tsserver watch events require TypeScript 5.4 or newer; disabling canUseWatchEvents.'); + } + const tsserverLogVerbosity = tsserver?.logVerbosity && TsServerLogLevel.fromString(tsserver.logVerbosity); const started = this.tsClient.start( this.workspaceRoot, @@ -170,6 +188,7 @@ export class LspServer { } }, useSyntaxServer: toSyntaxServerConfiguration(userInitializationOptions.tsserver?.useSyntaxServer), + canUseWatchEvents, }); if (!started) { throw new Error('tsserver process has failed to start.'); @@ -181,6 +200,16 @@ export class LspServer { process.exit(); }); + if (canUseWatchEvents) { + this.watchEventManager = new WatchEventManager({ + lspClient: this.options.lspClient, + logger: this.logger, + workspaceFolders: this.getWorkspaceFolders(), + sendWatchChanges: changes => this.tsClient.sendWatchChanges(changes), + caseInsensitive: onCaseInsensitiveFileSystem(), + }); + } + this.fileConfigurationManager.setGlobalConfiguration(this.workspaceRoot, hostInfo); this.registerHandlers(); @@ -319,6 +348,7 @@ export class LspServer { version: apiVersion.displayName, source: typescriptVersionSource, }); + this.watchEventManager?.onInitialized(); } private findTypescriptVersion(userTsserverPath: string | undefined, fallbackTsserverPath: string | undefined): TypeScriptVersion | null { @@ -366,6 +396,16 @@ export class LspServer { return undefined; } + private getWorkspaceFolders(): URI[] { + if (this.initializeParams?.workspaceFolders?.length) { + return this.initializeParams.workspaceFolders.map(folder => URI.parse(folder.uri)); + } + if (this.workspaceRoot) { + return [URI.file(this.workspaceRoot)]; + } + return []; + } + didChangeConfiguration(params: lsp.DidChangeConfigurationParams): void { this.fileConfigurationManager.setWorkspaceConfiguration((params.settings || {}) as WorkspaceConfiguration); const ignoredDiagnosticCodes = this.fileConfigurationManager.workspaceConfiguration.diagnostics?.ignoredCodes || []; @@ -412,6 +452,10 @@ export class LspServer { // do nothing } + didChangeWatchedFiles(params: lsp.DidChangeWatchedFilesParams): void { + this.watchEventManager?.handleFileChanges(params); + } + async definition(params: lsp.DefinitionParams, token?: lsp.CancellationToken): Promise { return this.getDefinition({ type: this.features.definitionLinkSupport ? CommandTypes.DefinitionAndBoundSpan : CommandTypes.Definition, @@ -1175,6 +1219,9 @@ export class LspServer { } protected onTsEvent(event: ts.server.protocol.Event): void { + if (this.watchEventManager?.handleTsserverEvent(event)) { + return; + } const eventName = event.event as EventName; if (eventName === EventName.semanticDiag || eventName === EventName.syntaxDiag || eventName === EventName.suggestionDiag) { const diagnosticEvent = event as ts.server.protocol.DiagnosticEvent; diff --git a/src/watchEventManager.ts b/src/watchEventManager.ts new file mode 100644 index 00000000..6c1b6558 --- /dev/null +++ b/src/watchEventManager.ts @@ -0,0 +1,338 @@ +import path from 'node:path'; +import * as lsp from 'vscode-languageserver'; +import { URI } from 'vscode-uri'; +import type { ts } from './ts-protocol.js'; +import type { LspClient } from './lsp-client.js'; +import type { Logger } from './utils/logger.js'; + +type TsserverWatcher = { + readonly id: number; + readonly path: string; + readonly normalizedPath: string; + readonly kind: 'file' | 'directory'; + readonly recursive: boolean; + readonly ignoreUpdate: boolean; + coverageKey?: string; +}; + +type CoverageEntry = { + readonly key: string; + readonly normalizedBase: string; + readonly watcher: lsp.FileSystemWatcher; + watchKind: lsp.WatchKind; + readonly permanent: boolean; +}; + +export interface WatchEventManagerOptions { + readonly lspClient: LspClient; + readonly logger: Logger; + readonly workspaceFolders: readonly URI[]; + readonly sendWatchChanges: (args: ts.server.protocol.WatchChangeRequestArgs | readonly ts.server.protocol.WatchChangeRequestArgs[]) => void; + readonly caseInsensitive: boolean; +} + +export class WatchEventManager { + private readonly watchers = new Map(); + private readonly coverage = new Map(); + private readonly coverageUsage = new Map(); + private readonly workspacePaths: readonly string[]; + private registration: lsp.Disposable | undefined; + private registrationSignature: string | undefined; + private readyForRegistration = false; + + constructor(private readonly options: WatchEventManagerOptions) { + this.workspacePaths = options.workspaceFolders.map(folder => this.normalizePath(folder.fsPath)); + for (const folder of options.workspaceFolders) { + const key = this.coverageKey(folder.fsPath, '**/*'); + const entry: CoverageEntry = { + key, + normalizedBase: this.normalizePath(folder.fsPath), + watcher: { + globPattern: { + baseUri: folder.toString(), + pattern: '**/*', + }, + kind: lsp.WatchKind.Create | lsp.WatchKind.Change | lsp.WatchKind.Delete, + }, + watchKind: lsp.WatchKind.Create | lsp.WatchKind.Change | lsp.WatchKind.Delete, + permanent: true, + }; + this.coverage.set(key, entry); + } + } + + public onInitialized(): void { + this.readyForRegistration = true; + void this.updateRegistration(); + } + + public dispose(): void { + void this.registration?.dispose?.(); + this.watchers.clear(); + this.coverage.clear(); + this.coverageUsage.clear(); + } + + public handleTsserverEvent(event: ts.server.protocol.Event): boolean { + switch (event.event) { + case 'createFileWatcher': + this.addFileWatcher((event as ts.server.protocol.CreateFileWatcherEvent).body); + return true; + case 'createDirectoryWatcher': + this.addDirectoryWatcher((event as ts.server.protocol.CreateDirectoryWatcherEvent).body); + return true; + case 'closeFileWatcher': + this.closeWatcher((event as ts.server.protocol.CloseFileWatcherEvent).body); + return true; + default: + return false; + } + } + + public handleFileChanges(params: lsp.DidChangeWatchedFilesParams): void { + if (!this.watchers.size || !params.changes.length) { + return; + } + + type ChangeBucket = { created: string[]; deleted: string[]; updated: string[]; }; + const collected = new Map(); + for (const change of params.changes) { + const uri = URI.parse(change.uri); + const fsPath = uri.fsPath; + const normalizedFsPath = this.normalizePath(fsPath); + const tsserverPath = this.toTsserverPath(fsPath); + + for (const watcher of this.watchers.values()) { + if (!this.watcherMatchesPath(watcher, normalizedFsPath)) { + continue; + } + + if (change.type === lsp.FileChangeType.Changed && watcher.ignoreUpdate) { + continue; + } + + const changes = this.ensureChangeBucket(collected, watcher.id); + switch (change.type) { + case lsp.FileChangeType.Created: + changes.created.push(tsserverPath); + break; + case lsp.FileChangeType.Deleted: + changes.deleted.push(tsserverPath); + break; + case lsp.FileChangeType.Changed: + changes.updated.push(tsserverPath); + break; + } + } + } + + if (!collected.size) { + return; + } + + const payload: ts.server.protocol.WatchChangeRequestArgs[] = []; + for (const [id, changes] of collected.entries()) { + payload.push({ + id, + created: changes.created.length ? changes.created : undefined, + deleted: changes.deleted.length ? changes.deleted : undefined, + updated: changes.updated.length ? changes.updated : undefined, + }); + } + this.options.sendWatchChanges(payload.length === 1 ? payload[0] : payload); + } + + private ensureChangeBucket(collected: Map, id: number) { + let bucket = collected.get(id); + if (!bucket) { + bucket = { created: [], deleted: [], updated: [] }; + collected.set(id, bucket); + } + return bucket; + } + + private addFileWatcher(body: ts.server.protocol.CreateFileWatcherEventBody): void { + const normalizedPath = this.normalizePath(body.path); + const watcher: TsserverWatcher = { + id: body.id, + path: body.path, + normalizedPath, + kind: 'file', + recursive: false, + ignoreUpdate: false, + }; + + watcher.coverageKey = this.ensureCoverageForWatcher(watcher); + this.watchers.set(body.id, watcher); + } + + private addDirectoryWatcher(body: ts.server.protocol.CreateDirectoryWatcherEventBody): void { + const normalizedPath = this.normalizePath(body.path); + const watcher: TsserverWatcher = { + id: body.id, + path: body.path, + normalizedPath, + kind: 'directory', + recursive: !!body.recursive, + ignoreUpdate: !!body.ignoreUpdate, + }; + + watcher.coverageKey = this.ensureCoverageForWatcher(watcher); + this.watchers.set(body.id, watcher); + } + + private closeWatcher(body: ts.server.protocol.CloseFileWatcherEventBody): void { + const watcher = this.watchers.get(body.id); + if (!watcher) { + return; + } + this.watchers.delete(body.id); + if (!watcher.coverageKey) { + return; + } + const usage = (this.coverageUsage.get(watcher.coverageKey) ?? 1) - 1; + if (usage <= 0) { + this.coverageUsage.delete(watcher.coverageKey); + const coverage = this.coverage.get(watcher.coverageKey); + if (coverage && !coverage.permanent) { + this.coverage.delete(watcher.coverageKey); + this.registrationSignature = undefined; + void this.updateRegistration(); + } + } else { + this.coverageUsage.set(watcher.coverageKey, usage); + } + } + + private ensureCoverageForWatcher(watcher: TsserverWatcher): string | undefined { + if (this.isCoveredByWorkspace(watcher.normalizedPath)) { + return undefined; + } + + const basePath = watcher.kind === 'file' ? path.dirname(watcher.path) : watcher.path; + const pattern = watcher.kind === 'file' + ? path.basename(watcher.path) + : watcher.recursive ? '**/*' : '*'; + const coverageKey = this.coverageKey(basePath, pattern); + const desiredWatchKind = watcher.ignoreUpdate + ? lsp.WatchKind.Create | lsp.WatchKind.Delete + : lsp.WatchKind.Create | lsp.WatchKind.Change | lsp.WatchKind.Delete; + + const existing = this.coverage.get(coverageKey); + let coverageChanged = false; + if (existing) { + const addedChangeWatch = desiredWatchKind !== existing.watchKind + && (existing.watchKind & lsp.WatchKind.Change) === 0 + && (desiredWatchKind & lsp.WatchKind.Change) !== 0; + if (addedChangeWatch) { + const updated = this.createCoverageEntry(basePath, pattern, desiredWatchKind, coverageKey, false); + this.coverage.set(coverageKey, updated); + coverageChanged = true; + } + } else { + const entry = this.createCoverageEntry(basePath, pattern, desiredWatchKind, coverageKey, false); + this.coverage.set(coverageKey, entry); + coverageChanged = true; + } + + this.coverageUsage.set(coverageKey, (this.coverageUsage.get(coverageKey) ?? 0) + 1); + if (coverageChanged) { + this.registrationSignature = undefined; + void this.updateRegistration(); + } + return coverageKey; + } + + private createCoverageEntry(basePath: string, pattern: string, watchKind: lsp.WatchKind, key: string, permanent: boolean): CoverageEntry { + return { + key, + watchKind, + normalizedBase: this.normalizePath(basePath), + watcher: { + globPattern: { + baseUri: URI.file(basePath).toString(), + pattern, + }, + kind: watchKind, + }, + permanent, + }; + } + + private async updateRegistration(): Promise { + if (!this.readyForRegistration) { + return; + } + + const watchers = Array.from(this.coverage.values(), entry => entry.watcher); + if (!watchers.length) { + this.registration?.dispose(); + this.registration = undefined; + this.registrationSignature = undefined; + return; + } + const signature = JSON.stringify(watchers.map(watcher => { + const globPattern = watcher.globPattern; + if (typeof globPattern === 'string') { + return { pattern: globPattern, kind: watcher.kind }; + } + + if ('baseUri' in globPattern) { + const baseUri = globPattern.baseUri; + const baseUriString = typeof baseUri === 'string' ? baseUri : baseUri.uri; + return { baseUri: baseUriString, pattern: globPattern.pattern, kind: watcher.kind }; + } + + // Fallback for unexpected shapes + return { pattern: String(globPattern), kind: watcher.kind }; + })); + if (signature === this.registrationSignature) { + return; + } + this.registrationSignature = signature; + + try { + this.registration?.dispose?.(); + this.registration = await this.options.lspClient.registerFileWatcher(watchers); + } catch (err) { + this.options.logger.warn('Failed to register file watchers for tsserver watch events', err); + } + } + + private watcherMatchesPath(watcher: TsserverWatcher, normalizedFsPath: string): boolean { + if (watcher.kind === 'file') { + return watcher.normalizedPath === normalizedFsPath; + } + + const base = watcher.normalizedPath; + if (watcher.recursive) { + return normalizedFsPath === base || normalizedFsPath.startsWith(base + '/'); + } + return this.dirname(normalizedFsPath) === base; + } + + private isCoveredByWorkspace(normalizedPath: string): boolean { + return this.workspacePaths.some(base => normalizedPath === base || normalizedPath.startsWith(base + '/')); + } + + private normalizePath(input: string): string { + const normalized = path.normalize(input).replace(/\\/g, '/'); + const isDriveRoot = /^[a-zA-Z]:\/?$/.test(normalized); + const isPosixRoot = normalized === '/'; + const trimmed = isDriveRoot || isPosixRoot ? normalized.replace(/\/+$/, '/') : normalized.replace(/\/+$/, ''); + return this.options.caseInsensitive ? trimmed.toLowerCase() : trimmed; + } + + private dirname(normalizedPath: string): string { + const dir = normalizedPath.substring(0, normalizedPath.lastIndexOf('/')); + return dir || normalizedPath; + } + + private coverageKey(basePath: string, pattern: string) { + return `${this.normalizePath(basePath)}|${pattern}`; + } + + private toTsserverPath(fsPath: string): string { + return fsPath.replace(/\\/g, '/'); + } +} From ed281288d7d00680eed5c03b6f11b385c862c9c6 Mon Sep 17 00:00:00 2001 From: Oleksandra Kordonets Date: Thu, 20 Nov 2025 19:16:27 -0500 Subject: [PATCH 3/9] Expose canUseWatchEvents opt-in flag and make test LSP client resilient for watch-events --- src/cli.ts | 4 +++- src/test-utils.ts | 12 +++++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index e8638d9b..e1e3a0df 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,9 +17,10 @@ const program = new Command('typescript-language-server') .version(version) .requiredOption('--stdio', 'use stdio') .option('--log-level ', 'A number indicating the log level (4 = log, 3 = info, 2 = warn, 1 = error). Defaults to `2`.', value => parseInt(value, 10), 2) + .option('--canUseWatchEvents', 'Opt-in to tsserver watch events mode when supported by the client') .parse(process.argv); -const options = program.opts<{ logLevel: number; }>(); +const options = program.opts<{ logLevel: number; canUseWatchEvents?: boolean; }>(); let logLevel = DEFAULT_LOG_LEVEL; if (options.logLevel && (options.logLevel < 1 || options.logLevel > 4)) { @@ -29,4 +30,5 @@ if (options.logLevel && (options.logLevel < 1 || options.logLevel > 4)) { createLspConnection({ showMessageLevel: logLevel as lsp.MessageType, + canUseWatchEvents: options.canUseWatchEvents, }).listen(); diff --git a/src/test-utils.ts b/src/test-utils.ts index 368cfaa7..b2b34a1f 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -184,17 +184,23 @@ export class TestLspClient implements LspClient { return { applied: true }; } - rename(): Promise { + rename(_args: lsp.TextDocumentPositionParams): Promise { throw new Error('unsupported'); } - sendNotification

(_type: lsp.NotificationType

, _params: P): Promise { - throw new Error('unsupported'); + async sendNotification

(_type: lsp.NotificationType

, _params: P): Promise { + return Promise.resolve(); } async getWorkspaceConfiguration(_scopeUri: string, _section: string): Promise { return Promise.resolve(undefined); } + + registerFileWatcher(_watchers: lsp.FileSystemWatcher[]): Promise { + return Promise.resolve({ + dispose: () => {}, + }); + } } export class TestLspServer extends LspServer { From 1cb8a430b7cd48fcebc05760065621e432816f68 Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Sat, 22 Nov 2025 16:49:40 +0100 Subject: [PATCH 4/9] replace cli with initializationOptions --- src/cli.ts | 4 +--- src/lsp-connection.ts | 2 -- src/lsp-server.ts | 8 ++++---- src/ts-client.test.ts | 1 + src/ts-client.ts | 7 +------ src/ts-protocol.ts | 8 ++++---- src/tsServer/spawner.ts | 2 +- src/utils/configuration.ts | 1 - 8 files changed, 12 insertions(+), 21 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index e1e3a0df..e8638d9b 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -17,10 +17,9 @@ const program = new Command('typescript-language-server') .version(version) .requiredOption('--stdio', 'use stdio') .option('--log-level ', 'A number indicating the log level (4 = log, 3 = info, 2 = warn, 1 = error). Defaults to `2`.', value => parseInt(value, 10), 2) - .option('--canUseWatchEvents', 'Opt-in to tsserver watch events mode when supported by the client') .parse(process.argv); -const options = program.opts<{ logLevel: number; canUseWatchEvents?: boolean; }>(); +const options = program.opts<{ logLevel: number; }>(); let logLevel = DEFAULT_LOG_LEVEL; if (options.logLevel && (options.logLevel < 1 || options.logLevel > 4)) { @@ -30,5 +29,4 @@ if (options.logLevel && (options.logLevel < 1 || options.logLevel > 4)) { createLspConnection({ showMessageLevel: logLevel as lsp.MessageType, - canUseWatchEvents: options.canUseWatchEvents, }).listen(); diff --git a/src/lsp-connection.ts b/src/lsp-connection.ts index f5b466f6..a63e3bbc 100644 --- a/src/lsp-connection.ts +++ b/src/lsp-connection.ts @@ -12,7 +12,6 @@ import { LspClientImpl } from './lsp-client.js'; export interface LspConnectionOptions { showMessageLevel: lsp.MessageType; - canUseWatchEvents?: boolean; } export function createLspConnection(options: LspConnectionOptions): lsp.Connection { @@ -22,7 +21,6 @@ export function createLspConnection(options: LspConnectionOptions): lsp.Connecti const server: LspServer = new LspServer({ logger, lspClient, - canUseWatchEvents: options.canUseWatchEvents, }); connection.onInitialize(server.initialize.bind(server)); diff --git a/src/lsp-server.ts b/src/lsp-server.ts index 0e3d6ec4..4ae23a66 100644 --- a/src/lsp-server.ts +++ b/src/lsp-server.ts @@ -154,7 +154,7 @@ export class LspServer { const supportsFileWatcherRegistration = Boolean(workspace?.didChangeWatchedFiles?.dynamicRegistration); const supportsRelativePatterns = workspace?.didChangeWatchedFiles?.relativePatternSupport !== false; - const requestedWatchEvents = userInitializationOptions.tsserver?.canUseWatchEvents ?? this.options.canUseWatchEvents ?? false; + const requestedWatchEvents = tsserver?.useClientFileWatcher ?? false; const typescriptSupportsWatchEvents = typescriptVersion.version?.gte(API.v540); const canUseWatchEvents = Boolean(requestedWatchEvents && supportsFileWatcherRegistration && supportsRelativePatterns && typescriptSupportsWatchEvents); @@ -163,7 +163,7 @@ export class LspServer { } else if (requestedWatchEvents && !supportsRelativePatterns) { this.logger.logIgnoringVerbosity(LogLevel.Warning, 'Client does not support relative file watcher patterns; tsserver watch events will stay disabled.'); } else if (requestedWatchEvents && !typescriptSupportsWatchEvents) { - this.logger.logIgnoringVerbosity(LogLevel.Warning, 'tsserver watch events require TypeScript 5.4 or newer; disabling canUseWatchEvents.'); + this.logger.logIgnoringVerbosity(LogLevel.Warning, 'tsserver watch events require TypeScript 5.4 or newer; disabling useClientFileWatcher.'); } const tsserverLogVerbosity = tsserver?.logVerbosity && TsServerLogLevel.fromString(tsserver.logVerbosity); @@ -187,8 +187,8 @@ export class LspServer { throw new Error(`tsserver process has exited (exit code: ${exitCode}, signal: ${signal}). Stopping the server.`); } }, - useSyntaxServer: toSyntaxServerConfiguration(userInitializationOptions.tsserver?.useSyntaxServer), - canUseWatchEvents, + useClientFileWatcher: tsserver?.useClientFileWatcher ?? false, + useSyntaxServer: toSyntaxServerConfiguration(tsserver?.useSyntaxServer), }); if (!started) { throw new Error('tsserver process has failed to start.'); diff --git a/src/ts-client.test.ts b/src/ts-client.test.ts index 0039ef77..c926f43c 100644 --- a/src/ts-client.test.ts +++ b/src/ts-client.test.ts @@ -38,6 +38,7 @@ describe('ts server client', () => { plugins: [], trace: Trace.Off, typescriptVersion: bundled!, + useClientFileWatcher: false, useSyntaxServer: SyntaxServerConfiguration.Never, }, ); diff --git a/src/ts-client.ts b/src/ts-client.ts index 55b0ba30..41961319 100644 --- a/src/ts-client.ts +++ b/src/ts-client.ts @@ -154,8 +154,8 @@ export interface TsClientOptions { plugins: TypeScriptPlugin[]; onEvent?: (event: ts.server.protocol.Event) => void; onExit?: (exitCode: number | null, signal: NodeJS.Signals | null) => void; + useClientFileWatcher: boolean; useSyntaxServer: SyntaxServerConfiguration; - canUseWatchEvents?: boolean; } export class TsClient implements ITypeScriptServiceClient { @@ -174,7 +174,6 @@ export class TsClient implements ITypeScriptServiceClient { private useSyntaxServer: SyntaxServerConfiguration = SyntaxServerConfiguration.Auto; private onEvent?: (event: ts.server.protocol.Event) => void; private onExit?: (exitCode: number | null, signal: NodeJS.Signals | null) => void; - private canUseWatchEvents: boolean = false; constructor( onCaseInsensitiveFileSystem: boolean, @@ -349,9 +348,6 @@ export class TsClient implements ITypeScriptServiceClient { } public sendWatchChanges(args: ts.server.protocol.WatchChangeRequestArgs | readonly ts.server.protocol.WatchChangeRequestArgs[]): void { - if (!this.canUseWatchEvents) { - return; - } this.executeWithoutWaitingForResponse(CommandTypes.WatchChange, args); } @@ -365,7 +361,6 @@ export class TsClient implements ITypeScriptServiceClient { this.tracer = new Tracer(this.tsserverLogger, options.trace); this.workspaceFolders = workspaceRoot ? [{ uri: URI.file(workspaceRoot) }] : []; this.useSyntaxServer = options.useSyntaxServer; - this.canUseWatchEvents = Boolean(options.canUseWatchEvents && this.apiVersion.gte(API.v540)); this.onEvent = options.onEvent; this.onExit = options.onExit; this.pluginManager.setPlugins(options.plugins); diff --git a/src/ts-protocol.ts b/src/ts-protocol.ts index 2bf3821b..a9290ff7 100644 --- a/src/ts-protocol.ts +++ b/src/ts-protocol.ts @@ -415,6 +415,10 @@ interface TsserverOptions { * @default 'off' */ trace?: TraceValue; + /** + * Use client's file watcher instead of TypeScript's built-in one. Requires TypeScript 5.4+ in the workspace. + */ + useClientFileWatcher?: boolean; /** * Whether a dedicated server is launched to more quickly handle syntax related operations, such as computing diagnostics or code folding. * @@ -425,10 +429,6 @@ interface TsserverOptions { * @default 'auto' */ useSyntaxServer?: 'auto' | 'never'; - /** - * Whether to opt in to tsserver's watch events mode (using the `--canUseWatchEvents` flag) when supported by the client and TypeScript version. - */ - canUseWatchEvents?: boolean; } export type TypeScriptInitializeParams = lsp.InitializeParams & { diff --git a/src/tsServer/spawner.ts b/src/tsServer/spawner.ts index 0ca8090a..2fa87db6 100644 --- a/src/tsServer/spawner.ts +++ b/src/tsServer/spawner.ts @@ -221,7 +221,7 @@ export class TypeScriptServerSpawner { args.push('--npmLocation', `"${npmLocation}"`); } - if (configuration.canUseWatchEvents && apiVersion.gte(API.v540)) { + if (configuration.useClientFileWatcher && apiVersion.gte(API.v540)) { args.push('--canUseWatchEvents'); } diff --git a/src/utils/configuration.ts b/src/utils/configuration.ts index 1b5a13ab..ede34656 100644 --- a/src/utils/configuration.ts +++ b/src/utils/configuration.ts @@ -51,7 +51,6 @@ export namespace TsServerLogLevel { export interface LspServerConfiguration { readonly logger: Logger; readonly lspClient: LspClient; - readonly canUseWatchEvents?: boolean; } export const enum SyntaxServerConfiguration { From c3e4a6b9e09675df37fa6f7b57ab31c71f09586e Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Sat, 22 Nov 2025 16:54:21 +0100 Subject: [PATCH 5/9] small renames --- src/lsp-client.ts | 4 ++-- src/test-utils.ts | 12 +++++------- src/watchEventManager.ts | 2 +- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/lsp-client.ts b/src/lsp-client.ts index 7ea4db13..dff54221 100644 --- a/src/lsp-client.ts +++ b/src/lsp-client.ts @@ -25,7 +25,7 @@ export interface LspClient { rename(args: lsp.TextDocumentPositionParams): Promise; sendNotification

(type: lsp.NotificationType

, params: P): Promise; getWorkspaceConfiguration(scopeUri: string, section: string): Promise; - registerFileWatcher(watchers: lsp.FileSystemWatcher[]): Promise; + registerDidChangeWatchedFilesCapability(watchers: lsp.FileSystemWatcher[]): Promise; } // Hack around the LSP library that makes it otherwise impossible to differentiate between Null and Client-initiated reporter. @@ -84,7 +84,7 @@ export class LspClientImpl implements LspClient { await this.connection.sendNotification(type, params); } - async registerFileWatcher(watchers: lsp.FileSystemWatcher[]): Promise { + async registerDidChangeWatchedFilesCapability(watchers: lsp.FileSystemWatcher[]): Promise { return await this.connection.client.register(lsp.DidChangeWatchedFilesNotification.type, { watchers }); } } diff --git a/src/test-utils.ts b/src/test-utils.ts index b2b34a1f..777dc52e 100644 --- a/src/test-utils.ts +++ b/src/test-utils.ts @@ -161,7 +161,7 @@ export class TestLspClient implements LspClient { } publishDiagnostics(args: lsp.PublishDiagnosticsParams): void { - return this.options.publishDiagnostics(args); + this.options.publishDiagnostics(args); } showErrorMessage(message: string): void { @@ -188,18 +188,16 @@ export class TestLspClient implements LspClient { throw new Error('unsupported'); } - async sendNotification

(_type: lsp.NotificationType

, _params: P): Promise { - return Promise.resolve(); + sendNotification

(_type: lsp.NotificationType

, _params: P): Promise { + throw new Error('unsupported'); } async getWorkspaceConfiguration(_scopeUri: string, _section: string): Promise { return Promise.resolve(undefined); } - registerFileWatcher(_watchers: lsp.FileSystemWatcher[]): Promise { - return Promise.resolve({ - dispose: () => {}, - }); + registerDidChangeWatchedFilesCapability(_watchers: lsp.FileSystemWatcher[]): Promise { + throw new Error('unsupported'); } } diff --git a/src/watchEventManager.ts b/src/watchEventManager.ts index 6c1b6558..a53d67cf 100644 --- a/src/watchEventManager.ts +++ b/src/watchEventManager.ts @@ -293,7 +293,7 @@ export class WatchEventManager { try { this.registration?.dispose?.(); - this.registration = await this.options.lspClient.registerFileWatcher(watchers); + this.registration = await this.options.lspClient.registerDidChangeWatchedFilesCapability(watchers); } catch (err) { this.options.logger.warn('Failed to register file watchers for tsserver watch events', err); } From 118b4d495b16a6bfe33fafe8c5db050ed1210add Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Sat, 22 Nov 2025 16:58:51 +0100 Subject: [PATCH 6/9] require TS 5.4.4 to follow vscode --- src/lsp-server.ts | 4 ++-- src/ts-protocol.ts | 2 +- src/tsServer/spawner.ts | 2 +- src/utils/api.ts | 1 + 4 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/lsp-server.ts b/src/lsp-server.ts index 4ae23a66..49dbd8bb 100644 --- a/src/lsp-server.ts +++ b/src/lsp-server.ts @@ -155,7 +155,7 @@ export class LspServer { const supportsFileWatcherRegistration = Boolean(workspace?.didChangeWatchedFiles?.dynamicRegistration); const supportsRelativePatterns = workspace?.didChangeWatchedFiles?.relativePatternSupport !== false; const requestedWatchEvents = tsserver?.useClientFileWatcher ?? false; - const typescriptSupportsWatchEvents = typescriptVersion.version?.gte(API.v540); + const typescriptSupportsWatchEvents = typescriptVersion.version?.gte(API.v544); const canUseWatchEvents = Boolean(requestedWatchEvents && supportsFileWatcherRegistration && supportsRelativePatterns && typescriptSupportsWatchEvents); if (requestedWatchEvents && !supportsFileWatcherRegistration) { @@ -163,7 +163,7 @@ export class LspServer { } else if (requestedWatchEvents && !supportsRelativePatterns) { this.logger.logIgnoringVerbosity(LogLevel.Warning, 'Client does not support relative file watcher patterns; tsserver watch events will stay disabled.'); } else if (requestedWatchEvents && !typescriptSupportsWatchEvents) { - this.logger.logIgnoringVerbosity(LogLevel.Warning, 'tsserver watch events require TypeScript 5.4 or newer; disabling useClientFileWatcher.'); + this.logger.logIgnoringVerbosity(LogLevel.Warning, 'tsserver watch events require TypeScript 5.4.4 or newer; disabling useClientFileWatcher.'); } const tsserverLogVerbosity = tsserver?.logVerbosity && TsServerLogLevel.fromString(tsserver.logVerbosity); diff --git a/src/ts-protocol.ts b/src/ts-protocol.ts index a9290ff7..59b837fd 100644 --- a/src/ts-protocol.ts +++ b/src/ts-protocol.ts @@ -416,7 +416,7 @@ interface TsserverOptions { */ trace?: TraceValue; /** - * Use client's file watcher instead of TypeScript's built-in one. Requires TypeScript 5.4+ in the workspace. + * Use client's file watcher instead of TypeScript's built-in one. Requires TypeScript 5.4.4+ in the workspace. */ useClientFileWatcher?: boolean; /** diff --git a/src/tsServer/spawner.ts b/src/tsServer/spawner.ts index 2fa87db6..7acabd79 100644 --- a/src/tsServer/spawner.ts +++ b/src/tsServer/spawner.ts @@ -221,7 +221,7 @@ export class TypeScriptServerSpawner { args.push('--npmLocation', `"${npmLocation}"`); } - if (configuration.useClientFileWatcher && apiVersion.gte(API.v540)) { + if (configuration.useClientFileWatcher && apiVersion.gte(API.v544)) { args.push('--canUseWatchEvents'); } diff --git a/src/utils/api.ts b/src/utils/api.ts index f790dfaa..9070dba4 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -35,6 +35,7 @@ export default class API { public static readonly v510 = API.fromSimpleString('5.1.0'); public static readonly v520 = API.fromSimpleString('5.2.0'); public static readonly v540 = API.fromSimpleString('5.4.0'); + public static readonly v544 = API.fromSimpleString('5.4.4'); public static fromVersionString(versionString: string): API { let version = semver.valid(versionString); From 1e86972720359a522d5dee74d3433daf78e5d619 Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Sat, 22 Nov 2025 16:59:38 +0100 Subject: [PATCH 7/9] docs --- src/ts-protocol.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/ts-protocol.ts b/src/ts-protocol.ts index 59b837fd..8e15d724 100644 --- a/src/ts-protocol.ts +++ b/src/ts-protocol.ts @@ -417,6 +417,7 @@ interface TsserverOptions { trace?: TraceValue; /** * Use client's file watcher instead of TypeScript's built-in one. Requires TypeScript 5.4.4+ in the workspace. + * @default false */ useClientFileWatcher?: boolean; /** From 42e1109db3723ce5a8d6d4cece16e6c9d65a0028 Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Sat, 22 Nov 2025 17:03:36 +0100 Subject: [PATCH 8/9] less typing --- src/typescriptService.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/typescriptService.ts b/src/typescriptService.ts index 1d6d771c..663c5708 100644 --- a/src/typescriptService.ts +++ b/src/typescriptService.ts @@ -201,8 +201,7 @@ export interface NoResponseTsServerRequests { [CommandTypes.Configure]: [ts.server.protocol.ConfigureRequestArguments, ts.server.protocol.ConfigureResponse]; [CommandTypes.ConfigurePlugin]: [ts.server.protocol.ConfigurePluginRequestArguments, ts.server.protocol.ConfigurePluginResponse]; [CommandTypes.Open]: [ts.server.protocol.OpenRequestArgs, null]; - [CommandTypes.WatchChange]: [ts.server.protocol.WatchChangeRequestArgs | readonly ts.server.protocol.WatchChangeRequestArgs[], null]; -} + [CommandTypes.WatchChange]: [ts.server.protocol.WatchChangeRequest['arguments'], null];} export interface AsyncTsServerRequests { [CommandTypes.Geterr]: [ts.server.protocol.GeterrRequestArgs, ts.server.protocol.Response]; From ed8b67d7217cca7556e13a32658d5356db99939a Mon Sep 17 00:00:00 2001 From: Rafal Chlodnicki Date: Sat, 22 Nov 2025 17:08:49 +0100 Subject: [PATCH 9/9] update configuration docs --- docs/configuration.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/configuration.md b/docs/configuration.md index 2e8b5836..0409172e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -25,7 +25,7 @@ The language server accepts various settings through the `initializationOptions` | plugins | object[] | An array of `{ name: string, location: string, languages?: string[] }` objects for registering a Typescript plugins. **Default**: [] | | preferences | object | Preferences passed to the Typescript (`tsserver`) process. See [`preferences` options](#preferences-options) for more details. | | supportsMoveToFileCodeAction | boolean | Whether the client supports the "Move to file" interactive code action. See [`supportsMoveToFileCodeAction` option](#supportsmovetofilecodeaction-option) for more details. | -| tsserver | object | Options related to the `tsserver` process. See below for more | +| tsserver | object | Options related to the `tsserver` process. See [tsserver options](#tsserver-options). | ### `plugins` option @@ -74,6 +74,8 @@ Specifies additional options related to the internal `tsserver` process, like tr **trace** [string] The verbosity of logging of the tsserver communication. Delivered through the LSP messages and not related to file logging. Allowed values are: `'off'`, `'messages'`, `'verbose'`. **Default**: `'off'` +**useClientFileWatcher** [boolean] Use client's file watcher instead of TypeScript's built-in one. Requires TypeScript 5.4.4+ in the workspace. **Default**: `false` + **useSyntaxServer** [string] Whether a dedicated server is launched to more quickly handle syntax related operations, such as computing diagnostics or code folding. **Default**: `'auto'`. Allowed values: - `'auto'`: Spawn both a full server and a lighter weight server dedicated to syntax operations. The syntax server is used to speed up syntax operations and provide IntelliSense while projects are loading. - `'never'`: Don't use a dedicated syntax server. Use a single server to handle all IntelliSense operations.