'use strict' import * as minimatch from 'minimatch' import { CancellationToken, ClientCapabilities, CreateFilesParams, DeleteFilesParams, DidCreateFilesNotification, DidDeleteFilesNotification, DidRenameFilesNotification, Disposable, Event, FileOperationClientCapabilities, FileOperationOptions, FileOperationPatternKind, FileOperationPatternOptions, FileOperationRegistrationOptions, ProtocolNotificationType, ProtocolRequestType, RegistrationType, RenameFilesParams, ServerCapabilities, WillCreateFilesRequest, WillDeleteFilesRequest, WillRenameFilesRequest, WorkspaceEdit } from 'vscode-languageserver-protocol' import { URI } from 'vscode-uri' import { FileCreateEvent, FileDeleteEvent, FileRenameEvent, FileType, FileWillCreateEvent, FileWillDeleteEvent, FileWillRenameEvent } from '../types' import { getFileType } from '../util/fs' import workspace from '../workspace' import { BaseFeature, DynamicFeature, ensure, FeatureClient, FeatureState, NextSignature, RegistrationData } from './features' import * as UUID from './utils/uuid' const logger = require('../util/logger')('language-client-fileOperations') function access(target: T, key: K): T[K] { return target[key] } function assign(target: T, key: K, value: T[K]): void { target[key] = value } function asCreateDeleteFilesParams(e: FileCreateEvent | FileDeleteEvent): CreateFilesParams | DeleteFilesParams { return { files: e.files.map(f => ({ uri: f.toString() })) } } function asRenameFilesParams(e: FileRenameEvent | FileWillRenameEvent): RenameFilesParams { return { files: e.files.map(f => ({ oldUri: f.oldUri.toString(), newUri: f.newUri.toString() })) } } /** * File operation middleware * * @since 3.16.0 */ export interface FileOperationsMiddleware { didCreateFiles?: NextSignature willCreateFiles?: NextSignature> didRenameFiles?: NextSignature willRenameFiles?: NextSignature> didDeleteFiles?: NextSignature willDeleteFiles?: NextSignature> } interface FileOperationsWorkspaceMiddleware { workspace?: FileOperationsMiddleware } interface EventWithFiles { readonly files: ReadonlyArray } abstract class FileOperationFeature> extends BaseFeature implements DynamicFeature { private _event: Event private _registrationType: RegistrationType private _clientCapability: keyof FileOperationClientCapabilities private _serverCapability: keyof FileOperationOptions private _listener: Disposable | undefined private _filters = new Map< string, Array<{ scheme?: string matcher: minimatch.IMinimatch kind?: FileOperationPatternKind }> >() constructor( client: FeatureClient, event: Event, registrationType: RegistrationType, clientCapability: keyof FileOperationClientCapabilities, serverCapability: keyof FileOperationOptions ) { super(client) this._event = event this._registrationType = registrationType this._clientCapability = clientCapability this._serverCapability = serverCapability } public getState(): FeatureState { return { kind: 'workspace', id: this._registrationType.method, registrations: this._filters.size > 0 } } public get registrationType(): RegistrationType { return this._registrationType } public fillClientCapabilities(capabilities: ClientCapabilities): void { const value = ensure(ensure(capabilities, 'workspace')!, 'fileOperations')! // this happens n times but it is the same value so we tolerate this. assign(value, 'dynamicRegistration', true) assign(value, this._clientCapability, true) } public initialize(capabilities: ServerCapabilities): void { const options = capabilities.workspace?.fileOperations const capability = options !== undefined ? access(options, this._serverCapability) : undefined if (capability?.filters !== undefined) { try { this.register({ id: UUID.generateUuid(), registerOptions: { filters: capability.filters }, }) } catch (e) { this._client.warn( `Ignoring invalid glob pattern for ${this._serverCapability} registration: ${e}` ) } } } public register(data: RegistrationData): void { if (!this._listener) { this._listener = this._event(this.send, this) } const minimatchFilter = data.registerOptions.filters.map(filter => { const matcher = new minimatch.Minimatch( filter.pattern.glob, FileOperationFeature.asMinimatchOptions(filter.pattern.options) ) if (!matcher.makeRe()) { throw new Error(`Invalid pattern ${filter.pattern.glob}!`) } return { scheme: filter.scheme, matcher, kind: filter.pattern.matches } }) this._filters.set(data.id, minimatchFilter) } public abstract send(data: E): Promise public unregister(id: string): void { this._filters.delete(id) } public dispose(): void { this._filters.clear() if (this._listener) { this._listener.dispose() this._listener = undefined } } public async filter(event: E, prop: (i: I) => URI): Promise { // (Asynchronously) map each file onto a boolean of whether it matches // any of the globs. const fileMatches = await Promise.all( event.files.map(async item => { const uri = prop(item) // Use fsPath to make this consistent with file system watchers but help // minimatch to use '/' instead of `\\` if present. const path = uri.fsPath.replace(/\\/g, '/') for (const filters of this._filters.values()) { for (const filter of filters) { if (filter.scheme !== undefined && filter.scheme !== uri.scheme) { continue } if (filter.matcher.match(path)) { // The pattern matches. If kind is undefined then everything is ok if (filter.kind === undefined) { return true } const fileType = await getFileType(uri.fsPath) // If we can't determine the file type than we treat it as a match. // Dropping it would be another alternative. if (fileType === undefined) { this._client.error(`Failed to determine file type for ${uri.toString()}.`) return true } if ( (fileType === FileType.File && filter.kind === FileOperationPatternKind.file) || (fileType === FileType.Directory && filter.kind === FileOperationPatternKind.folder) ) { return true } } else if (filter.kind === FileOperationPatternKind.folder) { const fileType = await getFileType(uri.fsPath) if (fileType === FileType.Directory && filter.matcher.match(`${path}/`)) { return true } } } } return false }) ) // Filter the files to those that matched. const files = event.files.filter((_, index) => fileMatches[index]) return { ...event, files } } public static asMinimatchOptions(options: FileOperationPatternOptions | undefined): minimatch.IOptions | undefined { if (options === undefined) { return undefined } if (options.ignoreCase === true) { return { nocase: true } } return undefined } } abstract class NotificationFileOperationFeature }, P> extends FileOperationFeature { private _notificationType: ProtocolNotificationType private _accessUri: (i: I) => URI private _createParams: (e: E) => P constructor( client: FeatureClient, event: Event, notificationType: ProtocolNotificationType, clientCapability: keyof FileOperationClientCapabilities, serverCapability: keyof FileOperationOptions, accessUri: (i: I) => URI, createParams: (e: E) => P ) { super(client, event, notificationType, clientCapability, serverCapability) this._notificationType = notificationType this._accessUri = accessUri this._createParams = createParams } public async send(originalEvent: E): Promise { // Create a copy of the event that has the files filtered to match what the // server wants. const filteredEvent = await this.filter(originalEvent, this._accessUri) if (filteredEvent.files.length) { const next = async (event: E): Promise => { if (!this._client.isRunning()) return return this._client.sendNotification( this._notificationType, this._createParams(event) ) } let promise = this.doSend(filteredEvent, next) if (promise) { await promise.catch(e => { this._client.error(`Sending notification ${this.registrationType.method} failed`, e) }) } } } protected abstract doSend(event: E, next: (event: E) => void): void | Promise } export class DidCreateFilesFeature extends NotificationFileOperationFeature { constructor(client: FeatureClient) { super( client, workspace.onDidCreateFiles, DidCreateFilesNotification.type, 'didCreate', 'didCreate', (i: URI) => i, e => asCreateDeleteFilesParams(e), ) } protected doSend(event: FileCreateEvent, next: (event: FileCreateEvent) => void): void | Promise { const middleware = this._client.middleware.workspace return middleware?.didCreateFiles ? middleware.didCreateFiles(event, next) : next(event) } } export class DidRenameFilesFeature extends NotificationFileOperationFeature<{ oldUri: URI; newUri: URI }, FileRenameEvent, RenameFilesParams> { constructor(client: FeatureClient) { super( client, workspace.onDidRenameFiles, DidRenameFilesNotification.type, 'didRename', 'didRename', (i: { oldUri: URI; newUri: URI }) => i.oldUri, e => asRenameFilesParams(e) ) } protected doSend(event: FileRenameEvent, next: (event: FileRenameEvent) => void): void | Promise { const middleware = this._client.middleware.workspace return middleware?.didRenameFiles ? middleware.didRenameFiles(event, next) : next(event) } } export class DidDeleteFilesFeature extends NotificationFileOperationFeature { constructor(client: FeatureClient) { super( client, workspace.onDidDeleteFiles, DidDeleteFilesNotification.type, 'didDelete', 'didDelete', (i: URI) => i, e => asCreateDeleteFilesParams(e) ) } protected doSend(event: FileCreateEvent, next: (event: FileCreateEvent) => void): void | Promise { const middleware = this._client.middleware.workspace return middleware?.didDeleteFiles ? middleware.didDeleteFiles(event, next) : next(event) } } interface RequestEvent { readonly files: ReadonlyArray waitUntil(thenable: Thenable): void } abstract class RequestFileOperationFeature, P> extends FileOperationFeature { private _requestType: ProtocolRequestType private _accessUri: (i: I) => URI private _createParams: (e: EventWithFiles) => P constructor( client: FeatureClient, event: Event, requestType: ProtocolRequestType, clientCapability: keyof FileOperationClientCapabilities, serverCapability: keyof FileOperationOptions, accessUri: (i: I) => URI, createParams: (e: EventWithFiles) => P ) { super(client, event, requestType, clientCapability, serverCapability) this._requestType = requestType this._accessUri = accessUri this._createParams = createParams } public async send(originalEvent: E & RequestEvent): Promise { const waitUntil = this.waitUntil(originalEvent) originalEvent.waitUntil(waitUntil) } private async waitUntil(originalEvent: E): Promise { // Create a copy of the event that has the files filtered to match what the // server wants. const filteredEvent = await this.filter(originalEvent, this._accessUri) if (filteredEvent.files.length) { const next = (event: EventWithFiles): Promise => { return this.sendRequest(this._requestType, this._createParams(event), CancellationToken.None) } return this.doSend(filteredEvent, next) } else { return undefined } } protected abstract doSend(event: E, next: (event: EventWithFiles) => Thenable | Thenable): Thenable | Thenable } export class WillCreateFilesFeature extends RequestFileOperationFeature { constructor(client: FeatureClient) { super( client, workspace.onWillCreateFiles, WillCreateFilesRequest.type, 'willCreate', 'willCreate', (i: URI) => i, e => asCreateDeleteFilesParams(e) ) } protected doSend(event: FileWillCreateEvent, next: (event: FileWillCreateEvent) => Thenable | Thenable): Thenable | Thenable { const middleware = this._client.middleware.workspace return middleware?.willCreateFiles ? middleware.willCreateFiles(event, next) : next(event) } } export class WillRenameFilesFeature extends RequestFileOperationFeature<{ oldUri: URI; newUri: URI }, FileWillRenameEvent, RenameFilesParams> { constructor(client: FeatureClient) { super( client, workspace.onWillRenameFiles, WillRenameFilesRequest.type, 'willRename', 'willRename', (i: { oldUri: URI; newUri: URI }) => i.oldUri, e => asRenameFilesParams(e) ) } protected doSend(event: FileWillRenameEvent, next: (event: FileWillRenameEvent) => Thenable | Thenable): Thenable | Thenable { const middleware = this._client.middleware.workspace return middleware?.willRenameFiles ? middleware.willRenameFiles(event, next) : next(event) } } export class WillDeleteFilesFeature extends RequestFileOperationFeature { constructor(client: FeatureClient) { super( client, workspace.onWillDeleteFiles, WillDeleteFilesRequest.type, 'willDelete', 'willDelete', (i: URI) => i, e => asCreateDeleteFilesParams(e) ) } protected doSend(event: FileWillDeleteEvent, next: (event: FileWillDeleteEvent) => Thenable | Thenable): Thenable | Thenable { const middleware = this._client.middleware.workspace return middleware?.willDeleteFiles ? middleware.willDeleteFiles(event, next) : next(event) } }