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. diff --git a/src/lsp-client.ts b/src/lsp-client.ts index d3a5f184..dff54221 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; + registerDidChangeWatchedFilesCapability(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 registerDidChangeWatchedFilesCapability(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..a63e3bbc 100644 --- a/src/lsp-connection.ts +++ b/src/lsp-connection.ts @@ -31,6 +31,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..49dbd8bb 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 = tsserver?.useClientFileWatcher ?? false; + const typescriptSupportsWatchEvents = typescriptVersion.version?.gte(API.v544); + 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.4 or newer; disabling useClientFileWatcher.'); + } + const tsserverLogVerbosity = tsserver?.logVerbosity && TsServerLogLevel.fromString(tsserver.logVerbosity); const started = this.tsClient.start( this.workspaceRoot, @@ -169,7 +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), + useClientFileWatcher: tsserver?.useClientFileWatcher ?? false, + useSyntaxServer: toSyntaxServerConfiguration(tsserver?.useSyntaxServer), }); 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/test-utils.ts b/src/test-utils.ts index 368cfaa7..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 { @@ -184,7 +184,7 @@ export class TestLspClient implements LspClient { return { applied: true }; } - rename(): Promise { + rename(_args: lsp.TextDocumentPositionParams): Promise { throw new Error('unsupported'); } @@ -195,6 +195,10 @@ export class TestLspClient implements LspClient { async getWorkspaceConfiguration(_scopeUri: string, _section: string): Promise { return Promise.resolve(undefined); } + + registerDidChangeWatchedFilesCapability(_watchers: lsp.FileSystemWatcher[]): Promise { + throw new Error('unsupported'); + } } export class TestLspServer extends LspServer { 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 c3d06d8b..41961319 100644 --- a/src/ts-client.ts +++ b/src/ts-client.ts @@ -154,6 +154,7 @@ 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; } @@ -346,6 +347,10 @@ export class TsClient implements ITypeScriptServiceClient { } } + public sendWatchChanges(args: ts.server.protocol.WatchChangeRequestArgs | readonly ts.server.protocol.WatchChangeRequestArgs[]): void { + this.executeWithoutWaitingForResponse(CommandTypes.WatchChange, args); + } + start( workspaceRoot: string | undefined, options: TsClientOptions, diff --git a/src/ts-protocol.ts b/src/ts-protocol.ts index a7f382a8..8e15d724 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 { @@ -411,6 +415,11 @@ interface TsserverOptions { * @default 'off' */ 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; /** * Whether a dedicated server is launched to more quickly handle syntax related operations, such as computing diagnostics or code folding. * diff --git a/src/tsServer/spawner.ts b/src/tsServer/spawner.ts index e634c92d..7acabd79 100644 --- a/src/tsServer/spawner.ts +++ b/src/tsServer/spawner.ts @@ -221,6 +221,10 @@ export class TypeScriptServerSpawner { args.push('--npmLocation', `"${npmLocation}"`); } + if (configuration.useClientFileWatcher && apiVersion.gte(API.v544)) { + 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..663c5708 100644 --- a/src/typescriptService.ts +++ b/src/typescriptService.ts @@ -201,7 +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.WatchChangeRequest['arguments'], null];} export interface AsyncTsServerRequests { [CommandTypes.Geterr]: [ts.server.protocol.GeterrRequestArgs, ts.server.protocol.Response]; 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); diff --git a/src/watchEventManager.ts b/src/watchEventManager.ts new file mode 100644 index 00000000..a53d67cf --- /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.registerDidChangeWatchedFilesCapability(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, '/'); + } +}