diff --git a/src/client/common/editor.ts b/src/client/common/editor.ts index f777de96e246..15927c59194c 100644 --- a/src/client/common/editor.ts +++ b/src/client/common/editor.ts @@ -1,10 +1,10 @@ import { Diff, diff_match_patch } from 'diff-match-patch'; -import * as fs from 'fs-extra'; import { injectable } from 'inversify'; import * as md5 from 'md5'; import { EOL } from 'os'; import * as path from 'path'; import { Position, Range, TextDocument, TextEdit, Uri, WorkspaceEdit } from 'vscode'; +import { IFileSystem } from './platform/types'; import { IEditorUtils } from './types'; // Code borrowed from goFormat.ts (Go Extension for VS Code) @@ -80,7 +80,11 @@ export function getTextEditsFromPatch(before: string, patch: string): TextEdit[] return textEdits; } -export function getWorkspaceEditsFromPatch(filePatches: string[], workspaceRoot?: string): WorkspaceEdit { +export function getWorkspaceEditsFromPatch( + filePatches: string[], + fs: IFileSystem, + workspaceRoot?: string +): WorkspaceEdit { const workspaceEdit = new WorkspaceEdit(); filePatches.forEach(patch => { const indexOfAtAt = patch.indexOf('@@'); @@ -107,7 +111,7 @@ export function getWorkspaceEditsFromPatch(filePatches: string[], workspaceRoot? let fileName = fileNameLines[0].substring(fileNameLines[0].indexOf(' a') + 3).trim(); fileName = workspaceRoot && !path.isAbsolute(fileName) ? path.resolve(workspaceRoot, fileName) : fileName; - if (!fs.existsSync(fileName)) { + if (!fs.fileExistsSync(fileName)) { return; } @@ -123,7 +127,7 @@ export function getWorkspaceEditsFromPatch(filePatches: string[], workspaceRoot? throw new Error('Unable to parse Patch string'); } - const fileSource = fs.readFileSync(fileName).toString('utf8'); + const fileSource = fs.readFileSync(fileName); const fileUri = Uri.file(fileName); // Add line feeds and build the text edits @@ -226,24 +230,25 @@ function getTextEditsInternal(before: string, diffs: [number, string][], startLi return edits; } -export function getTempFileWithDocumentContents(document: TextDocument): Promise { - return new Promise((resolve, reject) => { - const ext = path.extname(document.uri.fsPath); - // Don't create file in temp folder since external utilities - // look into configuration files in the workspace and are not able - // to find custom rules if file is saved in a random disk location. - // This means temp file has to be created in the same folder - // as the original one and then removed. - - // tslint:disable-next-line:no-require-imports - const fileName = `${document.uri.fsPath}.${md5(document.uri.fsPath)}${ext}`; - fs.writeFile(fileName, document.getText(), ex => { - if (ex) { - reject(`Failed to create a temporary file, ${ex.message}`); - } - resolve(fileName); - }); - }); +export async function getTempFileWithDocumentContents( + document: TextDocument, + fs: IFileSystem +): Promise { + // Don't create file in temp folder since external utilities + // look into configuration files in the workspace and are not able + // to find custom rules if file is saved in a random disk location. + // This means temp file has to be created in the same folder + // as the original one and then removed. + + const ext = path.extname(document.uri.fsPath); + const filename = `${document.uri.fsPath}.${md5(document.uri.fsPath)}${ext}`; + await ( + fs.writeFile(filename, document.getText()) + .catch(err => { + throw Error(`Failed to create a temporary file, ${err.message}`); + }) + ); + return filename; } /** diff --git a/src/client/common/envFileParser.ts b/src/client/common/envFileParser.ts deleted file mode 100644 index f1cfda52b430..000000000000 --- a/src/client/common/envFileParser.ts +++ /dev/null @@ -1,63 +0,0 @@ -import * as fs from 'fs-extra'; -import { IS_WINDOWS } from './platform/constants'; -import { PathUtils } from './platform/pathUtils'; -import { EnvironmentVariablesService } from './variables/environment'; -import { EnvironmentVariables } from './variables/types'; -function parseEnvironmentVariables(contents: string): EnvironmentVariables | undefined { - if (typeof contents !== 'string' || contents.length === 0) { - return undefined; - } - - const env: EnvironmentVariables = {}; - contents.split('\n').forEach(line => { - const match = line.match(/^\s*([\w\.\-]+)\s*=\s*(.*)?\s*$/); - if (match !== null) { - let value = typeof match[2] === 'string' ? match[2] : ''; - if (value.length > 0 && value.charAt(0) === '"' && value.charAt(value.length - 1) === '"') { - value = value.replace(/\\n/gm, '\n'); - } - env[match[1]] = value.replace(/(^['"]|['"]$)/g, ''); - } - }); - return env; -} - -export function parseEnvFile(envFile: string, mergeWithProcessEnvVars: boolean = true): EnvironmentVariables { - const buffer = fs.readFileSync(envFile, 'utf8'); - const env = parseEnvironmentVariables(buffer)!; - return mergeWithProcessEnvVars ? mergeEnvVariables(env, process.env) : mergePythonPath(env, process.env.PYTHONPATH as string); -} - -/** - * Merge the target environment variables into the source. - * Note: The source variables are modified and returned (i.e. it modifies value passed in). - * @export - * @param {EnvironmentVariables} targetEnvVars target environment variables. - * @param {EnvironmentVariables} [sourceEnvVars=process.env] source environment variables (defaults to current process variables). - * @returns {EnvironmentVariables} - */ -export function mergeEnvVariables(targetEnvVars: EnvironmentVariables, sourceEnvVars: EnvironmentVariables = process.env): EnvironmentVariables { - const service = new EnvironmentVariablesService(new PathUtils(IS_WINDOWS)); - service.mergeVariables(sourceEnvVars, targetEnvVars); - if (sourceEnvVars.PYTHONPATH) { - service.appendPythonPath(targetEnvVars, sourceEnvVars.PYTHONPATH); - } - return targetEnvVars; -} - -/** - * Merge the target PYTHONPATH value into the env variables passed. - * Note: The env variables passed in are modified and returned (i.e. it modifies value passed in). - * @export - * @param {EnvironmentVariables} env target environment variables. - * @param {string | undefined} [currentPythonPath] PYTHONPATH value. - * @returns {EnvironmentVariables} - */ -export function mergePythonPath(env: EnvironmentVariables, currentPythonPath: string | undefined): EnvironmentVariables { - if (typeof currentPythonPath !== 'string' || currentPythonPath.length === 0) { - return env; - } - const service = new EnvironmentVariablesService(new PathUtils(IS_WINDOWS)); - service.appendPythonPath(env, currentPythonPath); - return env; -} diff --git a/src/client/common/installer/moduleInstaller.ts b/src/client/common/installer/moduleInstaller.ts index 8a4c58a4857a..d3e13c5ea1b0 100644 --- a/src/client/common/installer/moduleInstaller.ts +++ b/src/client/common/installer/moduleInstaller.ts @@ -1,7 +1,6 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import * as fs from 'fs'; import { injectable } from 'inversify'; import * as path from 'path'; import * as vscode from 'vscode'; @@ -10,15 +9,18 @@ import { IServiceContainer } from '../../ioc/types'; import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; import { STANDARD_OUTPUT_CHANNEL } from '../constants'; +import { IFileSystem } from '../platform/types'; import { ITerminalServiceFactory } from '../terminal/types'; import { ExecutionInfo, IConfigurationService, IOutputChannel } from '../types'; -import { noop } from '../utils/misc'; @injectable() export abstract class ModuleInstaller { public abstract get name(): string; public abstract get displayName(): string - constructor(protected serviceContainer: IServiceContainer) { } + constructor( + protected serviceContainer: IServiceContainer + ) { } + public async installModule(name: string, resource?: vscode.Uri): Promise { sendTelemetryEvent(EventName.PYTHON_INSTALL_PACKAGE, undefined, { installer: this.displayName }); const executionInfo = await this.getExecutionInfo(name, resource); @@ -38,7 +40,10 @@ export abstract class ModuleInstaller { if (!currentInterpreter || currentInterpreter.type !== InterpreterType.Unknown) { await terminalService.sendCommand(pythonPath, args); } else if (settings.globalModuleInstallation) { - if (await this.isPathWritableAsync(path.dirname(pythonPath))) { + const dirname = path.dirname(pythonPath); + const fs = this.serviceContainer.get(IFileSystem); + const isWritable = ! await fs.isDirReadonly(dirname); + if (isWritable) { await terminalService.sendCommand(pythonPath, args); } else { this.elevatedInstall(pythonPath, args); @@ -50,8 +55,10 @@ export abstract class ModuleInstaller { await terminalService.sendCommand(executionInfo.execPath!, executionInfoArgs); } } + public abstract isSupported(resource?: vscode.Uri): Promise; protected abstract getExecutionInfo(moduleName: string, resource?: vscode.Uri): Promise; + private async processInstallArgs(args: string[], resource?: vscode.Uri): Promise { const indexOfPylint = args.findIndex(arg => arg.toUpperCase() === 'PYLINT'); if (indexOfPylint === -1) { @@ -69,19 +76,6 @@ export abstract class ModuleInstaller { } return args; } - private async isPathWritableAsync(directoryPath: string): Promise { - const filePath = `${directoryPath}${path.sep}___vscpTest___`; - return new Promise(resolve => { - fs.open(filePath, fs.constants.O_CREAT | fs.constants.O_RDWR, (error, fd) => { - if (!error) { - fs.close(fd, () => { - fs.unlink(filePath, noop); - }); - } - return resolve(!error); - }); - }); - } private elevatedInstall(execPath: string, args: string[]) { const options = { diff --git a/src/client/common/net/fileDownloader.ts b/src/client/common/net/fileDownloader.ts index 53162c965304..281538a47a5d 100644 --- a/src/client/common/net/fileDownloader.ts +++ b/src/client/common/net/fileDownloader.ts @@ -3,12 +3,11 @@ 'use strict'; -import { WriteStream } from 'fs'; import { inject, injectable } from 'inversify'; import * as requestTypes from 'request'; import { Progress, ProgressLocation } from 'vscode'; import { IApplicationShell } from '../application/types'; -import { IFileSystem } from '../platform/types'; +import { IFileSystem, WriteStream } from '../platform/types'; import { DownloadOptions, IFileDownloader, IHttpClient } from '../types'; import { Http } from '../utils/localize'; import { noop } from '../utils/misc'; diff --git a/src/client/common/platform/fileSystem.ts b/src/client/common/platform/fileSystem.ts index b1413e5b422d..25886aabc12d 100644 --- a/src/client/common/platform/fileSystem.ts +++ b/src/client/common/platform/fileSystem.ts @@ -1,207 +1,480 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. + 'use strict'; import { createHash } from 'crypto'; -import * as fileSystem from 'fs'; -import * as fs from 'fs-extra'; +import * as fs from 'fs'; +import * as fsextra from 'fs-extra'; import * as glob from 'glob'; -import { inject, injectable } from 'inversify'; -import * as path from 'path'; -import * as tmp from 'tmp'; -import { FileStat } from 'vscode'; +import { injectable } from 'inversify'; +import * as fspath from 'path'; +import * as tmpMod from 'tmp'; +import * as util from 'util'; +import * as vscode from 'vscode'; import { createDeferred } from '../utils/async'; -import { IFileSystem, IPlatformService, TemporaryFile } from './types'; +import { getOSType, OSType } from '../utils/platform'; +import { + FileStat, FileType, + IFileSystem, IFileSystemPaths, IFileSystemUtils, IRawFileSystem, + ITempFileSystem, + TemporaryFile, WriteStream +} from './types'; -@injectable() -export class FileSystem implements IFileSystem { - constructor(@inject(IPlatformService) private platformService: IPlatformService) {} +// tslint:disable:max-classes-per-file - public get directorySeparatorChar(): string { - return path.sep; +const ENCODING: string = 'utf8'; + +// Determine the file type from the given file info. +function getFileType(stat: FileStat): FileType { + if (stat.isFile()) { + return FileType.File; + } else if (stat.isDirectory()) { + return FileType.Directory; + } else if (stat.isSymbolicLink()) { + return FileType.SymbolicLink; + } else { + return FileType.Unknown; } - public async stat(filePath: string): Promise { - // Do not import vscode directly, as this isn't available in the Debugger Context. - // If stat is used in debugger context, it will fail, however theres a separate PR that will resolve this. - // tslint:disable-next-line: no-require-imports - const vscode = require('vscode'); - return vscode.workspace.fs.stat(vscode.Uri.file(filePath)); +} + +// The parts of node's 'path' module used by FileSystemPath. +interface INodePath { + join(...filenames: string[]): string; + normalize(filename: string): string; +} + +// Eventually we will merge PathUtils into FileSystemPath. + +// The file path operations used by the extension. +export class FileSystemPaths implements IFileSystemPaths { + constructor( + protected readonly isCaseSensitive: boolean, + protected readonly raw: INodePath + ) { } + // Create a new object using common-case default values. + // We do not use an alternate constructor because defaults in the + // constructor runs counter to our approach. + public static withDefaults(): FileSystemPaths { + return new FileSystemPaths( + (getOSType() === OSType.Windows), + fspath + ); + } + + public join(...filenames: string[]): string { + return this.raw.join(...filenames); + } + + public normCase(filename: string): string { + filename = this.raw.normalize(filename); + return this.isCaseSensitive ? filename.toUpperCase() : filename; } +} - public objectExists(filePath: string, statCheck: (s: fs.Stats) => boolean): Promise { - return new Promise(resolve => { - fs.stat(filePath, (error, stats) => { - if (error) { - return resolve(false); +//tslint:disable-next-line:no-any +type TempCallback = (err: any, path: string, fd: number, cleanupCallback: () => void) => void; +// The parts of the 'tmp' module used by TempFileSystem. +interface IRawTmp { + file(options: tmpMod.Options, cb: TempCallback): void; +} + +// The operations on temporary files/directoryes used by the extension. +export class TempFileSystem { + constructor( + protected readonly raw: IRawTmp + ) { } + // Create a new object using common-case default values. + public static withDefaults(): TempFileSystem { + return new TempFileSystem( + tmpMod + ); + } + + public async createFile(suffix?: string, dir?: string): Promise { + const options = { + postfix: suffix, + dir: dir + }; + // We could use util.promisify() here. The tmp.file() callback + // makes it a bit complicated though. + return new Promise((resolve, reject) => { + this.raw.file(options, (err, tmpFile, _fd, cleanupCallback) => { + if (err) { + return reject(err); } - return resolve(statCheck(stats)); + resolve({ + filePath: tmpFile, + dispose: cleanupCallback + }); }); }); } +} - public fileExists(filePath: string): Promise { - return this.objectExists(filePath, stats => stats.isFile()); - } - public fileExistsSync(filePath: string): boolean { - return fs.existsSync(filePath); - } - /** - * Reads the contents of the file using utf8 and returns the string contents. - * @param {string} filePath - * @returns {Promise} - * @memberof FileSystem - */ - public readFile(filePath: string): Promise { - return fs.readFile(filePath).then(buffer => buffer.toString()); +// This is the parts of node's 'fs' module that we use in RawFileSystem. +interface IRawFS { + // non-async + createWriteStream(filePath: string): fs.WriteStream; +} + +// This is the parts of the 'fs-extra' module that we use in RawFileSystem. +interface IRawFSExtra { + chmod(filePath: string, mode: string | number): Promise; + readFile(path: string, encoding: string): Promise; + //tslint:disable-next-line:no-any + writeFile(path: string, data: any, options: any): Promise; + unlink(filename: string): Promise; + stat(filename: string): Promise; + lstat(filename: string): Promise; + mkdirp(dirname: string): Promise; + rmdir(dirname: string): Promise; + readdir(dirname: string): Promise; + remove(dirname: string): Promise; + + // non-async + statSync(filename: string): fsextra.Stats; + readFileSync(path: string, encoding: string): string; + createReadStream(src: string): fsextra.ReadStream; + createWriteStream(dest: string): fsextra.WriteStream; +} + +// The parts of IFileSystemPaths used by RawFileSystem. +interface IRawPath { + join(...filenames: string[]): string; +} + +// Later we will drop "FileSystem", switching usage to +// "FileSystemUtils" and then rename "RawFileSystem" to "FileSystem". + +// The low-level filesystem operations used by the extension. +export class RawFileSystem implements IRawFileSystem { + constructor( + protected readonly path: IRawPath, + protected readonly nodefs: IRawFS, + protected readonly fsExtra: IRawFSExtra + ) { } + + // Create a new object using common-case default values. + public static withDefaults(): RawFileSystem{ + return new RawFileSystem( + FileSystemPaths.withDefaults(), + fs, + fsextra + ); } - public async writeFile(filePath: string, data: {}, options: string | fs.WriteFileOptions = { encoding: 'utf8' }): Promise { - await fs.writeFile(filePath, data, options); + //**************************** + // fs-extra + + public async readText(filename: string): Promise { + return this.fsExtra.readFile(filename, ENCODING); } - public directoryExists(filePath: string): Promise { - return this.objectExists(filePath, stats => stats.isDirectory()); + public async writeText(filename: string, data: {}): Promise { + const options: fsextra.WriteFileOptions = { + encoding: ENCODING + }; + await this.fsExtra.writeFile(filename, data, options); } - public createDirectory(directoryPath: string): Promise { - return fs.mkdirp(directoryPath); + public async mkdirp(dirname: string): Promise { + return this.fsExtra.mkdirp(dirname); } - public deleteDirectory(directoryPath: string): Promise { - const deferred = createDeferred(); - fs.rmdir(directoryPath, err => (err ? deferred.reject(err) : deferred.resolve())); - return deferred.promise; + public async rmtree(dirname: string): Promise { + return this.fsExtra.stat(dirname) + .then(() => this.fsExtra.remove(dirname)); } - public getSubDirectories(rootDir: string): Promise { - return new Promise(resolve => { - fs.readdir(rootDir, async (error, files) => { - if (error) { - return resolve([]); - } - const subDirs = (await Promise.all( - files.map(async name => { - const fullPath = path.join(rootDir, name); - try { - if ((await fs.stat(fullPath)).isDirectory()) { - return fullPath; - } - // tslint:disable-next-line:no-empty - } catch (ex) {} - }) - )).filter(dir => dir !== undefined) as string[]; - resolve(subDirs); - }); - }); + public async rmfile(filename: string): Promise { + return this.fsExtra.unlink(filename); } - public async getFiles(rootDir: string): Promise { - const files = await fs.readdir(rootDir); - return files.filter(async f => { - const fullPath = path.join(rootDir, f); - if ((await fs.stat(fullPath)).isFile()) { - return true; - } - return false; - }); + public async chmod(filename: string, mode: string | number): Promise { + return this.fsExtra.chmod(filename, mode); } - public arePathsSame(path1: string, path2: string): boolean { - path1 = path.normalize(path1); - path2 = path.normalize(path2); - if (this.platformService.isWindows) { - return path1.toUpperCase() === path2.toUpperCase(); - } else { - return path1 === path2; - } + public async stat(filename: string): Promise { + return this.fsExtra.stat(filename); } - public appendFileSync(filename: string, data: {}, encoding: string): void; - public appendFileSync(filename: string, data: {}, options?: { encoding?: string; mode?: number; flag?: string }): void; - // tslint:disable-next-line:unified-signatures - public appendFileSync(filename: string, data: {}, options?: { encoding?: string; mode?: string; flag?: string }): void; - public appendFileSync(filename: string, data: {}, optionsOrEncoding: {}): void { - return fs.appendFileSync(filename, data, optionsOrEncoding); + public async lstat(filename: string): Promise { + return this.fsExtra.lstat(filename); } - public getRealPath(filePath: string): Promise { - return new Promise(resolve => { - fs.realpath(filePath, (err, realPath) => { - resolve(err ? filePath : realPath); + // Once we move to the VS Code API, this method becomes a trivial wrapper. + public async listdir(dirname: string): Promise<[string, FileType][]> { + const names: string[] = await this.fsExtra.readdir(dirname); + const promises = names + .map(name => { + const filename = this.path.join(dirname, name); + return this.lstat(filename) + .then(stat => [name, getFileType(stat)] as [string, FileType]) + .catch(() => [name, FileType.Unknown] as [string, FileType]); }); - }); + return Promise.all(promises); } - public copyFile(src: string, dest: string): Promise { + // Once we move to the VS Code API, this method becomes a trivial wrapper. + public async copyFile(src: string, dest: string): Promise { const deferred = createDeferred(); - const rs = fs.createReadStream(src).on('error', err => { - deferred.reject(err); - }); - const ws = fs - .createWriteStream(dest) - .on('error', err => { + const rs = this.fsExtra.createReadStream(src) + .on('error', (err) => { deferred.reject(err); - }) - .on('close', () => { + }); + const ws = this.fsExtra.createWriteStream(dest) + .on('error', (err) => { + deferred.reject(err); + }).on('close', () => { deferred.resolve(); }); rs.pipe(ws); return deferred.promise; } - public deleteFile(filename: string): Promise { - const deferred = createDeferred(); - fs.unlink(filename, err => (err ? deferred.reject(err) : deferred.resolve())); - return deferred.promise; + //**************************** + // non-async (fs-extra) + + public statSync(filename: string): FileStat { + return this.fsExtra.statSync(filename); } - public getFileHash(filePath: string): Promise { - return new Promise((resolve, reject) => { - fs.lstat(filePath, (err, stats) => { - if (err) { - reject(err); - } else { - const actual = createHash('sha512') - .update(`${stats.ctimeMs}-${stats.mtimeMs}`) - .digest('hex'); - resolve(actual); - } - }); - }); + public readTextSync(filename: string): string { + return this.fsExtra.readFileSync(filename, ENCODING); } - public search(globPattern: string): Promise { - return new Promise((resolve, reject) => { - glob(globPattern, (ex, files) => { - if (ex) { - return reject(ex); - } - resolve(Array.isArray(files) ? files : []); - }); - }); + + //**************************** + // non-async (fs) + + public createWriteStream(filename: string): WriteStream { + return this.nodefs.createWriteStream(filename); } - public createTemporaryFile(extension: string): Promise { - return new Promise((resolve, reject) => { - tmp.file({ postfix: extension }, (err, tmpFile, _, cleanupCallback) => { - if (err) { - return reject(err); - } - resolve({ filePath: tmpFile, dispose: cleanupCallback }); - }); - }); +} + +// High-level filesystem operations used by the extension. +@injectable() +export class FileSystemUtils implements IFileSystemUtils { + constructor( + public readonly raw: IRawFileSystem, + public readonly path: IFileSystemPaths, + public readonly tmp: ITempFileSystem, + protected readonly getHash: (data: string) => string, + protected readonly globFile: (pat: string) => Promise + ) { } + // Create a new object using common-case default values. + public static withDefaults(): FileSystemUtils { + const paths = FileSystemPaths.withDefaults(); + return new FileSystemUtils( + new RawFileSystem(paths, fs, fsextra), + paths, + TempFileSystem.withDefaults(), + getHashString, + util.promisify(glob) + ); } - public createWriteStream(filePath: string): fileSystem.WriteStream { - return fileSystem.createWriteStream(filePath); + //**************************** + // aliases + + public async createDirectory(dirname: string): Promise { + return this.raw.mkdirp(dirname); } - public chmod(filePath: string, mode: string): Promise { - return new Promise((resolve, reject) => { - fileSystem.chmod(filePath, mode, (err: NodeJS.ErrnoException | null) => { - if (err) { - return reject(err); - } - resolve(); - }); - }); + public async deleteDirectory(dirname: string): Promise { + return this.raw.rmtree(dirname); + } + + public async deleteFile(filename: string): Promise { + return this.raw.rmfile(filename); + } + + //**************************** + // helpers + + public arePathsSame(path1: string, path2: string): boolean { + if (path1 === path2) { + return true; + } + path1 = this.path.normCase(path1); + path2 = this.path.normCase(path2); + return path1 === path2; + } + + public async pathExists( + filename: string, + fileType?: FileType + ): Promise { + let stat: FileStat; + try { + stat = await this.raw.stat(filename); + } catch (err) { + return false; + } + if (fileType === undefined) { + return true; + } else if (fileType === FileType.File) { + return stat.isFile(); + } else if (fileType === FileType.Directory) { + return stat.isDirectory(); + } else { + return false; + } + } + public async fileExists(filename: string): Promise { + return this.pathExists(filename, FileType.File); + } + public async directoryExists(dirname: string): Promise { + return this.pathExists(dirname, FileType.Directory); + } + + public async listdir(dirname: string): Promise<[string, FileType][]> { + try { + return await this.raw.listdir(dirname); + } catch { + return []; + } + } + public async getSubDirectories(dirname: string): Promise { + return (await this.listdir(dirname)) + .filter(([_name, fileType]) => fileType === FileType.Directory) + .map(([name, _fileType]) => this.path.join(dirname, name)); + } + public async getFiles(dirname: string): Promise { + return (await this.listdir(dirname)) + .filter(([_name, fileType]) => fileType === FileType.File) + .map(([name, _fileType]) => this.path.join(dirname, name)); + } + + public async isDirReadonly(dirname: string): Promise { + let tmpFile: TemporaryFile; + try { + tmpFile = await this.tmp.createFile('___vscpTest___', dirname); + } catch { + // Use a stat call to ensure the directory exists. + await this.raw.stat(dirname); + return true; + } + tmpFile.dispose(); + return false; + } + + public async getFileHash(filename: string): Promise { + const stat = await this.raw.lstat(filename); + const data = `${stat.ctimeMs}-${stat.mtimeMs}`; + return this.getHash(data); + } + + public async search(globPattern: string): Promise { + const files = await this.globFile(globPattern); + return Array.isArray(files) ? files : []; + } +} + +// We *could* use ICryptoUtils, but it's a bit overkill, issue tracked +// in https://github.com/microsoft/vscode-python/issues/8438. +function getHashString(data: string): string { + const hash = createHash('sha512') + .update(data); + return hash.digest('hex'); +} + +// more aliases (to cause less churn) +@injectable() +export class FileSystem implements IFileSystem { + private readonly utils: FileSystemUtils; + constructor() { + this.utils = FileSystemUtils.withDefaults(); + } + + //**************************** + // wrappers + + public async createDirectory(dirname: string): Promise { + return this.utils.createDirectory(dirname); + } + public async deleteDirectory(dirname: string): Promise { + return this.utils.deleteDirectory(dirname); + } + public async deleteFile(filename: string): Promise { + return this.utils.deleteFile(filename); + } + public arePathsSame(path1: string, path2: string): boolean { + return this.utils.arePathsSame(path1, path2); + } + public async pathExists(filename: string): Promise { + return this.utils.pathExists(filename); + } + public async fileExists(filename: string): Promise { + return this.utils.fileExists(filename); + } + public async directoryExists(dirname: string): Promise { + return this.utils.directoryExists(dirname); + } + public async listdir(dirname: string): Promise<[string, FileType][]> { + return this.utils.listdir(dirname); + } + public async getSubDirectories(dirname: string): Promise { + return this.utils.getSubDirectories(dirname); + } + public async getFiles(dirname: string): Promise { + return this.utils.getFiles(dirname); + } + public async isDirReadonly(dirname: string): Promise { + return this.utils.isDirReadonly(dirname); + } + public async getFileHash(filename: string): Promise { + return this.utils.getFileHash(filename); + } + public async search(globPattern: string): Promise { + return this.utils.search(globPattern); + } + + public fileExistsSync(filename: string): boolean { + try { + this.utils.raw.statSync(filename); + } catch { + return false; + } + return true; + } + + //**************************** + // aliases + + public async stat(filePath: string): Promise { + // Do not import vscode directly, as this isn't available in the Debugger Context. + // If stat is used in debugger context, it will fail, however theres a separate PR that will resolve this. + // tslint:disable-next-line: no-require-imports + const vsc = require('vscode'); + return vsc.workspace.fs.stat(vscode.Uri.file(filePath)); + } + + public async readFile(filename: string): Promise { + return this.utils.raw.readText(filename); + } + + public async writeFile(filename: string, data: {}): Promise { + return this.utils.raw.writeText(filename, data); + } + + public async chmod(filename: string, mode: string): Promise { + return this.utils.raw.chmod(filename, mode); + } + + public async copyFile(src: string, dest: string): Promise { + return this.utils.raw.copyFile(src, dest); + } + + public readFileSync(filename: string): string { + return this.utils.raw.readTextSync(filename); + } + + public createWriteStream(filename: string): WriteStream { + return this.utils.raw.createWriteStream(filename); + } + + public async createTemporaryFile(suffix: string): Promise { + return this.utils.tmp.createFile(suffix); } } diff --git a/src/client/common/platform/serviceRegistry.ts b/src/client/common/platform/serviceRegistry.ts index d15edf5fc388..c44422874a74 100644 --- a/src/client/common/platform/serviceRegistry.ts +++ b/src/client/common/platform/serviceRegistry.ts @@ -3,13 +3,14 @@ 'use strict'; import { IServiceManager } from '../../ioc/types'; -import { FileSystem } from './fileSystem'; +import { FileSystem, FileSystemUtils } from './fileSystem'; import { PlatformService } from './platformService'; import { RegistryImplementation } from './registry'; -import { IFileSystem, IPlatformService, IRegistry } from './types'; +import { IFileSystem, IFileSystemUtils, IPlatformService, IRegistry } from './types'; export function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IPlatformService, PlatformService); serviceManager.addSingleton(IFileSystem, FileSystem); + serviceManager.addSingletonInstance(IFileSystemUtils, FileSystemUtils.withDefaults()); serviceManager.addSingleton(IRegistry, RegistryImplementation); } diff --git a/src/client/common/platform/types.ts b/src/client/common/platform/types.ts index 586b5557a070..317540932e99 100644 --- a/src/client/common/platform/types.ts +++ b/src/client/common/platform/types.ts @@ -1,10 +1,12 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. +'use strict'; + import * as fs from 'fs'; import * as fsextra from 'fs-extra'; import { SemVer } from 'semver'; -import { Disposable, FileStat } from 'vscode'; +import * as vscode from 'vscode'; import { Architecture, OSType } from '../utils/platform'; export enum RegistryHive { @@ -32,34 +34,140 @@ export interface IPlatformService { getVersion(): Promise; } -export type TemporaryFile = { filePath: string } & Disposable; -export type TemporaryDirectory = { path: string } & Disposable; +export type TemporaryFile = vscode.Disposable & { + filePath: string; +}; +export type TemporaryDirectory = vscode.Disposable & { + path: string; +}; +export interface ITempFileSystem { + createFile(suffix?: string, dir?: string): Promise; +} + +// Eventually we will merge IPathUtils into IFileSystemPath. + +export interface IFileSystemPaths { + join(...filenames: string[]): string; + normCase(filename: string): string; +} + +export import FileType = vscode.FileType; +export type FileStat = fsextra.Stats; +export type WriteStream = fs.WriteStream; + +// Later we will drop "IFileSystem", switching usage to +// "IFileSystemUtils" and then rename "IRawFileSystem" to "IFileSystem". + +// The low-level filesystem operations on which the extension depends. +export interface IRawFileSystem { + // Get information about a file (resolve symlinks). + stat(filename: string): Promise; + // Get information about a file (do not resolve synlinks). + lstat(filename: string): Promise; + // Change a file's permissions. + chmod(filename: string, mode: string | number): Promise; + + //*********************** + // files + + // Return the text of the given file (decoded from UTF-8). + readText(filename: string): Promise; + // Write the given text to the file (UTF-8 encoded). + writeText(filename: string, data: {}): Promise; + // Copy a file. + copyFile(src: string, dest: string): Promise; + // Delete a file. + rmfile(filename: string): Promise; + + //*********************** + // directories + + // Create the directory and any missing parent directories. + mkdirp(dirname: string): Promise; + // Delete the directory and everything in it. + rmtree(dirname: string): Promise; + // Return the contents of the directory. + listdir(dirname: string): Promise<[string, FileType][]>; + + //*********************** + // not async + + // Get information about a file (resolve symlinks). + statSync(filename: string): FileStat; + // Return the text of the given file (decoded from UTF-8). + readTextSync(filename: string): string; + // Create a streaming wrappr around an open file. + createWriteStream(filename: string): WriteStream; +} + +// High-level filesystem operations used by the extension. +export const IFileSystemUtils = Symbol('IFileSystemUtils'); +export interface IFileSystemUtils { + readonly raw: IRawFileSystem; + readonly path: IFileSystemPaths; + readonly tmp: ITempFileSystem; + //*********************** + // aliases + + createDirectory(dirname: string): Promise; + deleteDirectory(dirname: string): Promise; + deleteFile(filename: string): Promise; + + //*********************** + // helpers + + // Determine if the file exists, optionally requiring the type. + pathExists(filename: string, fileType?: FileType): Promise; + // Determine if the regular file exists. + fileExists(filename: string): Promise; + // Determine if the directory exists. + directoryExists(dirname: string): Promise; + // Get the paths of all immediate subdirectories. + getSubDirectories(dirname: string): Promise; + // Get the paths of all immediately contained files. + getFiles(dirname: string): Promise; + // Determine if the directory is read-only. + isDirReadonly(dirname: string): Promise; + // Generate the sha512 hash for the file (based on timestamps). + getFileHash(filename: string): Promise; + // Get the paths of all files matching the pattern. + search(globPattern: string): Promise; + + //*********************** + // helpers (non-async) + + // Decide if the two filenames are equivalent. + arePathsSame(path1: string, path2: string): boolean; // Move to IPathUtils. +} + +// more aliases (to cause less churn) export const IFileSystem = Symbol('IFileSystem'); export interface IFileSystem { - directorySeparatorChar: string; - stat(filePath: string): Promise; - objectExists(path: string, statCheck: (s: fs.Stats) => boolean): Promise; - fileExists(path: string): Promise; - fileExistsSync(path: string): boolean; - directoryExists(path: string): Promise; - createDirectory(path: string): Promise; - deleteDirectory(path: string): Promise; - getSubDirectories(rootDir: string): Promise; - getFiles(rootDir: string): Promise; - arePathsSame(path1: string, path2: string): boolean; - readFile(filePath: string): Promise; - writeFile(filePath: string, data: {}, options?: string | fsextra.WriteFileOptions): Promise; - appendFileSync(filename: string, data: {}, encoding: string): void; - appendFileSync(filename: string, data: {}, options?: { encoding?: string; mode?: number; flag?: string }): void; - // tslint:disable-next-line:unified-signatures - appendFileSync(filename: string, data: {}, options?: { encoding?: string; mode?: string; flag?: string }): void; - getRealPath(path: string): Promise; - copyFile(src: string, dest: string): Promise; + createDirectory(dirname: string): Promise; + deleteDirectory(dirname: string): Promise; deleteFile(filename: string): Promise; - getFileHash(filePath: string): Promise; + pathExists(filename: string, fileType?: FileType): Promise; + fileExists(filename: string): Promise; + directoryExists(dirname: string): Promise; + getSubDirectories(dirname: string): Promise; + getFiles(dirname: string): Promise; + isDirReadonly(dirname: string): Promise; + getFileHash(filename: string): Promise; search(globPattern: string): Promise; - createTemporaryFile(extension: string): Promise; - createWriteStream(path: string): fs.WriteStream; - chmod(path: string, mode: string): Promise; + arePathsSame(path1: string, path2: string): boolean; + + stat(filePath: string): Promise; + readFile(filename: string): Promise; + writeFile(filename: string, data: {}): Promise; + chmod(filename: string, mode: string): Promise; + copyFile(src: string, dest: string): Promise; + createTemporaryFile(suffix: string): Promise; + + //*********************** + // non-async + + fileExistsSync(filename: string): boolean; + readFileSync(filename: string): string; + createWriteStream(filename: string): WriteStream; } diff --git a/src/client/common/utils/fs.ts b/src/client/common/utils/fs.ts deleted file mode 100644 index 4ab1e19686c8..000000000000 --- a/src/client/common/utils/fs.ts +++ /dev/null @@ -1,66 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -'use strict'; - -import * as fs from 'fs'; -import * as path from 'path'; -import * as tmp from 'tmp'; - -export function fsExistsAsync(filePath: string): Promise { - return new Promise(resolve => { - fs.exists(filePath, exists => { - return resolve(exists); - }); - }); -} -export function fsReaddirAsync(root: string): Promise { - return new Promise(resolve => { - // Now look for Interpreters in this directory - fs.readdir(root, (err, subDirs) => { - if (err) { - return resolve([]); - } - resolve(subDirs.map(subDir => path.join(root, subDir))); - }); - }); -} - -export function getSubDirectories(rootDir: string): Promise { - return new Promise(resolve => { - fs.readdir(rootDir, (error, files) => { - if (error) { - return resolve([]); - } - const subDirs: string[] = []; - files.forEach(name => { - const fullPath = path.join(rootDir, name); - try { - if (fs.statSync(fullPath).isDirectory()) { - subDirs.push(fullPath); - } - } - // tslint:disable-next-line:no-empty one-line - catch (ex) { } - }); - resolve(subDirs); - }); - }); -} - -export function createTemporaryFile(extension: string, temporaryDirectory?: string): Promise<{ filePath: string; cleanupCallback: Function }> { - // tslint:disable-next-line:no-any - const options: any = { postfix: extension }; - if (temporaryDirectory) { - options.dir = temporaryDirectory; - } - - return new Promise<{ filePath: string; cleanupCallback: Function }>((resolve, reject) => { - tmp.file(options, (err, tmpFile, _fd, cleanupCallback) => { - if (err) { - return reject(err); - } - resolve({ filePath: tmpFile, cleanupCallback: cleanupCallback }); - }); - }); -} diff --git a/src/client/common/utils/localize.ts b/src/client/common/utils/localize.ts index ba1648f0ef4e..a4ab229ace9a 100644 --- a/src/client/common/utils/localize.ts +++ b/src/client/common/utils/localize.ts @@ -3,9 +3,10 @@ 'use strict'; -import * as fs from 'fs'; import * as path from 'path'; import { EXTENSION_ROOT_DIR } from '../../constants'; +import { FileSystem } from '../platform/fileSystem'; +import { IFileSystem } from '../platform/types'; // External callers of localize use these tables to retrieve localized values. export namespace Diagnostics { @@ -490,14 +491,15 @@ function getString(key: string, defValue?: string) { return result; } -function load() { +function load(fs?: IFileSystem) { + fs = fs ? fs : new FileSystem(); // Figure out our current locale. loadedLocale = parseLocale(); // Find the nls file that matches (if there is one) const nlsFile = path.join(EXTENSION_ROOT_DIR, `package.nls.${loadedLocale}.json`); - if (fs.existsSync(nlsFile)) { - const contents = fs.readFileSync(nlsFile, 'utf8'); + if (fs.fileExistsSync(nlsFile)) { + const contents = fs.readFileSync(nlsFile); loadedCollection = JSON.parse(contents); } else { // If there isn't one, at least remember that we looked so we don't try to load a second time @@ -507,8 +509,8 @@ function load() { // Get the default collection if necessary. Strings may be in the default or the locale json if (!defaultCollection) { const defaultNlsFile = path.join(EXTENSION_ROOT_DIR, 'package.nls.json'); - if (fs.existsSync(defaultNlsFile)) { - const contents = fs.readFileSync(defaultNlsFile, 'utf8'); + if (fs.fileExistsSync(defaultNlsFile)) { + const contents = fs.readFileSync(defaultNlsFile); defaultCollection = JSON.parse(contents); } else { defaultCollection = {}; diff --git a/src/client/common/variables/environment.ts b/src/client/common/variables/environment.ts index 92921345a6f6..fd905ad83786 100644 --- a/src/client/common/variables/environment.ts +++ b/src/client/common/variables/environment.ts @@ -1,28 +1,37 @@ // Copyright (c) Microsoft Corporation. All rights reserved. // Licensed under the MIT License. -import * as fs from 'fs-extra'; import { inject, injectable } from 'inversify'; import * as path from 'path'; import { sendTelemetryEvent } from '../../telemetry'; import { EventName } from '../../telemetry/constants'; +import { IFileSystem } from '../platform/types'; import { IPathUtils } from '../types'; import { EnvironmentVariables, IEnvironmentVariablesService } from './types'; @injectable() export class EnvironmentVariablesService implements IEnvironmentVariablesService { private readonly pathVariable: 'PATH' | 'Path'; - constructor(@inject(IPathUtils) pathUtils: IPathUtils) { + constructor( + @inject(IPathUtils) pathUtils: IPathUtils, + @inject(IFileSystem) private readonly fs: IFileSystem + ) { this.pathVariable = pathUtils.getPathVariableName(); } - public async parseFile(filePath?: string, baseVars?: EnvironmentVariables): Promise { - if (!filePath || !await fs.pathExists(filePath)) { + public async parseFile( + filePath?: string, + baseVars?: EnvironmentVariables + ): Promise { + if (!filePath) { return; } - if (!fs.lstatSync(filePath).isFile()) { + if (!await this.fs.fileExists(filePath)) { return; } - return parseEnvFile(await fs.readFile(filePath), baseVars); + return parseEnvFile( + await this.fs.readFile(filePath), + baseVars + ); } public mergeVariables(source: EnvironmentVariables, target: EnvironmentVariables) { if (!target) { diff --git a/src/client/datascience/interactive-common/interactiveBase.ts b/src/client/datascience/interactive-common/interactiveBase.ts index 387279f507a6..477a064b0024 100644 --- a/src/client/datascience/interactive-common/interactiveBase.ts +++ b/src/client/datascience/interactive-common/interactiveBase.ts @@ -642,7 +642,7 @@ export abstract class InteractiveBase extends WebViewHost { diff --git a/src/client/datascience/interactive-ipynb/nativeEditor.ts b/src/client/datascience/interactive-ipynb/nativeEditor.ts index 83a98201383d..f848e7358bff 100644 --- a/src/client/datascience/interactive-ipynb/nativeEditor.ts +++ b/src/client/datascience/interactive-ipynb/nativeEditor.ts @@ -755,7 +755,7 @@ export class NativeEditor extends InteractiveBase implements INotebookEditor { tempFile = await this.fileSystem.createTemporaryFile('.ipynb'); // Translate the cells into a notebook - await this.fileSystem.writeFile(tempFile.filePath, await this.generateNotebookContent(cells), { encoding: 'utf-8' }); + await this.fileSystem.writeFile(tempFile.filePath, await this.generateNotebookContent(cells)); // Import this file and show it const contents = await this.importer.importFromFile(tempFile.filePath, this.file.fsPath); diff --git a/src/client/datascience/jupyter/kernelService.ts b/src/client/datascience/jupyter/kernelService.ts index 46130666232a..d2341b0f4a7c 100644 --- a/src/client/datascience/jupyter/kernelService.ts +++ b/src/client/datascience/jupyter/kernelService.ts @@ -133,7 +133,7 @@ export class KernelService { if (await this.fileSystem.fileExists(diskPath)) { const specModel: Kernel.ISpecModel = JSON.parse(await this.fileSystem.readFile(diskPath)); specModel.argv[0] = bestInterpreter.path; - await this.fileSystem.writeFile(diskPath, JSON.stringify(specModel), { flag: 'w', encoding: 'utf8' }); + await this.fileSystem.writeFile(diskPath, JSON.stringify(specModel)); } } } diff --git a/src/client/debugger/debugAdapter/main.ts b/src/client/debugger/debugAdapter/main.ts index 0403dc5c7905..c078ee92bb8e 100644 --- a/src/client/debugger/debugAdapter/main.ts +++ b/src/client/debugger/debugAdapter/main.ts @@ -9,6 +9,7 @@ if ((Reflect as any).metadata === undefined) { require('reflect-metadata'); } +import * as fsextra from 'fs-extra'; import { Socket } from 'net'; import { EOL } from 'os'; import * as path from 'path'; @@ -20,7 +21,6 @@ import { DebugProtocol } from 'vscode-debugprotocol'; import { EXTENSION_ROOT_DIR } from '../../common/constants'; import '../../common/extensions'; import { isNotInstalledError } from '../../common/helpers'; -import { IFileSystem } from '../../common/platform/types'; import { ICurrentProcess, IDisposable, IDisposableRegistry } from '../../common/types'; import { createDeferred, Deferred, sleep } from '../../common/utils/async'; import { noop } from '../../common/utils/misc'; @@ -47,7 +47,10 @@ export class PythonDebugger extends DebugSession { public debugServer?: BaseDebugServer; public client = createDeferred(); private supportsRunInTerminalRequest: boolean = false; - constructor(private readonly serviceContainer: IServiceContainer) { + constructor( + private readonly serviceContainer: IServiceContainer, + private readonly fileExistsSync = fsextra.existsSync + ) { super(false); } public shutdown(): void { @@ -106,8 +109,7 @@ export class PythonDebugger extends DebugSession { } protected launchRequest(response: DebugProtocol.LaunchResponse, args: LaunchRequestArguments): void { - const fs = this.serviceContainer.get(IFileSystem); - if ((typeof args.module !== 'string' || args.module.length === 0) && args.program && !fs.fileExistsSync(args.program)) { + if ((typeof args.module !== 'string' || args.module.length === 0) && args.program && !this.fileExistsSync(args.program)) { return this.sendErrorResponse(response, { format: `File does not exist. "${args.program}"`, id: 1 }, undefined, undefined, ErrorDestination.User); } diff --git a/src/client/debugger/debugAdapter/serviceRegistry.ts b/src/client/debugger/debugAdapter/serviceRegistry.ts index 2c015eb99297..0feccf1baa85 100644 --- a/src/client/debugger/debugAdapter/serviceRegistry.ts +++ b/src/client/debugger/debugAdapter/serviceRegistry.ts @@ -5,9 +5,6 @@ import { Container } from 'inversify'; import { SocketServer } from '../../common/net/socket/socketServer'; -import { FileSystem } from '../../common/platform/fileSystem'; -import { PlatformService } from '../../common/platform/platformService'; -import { IFileSystem, IPlatformService } from '../../common/platform/types'; import { CurrentProcess } from '../../common/process/currentProcess'; import { BufferDecoder } from '../../common/process/decoder'; import { IBufferDecoder, IProcessServiceFactory } from '../../common/process/types'; @@ -37,8 +34,6 @@ function registerTypes(serviceManager: IServiceManager) { serviceManager.addSingleton(IDebugStreamProvider, DebugStreamProvider); serviceManager.addSingleton(IProtocolLogger, ProtocolLogger); serviceManager.add(IProtocolParser, ProtocolParser); - serviceManager.addSingleton(IFileSystem, FileSystem); - serviceManager.addSingleton(IPlatformService, PlatformService); serviceManager.addSingleton(ISocketServer, SocketServer); serviceManager.addSingleton(IProtocolMessageWriter, ProtocolMessageWriter); serviceManager.addSingleton(IBufferDecoder, BufferDecoder); diff --git a/src/client/formatters/baseFormatter.ts b/src/client/formatters/baseFormatter.ts index bd84428f8481..a8a2112c9d89 100644 --- a/src/client/formatters/baseFormatter.ts +++ b/src/client/formatters/baseFormatter.ts @@ -1,4 +1,3 @@ -import * as fs from 'fs-extra'; import * as path from 'path'; import * as vscode from 'vscode'; import { IApplicationShell, IWorkspaceService } from '../common/application/types'; @@ -6,6 +5,7 @@ import { STANDARD_OUTPUT_CHANNEL } from '../common/constants'; import '../common/extensions'; import { isNotInstalledError } from '../common/helpers'; import { traceError } from '../common/logger'; +import { IFileSystem } from '../common/platform/types'; import { IPythonToolExecutionService } from '../common/process/types'; import { IDisposableRegistry, IInstaller, IOutputChannel, Product } from '../common/types'; import { IServiceContainer } from '../ioc/types'; @@ -15,11 +15,17 @@ import { IFormatterHelper } from './types'; export abstract class BaseFormatter { protected readonly outputChannel: vscode.OutputChannel; protected readonly workspace: IWorkspaceService; + private readonly fs: IFileSystem; private readonly helper: IFormatterHelper; - constructor(public Id: string, private product: Product, protected serviceContainer: IServiceContainer) { + constructor( + public Id: string, + private product: Product, + protected serviceContainer: IServiceContainer + ) { this.outputChannel = serviceContainer.get(IOutputChannel, STANDARD_OUTPUT_CHANNEL); this.helper = serviceContainer.get(IFormatterHelper); + this.fs = serviceContainer.get(IFileSystem); this.workspace = serviceContainer.get(IWorkspaceService); } @@ -103,13 +109,13 @@ export abstract class BaseFormatter { private async createTempFile(document: vscode.TextDocument): Promise { return document.isDirty - ? getTempFileWithDocumentContents(document) + ? getTempFileWithDocumentContents(document, this.fs) : document.fileName; } private deleteTempFile(originalFile: string, tempFile: string): Promise { if (originalFile !== tempFile) { - return fs.unlink(tempFile); + return this.fs.deleteFile(tempFile); } return Promise.resolve(); } diff --git a/src/client/interpreter/locators/helpers.ts b/src/client/interpreter/locators/helpers.ts index bf1a0a8424dd..47bf6dd6fd2d 100644 --- a/src/client/interpreter/locators/helpers.ts +++ b/src/client/interpreter/locators/helpers.ts @@ -2,20 +2,28 @@ import { inject, injectable } from 'inversify'; import * as path from 'path'; import { traceError } from '../../common/logger'; import { IS_WINDOWS } from '../../common/platform/constants'; +import { FileSystem } from '../../common/platform/fileSystem'; import { IFileSystem } from '../../common/platform/types'; -import { fsReaddirAsync } from '../../common/utils/fs'; import { IInterpreterLocatorHelper, InterpreterType, PythonInterpreter } from '../contracts'; import { IPipEnvServiceHelper } from './types'; const CheckPythonInterpreterRegEx = IS_WINDOWS ? /^python(\d+(.\d+)?)?\.exe$/ : /^python(\d+(.\d+)?)?$/; -export function lookForInterpretersInDirectory(pathToCheck: string): Promise { - return fsReaddirAsync(pathToCheck) - .then(subDirs => subDirs.filter(fileName => CheckPythonInterpreterRegEx.test(path.basename(fileName)))) - .catch(err => { - traceError('Python Extension (lookForInterpretersInDirectory.fsReaddirAsync):', err); - return [] as string[]; - }); +export async function lookForInterpretersInDirectory( + pathToCheck: string, + fs: IFileSystem = new FileSystem() +): Promise { + const files = await ( + fs.getFiles(pathToCheck) + .catch(err => { + traceError('Python Extension (lookForInterpretersInDirectory.fs.getFiles):', err); + return [] as string[]; + }) + ); + return files.filter(filename => { + const name = path.basename(filename); + return CheckPythonInterpreterRegEx.test(name); + }); } @injectable() diff --git a/src/client/interpreter/locators/services/KnownPathsService.ts b/src/client/interpreter/locators/services/KnownPathsService.ts index ee033e322bc7..1458d4a52ba9 100644 --- a/src/client/interpreter/locators/services/KnownPathsService.ts +++ b/src/client/interpreter/locators/services/KnownPathsService.ts @@ -2,9 +2,8 @@ import { inject, injectable } from 'inversify'; import * as path from 'path'; import { Uri } from 'vscode'; -import { IPlatformService } from '../../../common/platform/types'; +import { IFileSystem, IPlatformService } from '../../../common/platform/types'; import { ICurrentProcess, IPathUtils } from '../../../common/types'; -import { fsExistsAsync } from '../../../common/utils/fs'; import { IServiceContainer } from '../../../ioc/types'; import { IInterpreterHelper, IKnownSearchPathsForInterpreters, InterpreterType, PythonInterpreter } from '../../contracts'; import { lookForInterpretersInDirectory } from '../helpers'; @@ -16,12 +15,14 @@ const flatten = require('lodash/flatten') as typeof import('lodash/flatten'); */ @injectable() export class KnownPathsService extends CacheableLocatorService { + private readonly fs: IFileSystem; public constructor( @inject(IKnownSearchPathsForInterpreters) private knownSearchPaths: IKnownSearchPathsForInterpreters, @inject(IInterpreterHelper) private helper: IInterpreterHelper, @inject(IServiceContainer) serviceContainer: IServiceContainer ) { super('KnownPathsService', serviceContainer); + this.fs = serviceContainer.get(IFileSystem); } /** @@ -72,9 +73,12 @@ export class KnownPathsService extends CacheableLocatorService { /** * Return the interpreters in the given directory. */ - private getInterpretersInDirectory(dir: string) { - return fsExistsAsync(dir) - .then(exists => exists ? lookForInterpretersInDirectory(dir) : Promise.resolve([])); + private async getInterpretersInDirectory(dir: string): Promise { + if (await this.fs.fileExists(dir)) { + return lookForInterpretersInDirectory(dir, this.fs); + } else { + return []; + } } } diff --git a/src/client/interpreter/locators/services/baseVirtualEnvService.ts b/src/client/interpreter/locators/services/baseVirtualEnvService.ts index 4b46de4a2261..1dec370789fd 100644 --- a/src/client/interpreter/locators/services/baseVirtualEnvService.ts +++ b/src/client/interpreter/locators/services/baseVirtualEnvService.ts @@ -40,7 +40,7 @@ export class BaseVirtualEnvService extends CacheableLocatorService { return this.fileSystem.getSubDirectories(pathToCheck) .then(subDirs => Promise.all(this.getProspectiveDirectoriesForLookup(subDirs))) .then(dirs => dirs.filter(dir => dir.length > 0)) - .then(dirs => Promise.all(dirs.map(lookForInterpretersInDirectory))) + .then(dirs => Promise.all(dirs.map(d => lookForInterpretersInDirectory(d, this.fileSystem)))) .then(pathsWithInterpreters => flatten(pathsWithInterpreters)) .then(interpreters => Promise.all(interpreters.map(interpreter => this.getVirtualEnvDetails(interpreter, resource)))) .then(interpreters => interpreters.filter(interpreter => !!interpreter).map(interpreter => interpreter!)) diff --git a/src/client/interpreter/locators/services/windowsRegistryService.ts b/src/client/interpreter/locators/services/windowsRegistryService.ts index e99f29a1be66..70af04c649e8 100644 --- a/src/client/interpreter/locators/services/windowsRegistryService.ts +++ b/src/client/interpreter/locators/services/windowsRegistryService.ts @@ -1,10 +1,11 @@ // tslint:disable:no-require-imports no-var-requires underscore-consistent-invocation -import * as fs from 'fs-extra'; import { inject, injectable } from 'inversify'; import * as path from 'path'; import { Uri } from 'vscode'; import { traceError } from '../../../common/logger'; -import { IPlatformService, IRegistry, RegistryHive } from '../../../common/platform/types'; +import { + IFileSystem, IPlatformService, IRegistry, RegistryHive +} from '../../../common/platform/types'; import { IPathUtils } from '../../../common/types'; import { Architecture } from '../../../common/utils/platform'; import { parsePythonVersion } from '../../../common/utils/version'; @@ -38,7 +39,8 @@ export class WindowsRegistryService extends CacheableLocatorService { @inject(IRegistry) private registry: IRegistry, @inject(IPlatformService) private readonly platform: IPlatformService, @inject(IServiceContainer) serviceContainer: IServiceContainer, - @inject(WindowsStoreInterpreter) private readonly windowsStoreInterpreter: IWindowsStoreInterpreter + @inject(WindowsStoreInterpreter) private readonly windowsStoreInterpreter: IWindowsStoreInterpreter, + @inject(IFileSystem) private readonly fs: IFileSystem ) { super('WindowsRegistryService', serviceContainer); this.pathUtils = serviceContainer.get(IPathUtils); @@ -158,7 +160,7 @@ export class WindowsRegistryService extends CacheableLocatorService { }) .then(interpreter => interpreter - ? fs + ? this.fs .pathExists(interpreter.path) .catch(() => false) .then(exists => (exists ? interpreter : null)) diff --git a/src/client/providers/jediProxy.ts b/src/client/providers/jediProxy.ts index eea57fd58080..3e549192144c 100644 --- a/src/client/providers/jediProxy.ts +++ b/src/client/providers/jediProxy.ts @@ -3,7 +3,6 @@ // tslint:disable:no-var-requires no-require-imports no-any import { ChildProcess } from 'child_process'; -import * as fs from 'fs-extra'; import * as path from 'path'; // @ts-ignore import * as pidusage from 'pidusage'; @@ -11,6 +10,7 @@ import { CancellationToken, CancellationTokenSource, CompletionItemKind, Disposa import { isTestExecution } from '../common/constants'; import '../common/extensions'; import { IS_WINDOWS } from '../common/platform/constants'; +import { IFileSystem } from '../common/platform/types'; import { IPythonExecutionFactory } from '../common/process/types'; import { BANNER_NAME_PROPOSE_LS, IConfigurationService, ILogger, IPythonExtensionBanner, IPythonSettings } from '../common/types'; import { createDeferred, Deferred } from '../common/utils/async'; @@ -155,7 +155,11 @@ export class JediProxy implements Disposable { private readonly disposables: Disposable[] = []; private timer?: NodeJS.Timer | number; - public constructor(private extensionRootDir: string, workspacePath: string, private serviceContainer: IServiceContainer) { + public constructor( + private extensionRootDir: string, + workspacePath: string, + private serviceContainer: IServiceContainer + ) { this.workspacePath = workspacePath; const configurationService = serviceContainer.get(IConfigurationService); this.pythonSettings = configurationService.getSettings(Uri.file(workspacePath)); @@ -652,6 +656,7 @@ export class JediProxy implements Disposable { if (lines.length === 0) { return ''; } + const fs = this.serviceContainer.get(IFileSystem); const exists = await fs.pathExists(lines[0]); return exists ? lines[0] : ''; } catch { diff --git a/src/client/providers/renameProvider.ts b/src/client/providers/renameProvider.ts index a29de3281be6..481c804bf83d 100644 --- a/src/client/providers/renameProvider.ts +++ b/src/client/providers/renameProvider.ts @@ -6,6 +6,7 @@ import { import { EXTENSION_ROOT_DIR, STANDARD_OUTPUT_CHANNEL } from '../common/constants'; import { getWorkspaceEditsFromPatch } from '../common/editor'; import { traceError } from '../common/logger'; +import { IFileSystem } from '../common/platform/types'; import { IConfigurationService, IInstaller, IOutputChannel, Product } from '../common/types'; import { IServiceContainer } from '../ioc/types'; import { RefactorProxy } from '../refactor/proxy'; @@ -19,9 +20,13 @@ type RenameResponse = { export class PythonRenameProvider implements RenameProvider { private readonly outputChannel: OutputChannel; private readonly configurationService: IConfigurationService; - constructor(private serviceContainer: IServiceContainer) { + private readonly fs: IFileSystem; + constructor( + private serviceContainer: IServiceContainer + ) { this.outputChannel = serviceContainer.get(IOutputChannel, STANDARD_OUTPUT_CHANNEL); this.configurationService = serviceContainer.get(IConfigurationService); + this.fs = serviceContainer.get(IFileSystem); } @captureTelemetry(EventName.REFACTOR_RENAME) public provideRenameEdits(document: TextDocument, position: Position, newName: string, _token: CancellationToken): ProviderResult { @@ -57,7 +62,7 @@ export class PythonRenameProvider implements RenameProvider { const proxy = new RefactorProxy(EXTENSION_ROOT_DIR, pythonSettings, workspaceRoot, this.serviceContainer); return proxy.rename(document, newName, document.uri.fsPath, range).then(response => { const fileDiffs = response.results.map(fileChanges => fileChanges.diff); - return getWorkspaceEditsFromPatch(fileDiffs, workspaceRoot); + return getWorkspaceEditsFromPatch(fileDiffs, this.fs, workspaceRoot); }).catch(reason => { if (reason === 'Not installed') { const installer = this.serviceContainer.get(IInstaller); diff --git a/src/client/sourceMapSupport.ts b/src/client/sourceMapSupport.ts index 02907eaaf5ae..b878df21a217 100644 --- a/src/client/sourceMapSupport.ts +++ b/src/client/sourceMapSupport.ts @@ -59,6 +59,9 @@ export class SourceMapSupport { } } protected async rename(sourceFile: string, targetFile: string) { + // SourceMapSupport is initialized before the extension, so we + // do not have access to IFileSystem yet and have to use Node's + // "fs" directly. const fsExists = promisify(fs.exists); const fsRename = promisify(fs.rename); if (await fsExists(targetFile)) { diff --git a/src/client/testing/common/managers/testConfigurationManager.ts b/src/client/testing/common/managers/testConfigurationManager.ts index ca4d7ce43ebb..ad693d5b9a0a 100644 --- a/src/client/testing/common/managers/testConfigurationManager.ts +++ b/src/client/testing/common/managers/testConfigurationManager.ts @@ -1,9 +1,9 @@ import * as path from 'path'; import { OutputChannel, QuickPickItem, Uri } from 'vscode'; import { IApplicationShell } from '../../../common/application/types'; +import { IFileSystem } from '../../../common/platform/types'; import { IInstaller, ILogger, IOutputChannel } from '../../../common/types'; import { createDeferred } from '../../../common/utils/async'; -import { getSubDirectories } from '../../../common/utils/fs'; import { IServiceContainer } from '../../../ioc/types'; import { ITestConfigSettingsService, ITestConfigurationManager } from '../../types'; import { TEST_OUTPUT_CHANNEL, UNIT_TEST_PRODUCTS } from '../constants'; @@ -13,7 +13,10 @@ export abstract class TestConfigurationManager implements ITestConfigurationMana protected readonly outputChannel: OutputChannel; protected readonly installer: IInstaller; protected readonly testConfigSettingsService: ITestConfigSettingsService; - constructor(protected workspace: Uri, + private readonly fs: IFileSystem; + + constructor( + protected workspace: Uri, protected product: UnitTestProduct, protected readonly serviceContainer: IServiceContainer, cfg?: ITestConfigSettingsService @@ -21,9 +24,12 @@ export abstract class TestConfigurationManager implements ITestConfigurationMana this.outputChannel = serviceContainer.get(IOutputChannel, TEST_OUTPUT_CHANNEL); this.installer = serviceContainer.get(IInstaller); this.testConfigSettingsService = cfg ? cfg : serviceContainer.get(ITestConfigSettingsService); + this.fs = serviceContainer.get(IFileSystem); } + public abstract configure(wkspace: Uri): Promise; public abstract requiresUserToConfigure(wkspace: Uri): Promise; + public async enable() { // Disable other test frameworks. await Promise.all(UNIT_TEST_PRODUCTS @@ -101,7 +107,7 @@ export abstract class TestConfigurationManager implements ITestConfigurationMana return def.promise; } protected getTestDirs(rootDir: string): Promise { - return getSubDirectories(rootDir).then(subDirs => { + return this.fs.getSubDirectories(rootDir).then(subDirs => { subDirs.sort(); // Find out if there are any dirs with the name test and place them on the top. diff --git a/src/client/workspaceSymbols/parser.ts b/src/client/workspaceSymbols/parser.ts index 54bd50571360..3522e66c6a66 100644 --- a/src/client/workspaceSymbols/parser.ts +++ b/src/client/workspaceSymbols/parser.ts @@ -1,6 +1,7 @@ import * as path from 'path'; import * as vscode from 'vscode'; -import { fsExistsAsync } from '../common/utils/fs'; +import { FileSystem } from '../common/platform/fileSystem'; +import { IFileSystem } from '../common/platform/types'; import { ITag } from './contracts'; // tslint:disable:no-require-imports no-var-requires no-suspicious-comment @@ -107,9 +108,11 @@ export function parseTags( workspaceFolder: string, tagFile: string, query: string, - token: vscode.CancellationToken + token: vscode.CancellationToken, + fs?: IFileSystem ): Promise { - return fsExistsAsync(tagFile).then(exists => { + fs = fs ? fs : new FileSystem(); + return fs.fileExists(tagFile).then(exists => { if (!exists) { return Promise.resolve([]); } diff --git a/src/test/common/crypto.unit.test.ts b/src/test/common/crypto.unit.test.ts index 2d1ea5257cf9..dfa6f1801dcd 100644 --- a/src/test/common/crypto.unit.test.ts +++ b/src/test/common/crypto.unit.test.ts @@ -7,13 +7,12 @@ import { assert, expect } from 'chai'; import * as path from 'path'; import { CryptoUtils } from '../../client/common/crypto'; import { FileSystem } from '../../client/common/platform/fileSystem'; -import { PlatformService } from '../../client/common/platform/platformService'; import { EXTENSION_ROOT_DIR_FOR_TESTS } from '../constants'; // tslint:disable-next-line: max-func-body-length suite('Crypto Utils', async () => { let crypto: CryptoUtils; - const fs = new FileSystem(new PlatformService()); + const fs = new FileSystem(); const file = path.join(EXTENSION_ROOT_DIR_FOR_TESTS, 'src', 'test', 'common', 'randomWords.txt'); setup(() => { crypto = new CryptoUtils(); diff --git a/src/test/common/net/fileDownloader.unit.test.ts b/src/test/common/net/fileDownloader.unit.test.ts index d3b31e1ffc86..c82ddd023cc2 100644 --- a/src/test/common/net/fileDownloader.unit.test.ts +++ b/src/test/common/net/fileDownloader.unit.test.ts @@ -95,7 +95,7 @@ suite('File Downloader', () => { httpClient = mock(HttpClient); appShell = mock(ApplicationShell); when(httpClient.downloadFile(anything())).thenCall(request); - fs = new FileSystem(new PlatformService()); + fs = new FileSystem(); }); teardown(() => { rewiremock.disable(); diff --git a/src/test/common/platform/filesystem.functional.test.ts b/src/test/common/platform/filesystem.functional.test.ts new file mode 100644 index 000000000000..24419c7369d8 --- /dev/null +++ b/src/test/common/platform/filesystem.functional.test.ts @@ -0,0 +1,1187 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +// tslint:disable:no-require-imports no-var-requires max-func-body-length chai-vague-errors + +import { expect, use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import * as fsextra from 'fs-extra'; +import * as net from 'net'; +import * as path from 'path'; +import * as tmpMod from 'tmp'; +import { + FileSystem, FileSystemPaths, FileSystemUtils, RawFileSystem, + TempFileSystem +} from '../../../client/common/platform/fileSystem'; +import { + FileType, + IFileSystemPaths, IFileSystemUtils, IRawFileSystem, ITempFileSystem, + TemporaryFile +} from '../../../client/common/platform/types'; +import { sleep } from '../../../client/common/utils/async'; + +const assertArrays = require('chai-arrays'); +use(assertArrays); +use(chaiAsPromised); + +const WINDOWS = /^win/.test(process.platform); + +const DOES_NOT_EXIST = 'this file does not exist'; + +async function assertDoesNotExist(filename: string) { + await expect( + fsextra.stat(filename) + ).to.eventually.be.rejected; +} + +async function assertExists(filename: string) { + await expect( + fsextra.stat(filename) + ).to.not.eventually.be.rejected; +} + +class FSFixture { + public tempDir: tmpMod.SynchrounousResult | undefined; + public sockServer: net.Server | undefined; + + public async cleanUp() { + if (this.tempDir) { + const tempDir = this.tempDir; + this.tempDir = undefined; + tempDir.removeCallback(); + } + if (this.sockServer) { + const srv = this.sockServer; + await new Promise(resolve => srv.close(resolve)); + this.sockServer = undefined; + } + } + + public async resolve(relname: string, mkdirs = true): Promise { + if (!this.tempDir) { + this.tempDir = tmpMod.dirSync({ + prefix: 'pyvsc-fs-tests-', + unsafeCleanup: true + }); + } + relname = path.normalize(relname); + const filename = path.join(this.tempDir.name, relname); + if (mkdirs) { + await fsextra.mkdirp( + path.dirname(filename)); + } + return filename; + } + + public async createFile(relname: string, text = ''): Promise { + const filename = await this.resolve(relname); + await fsextra.writeFile(filename, text); + return filename; + } + + public async createDirectory(relname: string): Promise { + const dirname = await this.resolve(relname); + await fsextra.mkdir(dirname); + return dirname; + } + + public async createSymlink(relname: string, source: string): Promise { + const symlink = await this.resolve(relname); + await fsextra.ensureSymlink(source, symlink); + return symlink; + } + + public async createSocket(relname: string): Promise { + if (!this.sockServer) { + this.sockServer = net.createServer(); + } + const srv = this.sockServer!; + const filename = await this.resolve(relname); + await new Promise(resolve => srv!.listen(filename, 0, resolve)); + return filename; + } +} + +suite('FileSystem - Temporary files', () => { + let tmp: ITempFileSystem; + setup(() => { + tmp = TempFileSystem.withDefaults(); + }); + + suite('createFile', () => { + test('TemporaryFile is populated properly', async () => { + const tempfile = await tmp.createFile('.tmp'); + await assertExists(tempfile.filePath); + tempfile.dispose(); + + await assertDoesNotExist(tempfile.filePath); + expect(tempfile.filePath.endsWith('.tmp')).to.equal(true, `bad suffix on ${tempfile.filePath}`); + }); + + test('fails if the target temp directory does not exist', async () => { + const promise = tmp.createFile('.tmp', DOES_NOT_EXIST); + + await expect(promise).to.eventually.be.rejected; + }); + }); +}); + +suite('FileSystem paths', () => { + let fspath: IFileSystemPaths; + setup(() => { + fspath = FileSystemPaths.withDefaults(); + }); + + suite('join', () => { + test('parts get joined by path.sep', () => { + const expected = path.join('x', 'y', 'z', 'spam.py'); + + const result = fspath.join( + 'x', + path.sep === '\\' ? 'y\\z' : 'y/z', + 'spam.py' + ); + + expect(result).to.equal(expected); + }); + }); + + suite('normCase', () => { + test('forward-slash', () => { + const filename = 'X/Y/Z/SPAM.PY'; + const expected = WINDOWS ? 'X\\Y\\Z\\SPAM.PY' : filename; + + const result = fspath.normCase(filename); + + expect(result).to.equal(expected); + }); + + test('backslash is not changed', () => { + const filename = 'X\\Y\\Z\\SPAM.PY'; + const expected = filename; + + const result = fspath.normCase(filename); + + expect(result).to.equal(expected); + }); + + test('lower-case', () => { + const filename = 'x\\y\\z\\spam.py'; + const expected = WINDOWS ? 'X\\Y\\Z\\SPAM.PY' : filename; + + const result = fspath.normCase(filename); + + expect(result).to.equal(expected); + }); + + test('upper-case stays upper-case', () => { + const filename = 'X\\Y\\Z\\SPAM.PY'; + const expected = 'X\\Y\\Z\\SPAM.PY'; + + const result = fspath.normCase(filename); + + expect(result).to.equal(expected); + }); + }); +}); + +suite('Raw FileSystem', () => { + let filesystem: IRawFileSystem; + let fix: FSFixture; + setup(() => { + filesystem = RawFileSystem.withDefaults(); + fix = new FSFixture(); + }); + teardown(async () => { + await fix.cleanUp(); + }); + + suite('readText', () => { + test('returns contents of a file', async () => { + const expected = ''; + const filename = await fix.createFile('x/y/z/spam.py', expected); + + const content = await filesystem.readText(filename); + + expect(content).to.be.equal(expected); + }); + + test('always UTF-8', async () => { + const expected = '... 😁 ...'; + const filename = await fix.createFile('x/y/z/spam.py', expected); + + const text = await filesystem.readText(filename); + + expect(text).to.equal(expected); + }); + + test('returns garbage if encoding is UCS-2', async () => { + const filename = await fix.resolve('spam.py'); + // There are probably cases where this would fail too. + // However, the extension never has to deal with non-UTF8 + // cases, so it doesn't matter too much. + const original = '... 😁 ...'; + await fsextra.writeFile(filename, original, { encoding: 'ucs2' }); + + const text = await filesystem.readText(filename); + + expect(text).to.equal('.\u0000.\u0000.\u0000 \u0000=�\u0001� \u0000.\u0000.\u0000.\u0000'); + }); + + test('throws an exception if file does not exist', async () => { + const promise = filesystem.readText(DOES_NOT_EXIST); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('writeText', () => { + test('creates the file if missing', async () => { + const filename = await fix.resolve('x/y/z/spam.py'); + await assertDoesNotExist(filename); + const data = 'line1\nline2\n'; + + await filesystem.writeText(filename, data); + + const actual = await fsextra.readFile(filename) + .then(buffer => buffer.toString()); + expect(actual).to.equal(data); + }); + + test('always UTF-8', async () => { + const filename = await fix.resolve('x/y/z/spam.py'); + const data = '... 😁 ...'; + + await filesystem.writeText(filename, data); + + const actual = await fsextra.readFile(filename) + .then(buffer => buffer.toString()); + expect(actual).to.equal(data); + }); + + test('overwrites existing file', async () => { + const filename = await fix.createFile('x/y/z/spam.py', '...'); + const data = 'line1\nline2\n'; + + await filesystem.writeText(filename, data); + + const actual = await fsextra.readFile(filename) + .then(buffer => buffer.toString()); + expect(actual).to.equal(data); + }); + }); + + suite('mkdirp', () => { + test('creates the directory and all missing parents', async () => { + await fix.createDirectory('x'); + // x/y, x/y/z, and x/y/z/spam are all missing. + const dirname = await fix.resolve('x/y/z/spam', false); + await assertDoesNotExist(dirname); + + await filesystem.mkdirp(dirname); + + await assertExists(dirname); + }); + + test('works if the directory already exists', async () => { + const dirname = await fix.createDirectory('spam'); + await assertExists(dirname); + + await filesystem.mkdirp(dirname); + + await assertExists(dirname); + }); + }); + + suite('rmtree', () => { + test('deletes the directory and everything in it', async () => { + const dirname = await fix.createDirectory('x'); + const filename = await fix.createFile('x/y/z/spam.py'); + await assertExists(filename); + + await filesystem.rmtree(dirname); + + await assertDoesNotExist(dirname); + }); + + test('fails if the directory does not exist', async () => { + const promise = filesystem.rmtree(DOES_NOT_EXIST); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('rmfile', () => { + test('deletes the file', async () => { + const filename = await fix.createFile('x/y/z/spam.py', '...'); + await assertExists(filename); + + await filesystem.rmfile(filename); + + await assertDoesNotExist(filename); + }); + + test('fails if the file does not exist', async () => { + const promise = filesystem.rmfile(DOES_NOT_EXIST); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('chmod (non-Windows)', () => { + suiteSetup(function () { + // On Windows, chmod won't have any effect on the file itself. + if (WINDOWS) { + // tslint:disable-next-line:no-invalid-this + this.skip(); + } + }); + + async function checkMode(filename: string, expected: number) { + const stat = await fsextra.stat(filename); + expect(stat.mode & 0o777).to.equal(expected); + } + + test('the file mode gets updated (string)', async () => { + const filename = await fix.createFile('spam.py', '...'); + await fsextra.chmod(filename, 0o644); + + await filesystem.chmod(filename, '755'); + + await checkMode(filename, 0o755); + }); + + test('the file mode gets updated (number)', async () => { + const filename = await fix.createFile('spam.py', '...'); + await fsextra.chmod(filename, 0o644); + + await filesystem.chmod(filename, 0o755); + + await checkMode(filename, 0o755); + }); + + test('the file mode gets updated for a directory', async () => { + const dirname = await fix.createDirectory('spam'); + await fsextra.chmod(dirname, 0o755); + + await filesystem.chmod(dirname, 0o700); + + await checkMode(dirname, 0o700); + }); + + test('nothing happens if the file mode already matches', async () => { + const filename = await fix.createFile('spam.py', '...'); + await fsextra.chmod(filename, 0o644); + + await filesystem.chmod(filename, 0o644); + + await checkMode(filename, 0o644); + }); + + test('fails if the file does not exist', async () => { + const promise = filesystem.chmod(DOES_NOT_EXIST, 0o755); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('stat', () => { + test('gets the info for an existing file', async () => { + const filename = await fix.createFile('x/y/z/spam.py', '...'); + const expected = await fsextra.stat(filename); + + const stat = await filesystem.stat(filename); + + expect(stat).to.deep.equal(expected); + }); + + test('gets the info for an existing directory', async () => { + const dirname = await fix.createDirectory('x/y/z/spam'); + const expected = await fsextra.stat(dirname); + + const stat = await filesystem.stat(dirname); + + expect(stat).to.deep.equal(expected); + }); + + test('for symlinks, gets the info for the linked file', async () => { + const filename = await fix.createFile('x/y/z/spam.py', '...'); + const symlink = await fix.createSymlink('x/y/z/eggs.py', filename); + const expected = await fsextra.stat(filename); + + const stat = await filesystem.stat(symlink); + + expect(stat).to.deep.equal(expected); + }); + + test('fails if the file does not exist', async () => { + const promise = filesystem.stat(DOES_NOT_EXIST); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('lstat', () => { + test('for symlinks, gives the link info', async () => { + const filename = await fix.createFile('x/y/z/spam.py', '...'); + const symlink = await fix.createSymlink('x/y/z/eggs.py', filename); + const expected = await fsextra.lstat(symlink); + + const stat = await filesystem.lstat(symlink); + + expect(stat).to.deep.equal(expected); + }); + + test('for normal files, gives the file info', async () => { + const filename = await fix.createFile('x/y/z/spam.py', '...'); + const expected = await fsextra.stat(filename); + + const stat = await filesystem.lstat(filename); + + expect(stat).to.deep.equal(expected); + }); + + test('fails if the file does not exist', async () => { + const promise = filesystem.lstat(DOES_NOT_EXIST); + + await expect(promise).to.eventually.be.rejected; + }); + }); + + suite('listdir', () => { + test('mixed', async () => { + // Create the target directory and its contents. + const dirname = await fix.createDirectory('x/y/z'); + await fix.createFile('x/y/z/__init__.py', ''); + const script = await fix.createFile('x/y/z/__main__.py', '