diff --git a/Jakefile.js b/Jakefile.js index 8a4c67ac84bc4..1db208c73ff82 100644 --- a/Jakefile.js +++ b/Jakefile.js @@ -93,6 +93,7 @@ var typesMapOutputPath = path.join(builtLocalDirectory, 'typesMap.json'); var harnessCoreSources = [ "harness.ts", "virtualFileSystem.ts", + "virtualFileSystemWithWatch.ts", "sourceMapRecorder.ts", "harnessLanguageService.ts", "fourslash.ts", @@ -125,7 +126,6 @@ var harnessSources = harnessCoreSources.concat([ "transpile.ts", "reuseProgramStructure.ts", "textStorage.ts", - "cachingInServerLSHost.ts", "moduleResolution.ts", "tsconfigParsing.ts", "commandLineParsing.ts", @@ -133,6 +133,7 @@ var harnessSources = harnessCoreSources.concat([ "convertCompilerOptionsFromJson.ts", "convertTypeAcquisitionFromJson.ts", "tsserverProjectSystem.ts", + "tscWatchMode.ts", "compileOnSave.ts", "typingsInstaller.ts", "projectErrors.ts", @@ -157,7 +158,6 @@ var harnessSources = harnessCoreSources.concat([ "utilities.ts", "scriptVersionCache.ts", "scriptInfo.ts", - "lsHost.ts", "project.ts", "typingsCache.ts", "editorServices.ts", diff --git a/src/compiler/builder.ts b/src/compiler/builder.ts new file mode 100644 index 0000000000000..38969b85bacd0 --- /dev/null +++ b/src/compiler/builder.ts @@ -0,0 +1,524 @@ +/// + +namespace ts { + export interface EmitOutput { + outputFiles: OutputFile[]; + emitSkipped: boolean; + } + + export interface EmitOutputDetailed extends EmitOutput { + diagnostics: Diagnostic[]; + sourceMaps: SourceMapData[]; + emittedSourceFiles: SourceFile[]; + } + + export interface OutputFile { + name: string; + writeByteOrderMark: boolean; + text: string; + } + + export function getFileEmitOutput(program: Program, sourceFile: SourceFile, emitOnlyDtsFiles: boolean, isDetailed: boolean, + cancellationToken?: CancellationToken, customTransformers?: CustomTransformers): EmitOutput | EmitOutputDetailed { + const outputFiles: OutputFile[] = []; + let emittedSourceFiles: SourceFile[]; + const emitResult = program.emit(sourceFile, writeFile, cancellationToken, emitOnlyDtsFiles, customTransformers); + if (!isDetailed) { + return { outputFiles, emitSkipped: emitResult.emitSkipped }; + } + + return { + outputFiles, + emitSkipped: emitResult.emitSkipped, + diagnostics: emitResult.diagnostics, + sourceMaps: emitResult.sourceMaps, + emittedSourceFiles + }; + + function writeFile(fileName: string, text: string, writeByteOrderMark: boolean, _onError: (message: string) => void, sourceFiles: SourceFile[]) { + outputFiles.push({ name: fileName, writeByteOrderMark, text }); + if (isDetailed) { + emittedSourceFiles = addRange(emittedSourceFiles, sourceFiles); + } + } + } +} + +/* @internal */ +namespace ts { + export interface Builder { + /** + * Call this to feed new program + */ + updateProgram(newProgram: Program): void; + getFilesAffectedBy(program: Program, path: Path): string[]; + emitFile(program: Program, path: Path): EmitOutput; + + /** Emit the changed files and clear the cache of the changed files */ + emitChangedFiles(program: Program): EmitOutputDetailed[]; + /** When called gets the semantic diagnostics for the program. It also caches the diagnostics and manage them */ + getSemanticDiagnostics(program: Program, cancellationToken?: CancellationToken): Diagnostic[]; + + /** Called to reset the status of the builder */ + clear(): void; + } + + interface EmitHandler { + /** + * Called when sourceFile is added to the program + */ + onAddSourceFile(program: Program, sourceFile: SourceFile): void; + /** + * Called when sourceFile is removed from the program + */ + onRemoveSourceFile(path: Path): void; + /** + * Called when sourceFile is changed + */ + onUpdateSourceFile(program: Program, sourceFile: SourceFile): void; + /** + * Called when source file has not changed but has some of the resolutions invalidated + * If returned true, builder will mark the file as changed (noting that something associated with file has changed) + */ + onUpdateSourceFileWithSameVersion(program: Program, sourceFile: SourceFile): boolean; + /** + * Gets the files affected by the script info which has updated shape from the known one + */ + getFilesAffectedByUpdatedShape(program: Program, sourceFile: SourceFile, singleFileResult: string[]): string[]; + } + + interface FileInfo { + fileName: string; + version: string; + signature: string; + } + + export function createBuilder( + getCanonicalFileName: (fileName: string) => string, + getEmitOutput: (program: Program, sourceFile: SourceFile, emitOnlyDtsFiles: boolean, isDetailed: boolean) => EmitOutput | EmitOutputDetailed, + computeHash: (data: string) => string, + shouldEmitFile: (sourceFile: SourceFile) => boolean + ): Builder { + let isModuleEmit: boolean | undefined; + const fileInfos = createMap(); + const semanticDiagnosticsPerFile = createMap>(); + /** The map has key by source file's path that has been changed */ + const changedFileNames = createMap(); + let emitHandler: EmitHandler; + return { + updateProgram, + getFilesAffectedBy, + emitFile, + emitChangedFiles, + getSemanticDiagnostics, + clear + }; + + function createProgramGraph(program: Program) { + const currentIsModuleEmit = program.getCompilerOptions().module !== ModuleKind.None; + if (isModuleEmit !== currentIsModuleEmit) { + isModuleEmit = currentIsModuleEmit; + emitHandler = isModuleEmit ? getModuleEmitHandler() : getNonModuleEmitHandler(); + fileInfos.clear(); + semanticDiagnosticsPerFile.clear(); + } + mutateMap( + fileInfos, + arrayToMap(program.getSourceFiles(), sourceFile => sourceFile.path), + { + // Add new file info + createNewValue: (_path, sourceFile) => addNewFileInfo(program, sourceFile), + // Remove existing file info + onDeleteValue: removeExistingFileInfo, + // We will update in place instead of deleting existing value and adding new one + onExistingValue: (existingInfo, sourceFile) => updateExistingFileInfo(program, existingInfo, sourceFile) + } + ); + } + + function registerChangedFile(path: Path, fileName: string) { + changedFileNames.set(path, fileName); + // All changed files need to re-evaluate its semantic diagnostics + semanticDiagnosticsPerFile.delete(path); + } + + function addNewFileInfo(program: Program, sourceFile: SourceFile): FileInfo { + registerChangedFile(sourceFile.path, sourceFile.fileName); + emitHandler.onAddSourceFile(program, sourceFile); + return { fileName: sourceFile.fileName, version: sourceFile.version, signature: undefined }; + } + + function removeExistingFileInfo(existingFileInfo: FileInfo, path: Path) { + registerChangedFile(path, existingFileInfo.fileName); + emitHandler.onRemoveSourceFile(path); + } + + function updateExistingFileInfo(program: Program, existingInfo: FileInfo, sourceFile: SourceFile) { + if (existingInfo.version !== sourceFile.version) { + registerChangedFile(sourceFile.path, sourceFile.fileName); + existingInfo.version = sourceFile.version; + emitHandler.onUpdateSourceFile(program, sourceFile); + } + else if (program.hasInvalidatedResolution(sourceFile.path) && + emitHandler.onUpdateSourceFileWithSameVersion(program, sourceFile)) { + registerChangedFile(sourceFile.path, sourceFile.fileName); + } + } + + function ensureProgramGraph(program: Program) { + if (!emitHandler) { + createProgramGraph(program); + } + } + + function updateProgram(newProgram: Program) { + if (emitHandler) { + createProgramGraph(newProgram); + } + } + + function getFilesAffectedBy(program: Program, path: Path): string[] { + ensureProgramGraph(program); + + const sourceFile = program.getSourceFile(path); + const singleFileResult = sourceFile && shouldEmitFile(sourceFile) ? [sourceFile.fileName] : []; + const info = fileInfos.get(path); + if (!info || !updateShapeSignature(program, sourceFile, info)) { + return singleFileResult; + } + + Debug.assert(!!sourceFile); + return emitHandler.getFilesAffectedByUpdatedShape(program, sourceFile, singleFileResult); + } + + function emitFile(program: Program, path: Path) { + ensureProgramGraph(program); + if (!fileInfos.has(path)) { + return { outputFiles: [], emitSkipped: true }; + } + + return getEmitOutput(program, program.getSourceFileByPath(path), /*emitOnlyDtsFiles*/ false, /*isDetailed*/ false); + } + + function enumerateChangedFilesSet( + program: Program, + onChangedFile: (fileName: string, path: Path) => void, + onAffectedFile: (fileName: string, sourceFile: SourceFile) => void + ) { + changedFileNames.forEach((fileName, path) => { + onChangedFile(fileName, path as Path); + const affectedFiles = getFilesAffectedBy(program, path as Path); + for (const file of affectedFiles) { + onAffectedFile(file, program.getSourceFile(file)); + } + }); + } + + function enumerateChangedFilesEmitOutput( + program: Program, + emitOnlyDtsFiles: boolean, + onChangedFile: (fileName: string, path: Path) => void, + onEmitOutput: (emitOutput: EmitOutputDetailed, sourceFile: SourceFile) => void + ) { + const seenFiles = createMap(); + enumerateChangedFilesSet(program, onChangedFile, (fileName, sourceFile) => { + if (!seenFiles.has(fileName)) { + seenFiles.set(fileName, sourceFile); + if (sourceFile) { + // Any affected file shouldnt have the cached diagnostics + semanticDiagnosticsPerFile.delete(sourceFile.path); + + const emitOutput = getEmitOutput(program, sourceFile, emitOnlyDtsFiles, /*isDetailed*/ true) as EmitOutputDetailed; + onEmitOutput(emitOutput, sourceFile); + + // mark all the emitted source files as seen + if (emitOutput.emittedSourceFiles) { + for (const file of emitOutput.emittedSourceFiles) { + seenFiles.set(file.fileName, file); + } + } + } + } + }); + } + + function emitChangedFiles(program: Program): EmitOutputDetailed[] { + ensureProgramGraph(program); + const result: EmitOutputDetailed[] = []; + enumerateChangedFilesEmitOutput(program, /*emitOnlyDtsFiles*/ false, + /*onChangedFile*/ noop, emitOutput => result.push(emitOutput)); + changedFileNames.clear(); + return result; + } + + function getSemanticDiagnostics(program: Program, cancellationToken?: CancellationToken): Diagnostic[] { + ensureProgramGraph(program); + + // Ensure that changed files have cleared their respective + enumerateChangedFilesSet(program, /*onChangedFile*/ noop, (_affectedFileName, sourceFile) => { + if (sourceFile) { + semanticDiagnosticsPerFile.delete(sourceFile.path); + } + }); + + let diagnostics: Diagnostic[]; + for (const sourceFile of program.getSourceFiles()) { + const path = sourceFile.path; + const cachedDiagnostics = semanticDiagnosticsPerFile.get(path); + // Report the semantic diagnostics from the cache if we already have those diagnostics present + if (cachedDiagnostics) { + diagnostics = addRange(diagnostics, cachedDiagnostics); + } + else { + // Diagnostics werent cached, get them from program, and cache the result + const cachedDiagnostics = program.getSemanticDiagnostics(sourceFile, cancellationToken); + semanticDiagnosticsPerFile.set(path, cachedDiagnostics); + diagnostics = addRange(diagnostics, cachedDiagnostics); + } + } + return diagnostics || emptyArray; + } + + function clear() { + isModuleEmit = undefined; + emitHandler = undefined; + fileInfos.clear(); + semanticDiagnosticsPerFile.clear(); + changedFileNames.clear(); + } + + /** + * For script files that contains only ambient external modules, although they are not actually external module files, + * they can only be consumed via importing elements from them. Regular script files cannot consume them. Therefore, + * there are no point to rebuild all script files if these special files have changed. However, if any statement + * in the file is not ambient external module, we treat it as a regular script file. + */ + function containsOnlyAmbientModules(sourceFile: SourceFile) { + for (const statement of sourceFile.statements) { + if (!isModuleWithStringLiteralName(statement)) { + return false; + } + } + return true; + } + + /** + * @return {boolean} indicates if the shape signature has changed since last update. + */ + function updateShapeSignature(program: Program, sourceFile: SourceFile, info: FileInfo) { + const prevSignature = info.signature; + let latestSignature: string; + if (sourceFile.isDeclarationFile) { + latestSignature = computeHash(sourceFile.text); + info.signature = latestSignature; + } + else { + const emitOutput = getEmitOutput(program, sourceFile, /*emitOnlyDtsFiles*/ true, /*isDetailed*/ false); + if (emitOutput.outputFiles && emitOutput.outputFiles.length > 0) { + latestSignature = computeHash(emitOutput.outputFiles[0].text); + info.signature = latestSignature; + } + else { + latestSignature = prevSignature; + } + } + + return !prevSignature || latestSignature !== prevSignature; + } + + /** + * Gets the referenced files for a file from the program with values for the keys as referenced file's path to be true + */ + function getReferencedFiles(program: Program, sourceFile: SourceFile): Map | undefined { + let referencedFiles: Map | undefined; + + // We need to use a set here since the code can contain the same import twice, + // but that will only be one dependency. + // To avoid invernal conversion, the key of the referencedFiles map must be of type Path + if (sourceFile.imports && sourceFile.imports.length > 0) { + const checker: TypeChecker = program.getTypeChecker(); + for (const importName of sourceFile.imports) { + const symbol = checker.getSymbolAtLocation(importName); + if (symbol && symbol.declarations && symbol.declarations[0]) { + const declarationSourceFile = getSourceFileOfNode(symbol.declarations[0]); + if (declarationSourceFile) { + addReferencedFile(declarationSourceFile.path); + } + } + } + } + + const sourceFileDirectory = getDirectoryPath(sourceFile.path); + // Handle triple slash references + if (sourceFile.referencedFiles && sourceFile.referencedFiles.length > 0) { + for (const referencedFile of sourceFile.referencedFiles) { + const referencedPath = toPath(referencedFile.fileName, sourceFileDirectory, getCanonicalFileName); + addReferencedFile(referencedPath); + } + } + + // Handle type reference directives + if (sourceFile.resolvedTypeReferenceDirectiveNames) { + sourceFile.resolvedTypeReferenceDirectiveNames.forEach((resolvedTypeReferenceDirective) => { + if (!resolvedTypeReferenceDirective) { + return; + } + + const fileName = resolvedTypeReferenceDirective.resolvedFileName; + const typeFilePath = toPath(fileName, sourceFileDirectory, getCanonicalFileName); + addReferencedFile(typeFilePath); + }); + } + + return referencedFiles; + + function addReferencedFile(referencedPath: Path) { + if (!referencedFiles) { + referencedFiles = createMap(); + } + referencedFiles.set(referencedPath, true); + } + } + + /** + * Gets all the emittable files from the program + */ + function getAllEmittableFiles(program: Program) { + const defaultLibraryFileName = getDefaultLibFileName(program.getCompilerOptions()); + const sourceFiles = program.getSourceFiles(); + const result: string[] = []; + for (const sourceFile of sourceFiles) { + if (getBaseFileName(sourceFile.fileName) !== defaultLibraryFileName && shouldEmitFile(sourceFile)) { + result.push(sourceFile.fileName); + } + } + return result; + } + + function getNonModuleEmitHandler(): EmitHandler { + return { + onAddSourceFile: noop, + onRemoveSourceFile: noop, + onUpdateSourceFile: noop, + onUpdateSourceFileWithSameVersion: returnFalse, + getFilesAffectedByUpdatedShape + }; + + function getFilesAffectedByUpdatedShape(program: Program, _sourceFile: SourceFile, singleFileResult: string[]): string[] { + const options = program.getCompilerOptions(); + // If `--out` or `--outFile` is specified, any new emit will result in re-emitting the entire project, + // so returning the file itself is good enough. + if (options && (options.out || options.outFile)) { + return singleFileResult; + } + return getAllEmittableFiles(program); + } + } + + function getModuleEmitHandler(): EmitHandler { + const references = createMap>(); + return { + onAddSourceFile: setReferences, + onRemoveSourceFile, + onUpdateSourceFile: updateReferences, + onUpdateSourceFileWithSameVersion: updateReferencesTrackingChangedReferences, + getFilesAffectedByUpdatedShape + }; + + function setReferences(program: Program, sourceFile: SourceFile) { + const newReferences = getReferencedFiles(program, sourceFile); + if (newReferences) { + references.set(sourceFile.path, newReferences); + } + } + + function updateReferences(program: Program, sourceFile: SourceFile) { + const newReferences = getReferencedFiles(program, sourceFile); + if (newReferences) { + references.set(sourceFile.path, newReferences); + } + else { + references.delete(sourceFile.path); + } + } + + function updateReferencesTrackingChangedReferences(program: Program, sourceFile: SourceFile) { + const newReferences = getReferencedFiles(program, sourceFile); + if (!newReferences) { + // Changed if we had references + return references.delete(sourceFile.path); + } + + const oldReferences = references.get(sourceFile.path); + references.set(sourceFile.path, newReferences); + if (!oldReferences || oldReferences.size !== newReferences.size) { + return true; + } + + // If there are any new references that werent present previously there is change + return forEachEntry(newReferences, (_true, referencedPath) => !oldReferences.delete(referencedPath)) || + // Otherwise its changed if there are more references previously than now + !!oldReferences.size; + } + + function onRemoveSourceFile(removedFilePath: Path) { + // Remove existing references + references.forEach((referencesInFile, filePath) => { + if (referencesInFile.has(removedFilePath)) { + // add files referencing the removedFilePath, as changed files too + const referencedByInfo = fileInfos.get(filePath); + if (referencedByInfo) { + registerChangedFile(filePath as Path, referencedByInfo.fileName); + } + } + }); + // Delete the entry for the removed file path + references.delete(removedFilePath); + } + + function getReferencedByPaths(referencedFilePath: Path) { + return mapDefinedIter(references.entries(), ([filePath, referencesInFile]) => + referencesInFile.has(referencedFilePath) ? filePath as Path : undefined + ); + } + + function getFilesAffectedByUpdatedShape(program: Program, sourceFile: SourceFile, singleFileResult: string[]): string[] { + if (!isExternalModule(sourceFile) && !containsOnlyAmbientModules(sourceFile)) { + return getAllEmittableFiles(program); + } + + const options = program.getCompilerOptions(); + if (options && (options.isolatedModules || options.out || options.outFile)) { + return singleFileResult; + } + + // Now we need to if each file in the referencedBy list has a shape change as well. + // Because if so, its own referencedBy files need to be saved as well to make the + // emitting result consistent with files on disk. + + const seenFileNamesMap = createMap(); + const setSeenFileName = (path: Path, sourceFile: SourceFile) => { + seenFileNamesMap.set(path, sourceFile && shouldEmitFile(sourceFile) ? sourceFile.fileName : undefined); + }; + + // Start with the paths this file was referenced by + const path = sourceFile.path; + setSeenFileName(path, sourceFile); + const queue = getReferencedByPaths(path); + while (queue.length > 0) { + const currentPath = queue.pop(); + if (!seenFileNamesMap.has(currentPath)) { + const currentSourceFile = program.getSourceFileByPath(currentPath); + if (currentSourceFile && updateShapeSignature(program, currentSourceFile, fileInfos.get(currentPath))) { + queue.push(...getReferencedByPaths(currentPath)); + } + setSeenFileName(currentPath, currentSourceFile); + } + } + + // Return array of values that needs emit + return flatMapIter(seenFileNamesMap.values(), value => value); + } + } + } +} diff --git a/src/compiler/checker.ts b/src/compiler/checker.ts index e3ba62bf3be87..59b4a29ffc469 100644 --- a/src/compiler/checker.ts +++ b/src/compiler/checker.ts @@ -1209,7 +1209,7 @@ namespace ts { } function diagnosticName(nameArg: __String | Identifier) { - return typeof nameArg === "string" ? unescapeLeadingUnderscores(nameArg as __String) : declarationNameToString(nameArg as Identifier); + return isString(nameArg) ? unescapeLeadingUnderscores(nameArg as __String) : declarationNameToString(nameArg as Identifier); } function isTypeParameterSymbolDeclaredInContainer(symbol: Symbol, container: Node) { diff --git a/src/compiler/commandLineParser.ts b/src/compiler/commandLineParser.ts index b63442bc89a5a..181f84b4c15f0 100644 --- a/src/compiler/commandLineParser.ts +++ b/src/compiler/commandLineParser.ts @@ -892,7 +892,7 @@ namespace ts { */ export function readConfigFile(fileName: string, readFile: (path: string) => string | undefined): { config?: any; error?: Diagnostic } { const textOrDiagnostic = tryReadFile(fileName, readFile); - return typeof textOrDiagnostic === "string" ? parseConfigFileTextToJson(fileName, textOrDiagnostic) : { config: {}, error: textOrDiagnostic }; + return isString(textOrDiagnostic) ? parseConfigFileTextToJson(fileName, textOrDiagnostic) : { config: {}, error: textOrDiagnostic }; } /** @@ -914,7 +914,7 @@ namespace ts { */ export function readJsonConfigFile(fileName: string, readFile: (path: string) => string | undefined): JsonSourceFile { const textOrDiagnostic = tryReadFile(fileName, readFile); - return typeof textOrDiagnostic === "string" ? parseJsonText(fileName, textOrDiagnostic) : { parseDiagnostics: [textOrDiagnostic] }; + return isString(textOrDiagnostic) ? parseJsonText(fileName, textOrDiagnostic) : { parseDiagnostics: [textOrDiagnostic] }; } function tryReadFile(fileName: string, readFile: (path: string) => string | undefined): string | Diagnostic { @@ -1118,9 +1118,9 @@ namespace ts { if (!isDoubleQuotedString(valueExpression)) { errors.push(createDiagnosticForNodeInSourceFile(sourceFile, valueExpression, Diagnostics.String_literal_with_double_quotes_expected)); } - reportInvalidOptionValue(option && (typeof option.type === "string" && option.type !== "string")); + reportInvalidOptionValue(option && (isString(option.type) && option.type !== "string")); const text = (valueExpression).text; - if (option && typeof option.type !== "string") { + if (option && !isString(option.type)) { const customOption = option; // Validate custom option type if (!customOption.type.has(text.toLowerCase())) { @@ -1191,7 +1191,7 @@ namespace ts { function getCompilerOptionValueTypeString(option: CommandLineOption) { return option.type === "list" ? "Array" : - typeof option.type === "string" ? option.type : "string"; + isString(option.type) ? option.type : "string"; } function isCompilerOptionsValue(option: CommandLineOption, value: any): value is CompilerOptionsValue { @@ -1200,7 +1200,7 @@ namespace ts { if (option.type === "list") { return isArray(value); } - const expectedType = typeof option.type === "string" ? option.type : "string"; + const expectedType = isString(option.type) ? option.type : "string"; return typeof value === expectedType; } } @@ -1420,12 +1420,13 @@ namespace ts { Debug.assert((json === undefined && sourceFile !== undefined) || (json !== undefined && sourceFile === undefined)); const errors: Diagnostic[] = []; - const parsedConfig = parseConfig(json, sourceFile, host, basePath, configFileName, resolutionStack, errors); + const getCanonicalFileName = createGetCanonicalFileName(host.useCaseSensitiveFileNames); + const parsedConfig = parseConfig(json, sourceFile, host, basePath, configFileName, getCanonicalFileName, resolutionStack, errors); const { raw } = parsedConfig; const options = extend(existingOptions, parsedConfig.options || {}); options.configFilePath = configFileName; setConfigFileInOptions(options, sourceFile); - const { fileNames, wildcardDirectories } = getFileNames(); + const { fileNames, wildcardDirectories, spec } = getFileNames(); return { options, fileNames, @@ -1433,15 +1434,16 @@ namespace ts { raw, errors, wildcardDirectories, - compileOnSave: !!raw.compileOnSave + compileOnSave: !!raw.compileOnSave, + configFileSpecs: spec }; function getFileNames(): ExpandResult { - let fileNames: ReadonlyArray; + let filesSpecs: ReadonlyArray; if (hasProperty(raw, "files") && !isNullOrUndefined(raw["files"])) { if (isArray(raw["files"])) { - fileNames = >raw["files"]; - if (fileNames.length === 0) { + filesSpecs = >raw["files"]; + if (filesSpecs.length === 0) { createCompilerDiagnosticOnlyIfJson(Diagnostics.The_files_list_in_config_file_0_is_empty, configFileName || "tsconfig.json"); } } @@ -1476,19 +1478,13 @@ namespace ts { } } - if (fileNames === undefined && includeSpecs === undefined) { + if (filesSpecs === undefined && includeSpecs === undefined) { includeSpecs = ["**/*"]; } - const result = matchFileNames(fileNames, includeSpecs, excludeSpecs, configFileName ? directoryOfCombinedPath(configFileName, basePath) : basePath, options, host, errors, extraFileExtensions, sourceFile); - + const result = matchFileNames(filesSpecs, includeSpecs, excludeSpecs, configFileName ? directoryOfCombinedPath(configFileName, basePath) : basePath, options, host, errors, extraFileExtensions, sourceFile); if (result.fileNames.length === 0 && !hasProperty(raw, "files") && resolutionStack.length === 0) { - errors.push( - createCompilerDiagnostic( - Diagnostics.No_inputs_were_found_in_config_file_0_Specified_include_paths_were_1_and_exclude_paths_were_2, - configFileName || "tsconfig.json", - JSON.stringify(includeSpecs || []), - JSON.stringify(excludeSpecs || []))); + errors.push(getErrorForNoInputFiles(result.spec, configFileName)); } return result; @@ -1501,6 +1497,20 @@ namespace ts { } } + /*@internal*/ + export function isErrorNoInputFiles(error: Diagnostic) { + return error.code === Diagnostics.No_inputs_were_found_in_config_file_0_Specified_include_paths_were_1_and_exclude_paths_were_2.code; + } + + /*@internal*/ + export function getErrorForNoInputFiles({ includeSpecs, excludeSpecs }: ConfigFileSpecs, configFileName: string | undefined) { + return createCompilerDiagnostic( + Diagnostics.No_inputs_were_found_in_config_file_0_Specified_include_paths_were_1_and_exclude_paths_were_2, + configFileName || "tsconfig.json", + JSON.stringify(includeSpecs || []), + JSON.stringify(excludeSpecs || [])); + } + interface ParsedTsconfig { raw: any; options?: CompilerOptions; @@ -1522,11 +1532,11 @@ namespace ts { host: ParseConfigHost, basePath: string, configFileName: string, + getCanonicalFileName: (fileName: string) => string, resolutionStack: Path[], errors: Push, ): ParsedTsconfig { basePath = normalizeSlashes(basePath); - const getCanonicalFileName = createGetCanonicalFileName(host.useCaseSensitiveFileNames); const resolvedPath = toPath(configFileName || "", basePath, getCanonicalFileName); if (resolutionStack.indexOf(resolvedPath) >= 0) { @@ -1586,7 +1596,7 @@ namespace ts { let extendedConfigPath: Path; if (json.extends) { - if (typeof json.extends !== "string") { + if (!isString(json.extends)) { errors.push(createCompilerDiagnostic(Diagnostics.Compiler_option_0_requires_a_value_of_type_1, "extends", "string")); } else { @@ -1680,7 +1690,7 @@ namespace ts { return undefined; } let extendedConfigPath = toPath(extendedConfig, basePath, getCanonicalFileName); - if (!host.fileExists(extendedConfigPath) && !endsWith(extendedConfigPath, ".json")) { + if (!host.fileExists(extendedConfigPath) && !endsWith(extendedConfigPath, Extension.Json)) { extendedConfigPath = `${extendedConfigPath}.json` as Path; if (!host.fileExists(extendedConfigPath)) { errors.push(createDiagnostic(Diagnostics.File_0_does_not_exist, extendedConfig)); @@ -1710,7 +1720,7 @@ namespace ts { const extendedDirname = getDirectoryPath(extendedConfigPath); const extendedConfig = parseConfig(/*json*/ undefined, extendedResult, host, extendedDirname, - getBaseFileName(extendedConfigPath), resolutionStack, errors); + getBaseFileName(extendedConfigPath), getCanonicalFileName, resolutionStack, errors); if (sourceFile) { sourceFile.extendedSourceFiles.push(...extendedResult.extendedSourceFiles); } @@ -1813,7 +1823,7 @@ namespace ts { if (optType === "list" && isArray(value)) { return convertJsonOptionOfListType(opt, value, basePath, errors); } - else if (typeof optType !== "string") { + else if (!isString(optType)) { return convertJsonOptionOfCustomType(opt, value, errors); } return normalizeNonListOptionValue(opt, basePath, value); @@ -1827,13 +1837,13 @@ namespace ts { if (isNullOrUndefined(value)) return undefined; if (option.type === "list") { const listOption = option; - if (listOption.element.isFilePath || typeof listOption.element.type !== "string") { + if (listOption.element.isFilePath || !isString(listOption.element.type)) { return filter(map(value, v => normalizeOptionValue(listOption.element, basePath, v)), v => !!v); } return value; } - else if (typeof option.type !== "string") { - return option.type.get(typeof value === "string" ? value.toLowerCase() : value); + else if (!isString(option.type)) { + return option.type.get(isString(value) ? value.toLowerCase() : value); } return normalizeNonListOptionValue(option, basePath, value); } @@ -1943,29 +1953,62 @@ namespace ts { /** * Expands an array of file specifications. * - * @param fileNames The literal file names to include. - * @param include The wildcard file specifications to include. - * @param exclude The wildcard file specifications to exclude. + * @param filesSpecs The literal file names to include. + * @param includeSpecs The wildcard file specifications to include. + * @param excludeSpecs The wildcard file specifications to exclude. * @param basePath The base path for any relative file specifications. * @param options Compiler options. * @param host The host used to resolve files and directories. * @param errors An array for diagnostic reporting. */ function matchFileNames( - fileNames: ReadonlyArray, - include: ReadonlyArray, - exclude: ReadonlyArray, + filesSpecs: ReadonlyArray, + includeSpecs: ReadonlyArray, + excludeSpecs: ReadonlyArray, basePath: string, options: CompilerOptions, host: ParseConfigHost, errors: Push, extraFileExtensions: ReadonlyArray, - jsonSourceFile: JsonSourceFile): ExpandResult { + jsonSourceFile: JsonSourceFile + ): ExpandResult { basePath = normalizePath(basePath); + let validatedIncludeSpecs: ReadonlyArray, validatedExcludeSpecs: ReadonlyArray; // The exclude spec list is converted into a regular expression, which allows us to quickly // test whether a file or directory should be excluded before recursively traversing the // file system. + + if (includeSpecs) { + validatedIncludeSpecs = validateSpecs(includeSpecs, errors, /*allowTrailingRecursion*/ false, jsonSourceFile, "include"); + } + + if (excludeSpecs) { + validatedExcludeSpecs = validateSpecs(excludeSpecs, errors, /*allowTrailingRecursion*/ true, jsonSourceFile, "exclude"); + } + + // Wildcard directories (provided as part of a wildcard path) are stored in a + // file map that marks whether it was a regular wildcard match (with a `*` or `?` token), + // or a recursive directory. This information is used by filesystem watchers to monitor for + // new entries in these paths. + const wildcardDirectories = getWildcardDirectories(validatedIncludeSpecs, validatedExcludeSpecs, basePath, host.useCaseSensitiveFileNames); + + const spec: ConfigFileSpecs = { filesSpecs, includeSpecs, excludeSpecs, validatedIncludeSpecs, validatedExcludeSpecs, wildcardDirectories }; + return getFileNamesFromConfigSpecs(spec, basePath, options, host, extraFileExtensions); + } + + /** + * Gets the file names from the provided config file specs that contain, files, include, exclude and + * other properties needed to resolve the file names + * @param spec The config file specs extracted with file names to include, wildcards to include/exclude and other details + * @param basePath The base path for any relative file specifications. + * @param options Compiler options. + * @param host The host used to resolve files and directories. + * @param extraFileExtensions optionaly file extra file extension information from host + */ + export function getFileNamesFromConfigSpecs(spec: ConfigFileSpecs, basePath: string, options: CompilerOptions, host: ParseConfigHost, extraFileExtensions: ReadonlyArray = []): ExpandResult { + basePath = normalizePath(basePath); + const keyMapper = host.useCaseSensitiveFileNames ? caseSensitiveKeyMapper : caseInsensitiveKeyMapper; // Literal file names (provided via the "files" array in tsconfig.json) are stored in a @@ -1978,19 +2021,7 @@ namespace ts { // via wildcard, and to handle extension priority. const wildcardFileMap = createMap(); - if (include) { - include = validateSpecs(include, errors, /*allowTrailingRecursion*/ false, jsonSourceFile, "include"); - } - - if (exclude) { - exclude = validateSpecs(exclude, errors, /*allowTrailingRecursion*/ true, jsonSourceFile, "exclude"); - } - - // Wildcard directories (provided as part of a wildcard path) are stored in a - // file map that marks whether it was a regular wildcard match (with a `*` or `?` token), - // or a recursive directory. This information is used by filesystem watchers to monitor for - // new entries in these paths. - const wildcardDirectories = getWildcardDirectories(include, exclude, basePath, host.useCaseSensitiveFileNames); + const { filesSpecs, validatedIncludeSpecs, validatedExcludeSpecs, wildcardDirectories } = spec; // Rather than requery this for each file and filespec, we query the supported extensions // once and store it on the expansion context. @@ -1998,15 +2029,15 @@ namespace ts { // Literal files are always included verbatim. An "include" or "exclude" specification cannot // remove a literal file. - if (fileNames) { - for (const fileName of fileNames) { + if (filesSpecs) { + for (const fileName of filesSpecs) { const file = getNormalizedAbsolutePath(fileName, basePath); literalFileMap.set(keyMapper(file), file); } } - if (include && include.length > 0) { - for (const file of host.readDirectory(basePath, supportedExtensions, exclude, include, /*depth*/ undefined)) { + if (validatedIncludeSpecs && validatedIncludeSpecs.length > 0) { + for (const file of host.readDirectory(basePath, supportedExtensions, validatedExcludeSpecs, validatedIncludeSpecs, /*depth*/ undefined)) { // If we have already included a literal or wildcard path with a // higher priority extension, we should skip this file. // @@ -2034,11 +2065,12 @@ namespace ts { const wildcardFiles = arrayFrom(wildcardFileMap.values()); return { fileNames: literalFiles.concat(wildcardFiles), - wildcardDirectories + wildcardDirectories, + spec }; } - function validateSpecs(specs: ReadonlyArray, errors: Push, allowTrailingRecursion: boolean, jsonSourceFile: JsonSourceFile, specKey: string) { + function validateSpecs(specs: ReadonlyArray, errors: Push, allowTrailingRecursion: boolean, jsonSourceFile: JsonSourceFile, specKey: string): ReadonlyArray { return specs.filter(spec => { const diag = specToDiagnostic(spec, allowTrailingRecursion); if (diag !== undefined) { diff --git a/src/compiler/core.ts b/src/compiler/core.ts index ffa010c655127..8d5883b7755b9 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -467,13 +467,20 @@ namespace ts { return result; } - export function flatMapIter(iter: Iterator, mapfn: (x: T) => U[] | undefined): U[] { + export function flatMapIter(iter: Iterator, mapfn: (x: T) => U | U[] | undefined): U[] { const result: U[] = []; while (true) { const { value, done } = iter.next(); if (done) break; const res = mapfn(value); - if (res) result.push(...res); + if (res) { + if (isArray(res)) { + result.push(...res); + } + else { + result.push(res); + } + } } return result; } @@ -523,6 +530,19 @@ namespace ts { return result; } + export function mapDefinedIter(iter: Iterator, mapFn: (x: T) => U | undefined): U[] { + const result: U[] = []; + while (true) { + const { value, done } = iter.next(); + if (done) break; + const res = mapFn(value); + if (res !== undefined) { + result.push(res); + } + } + return result; + } + /** * Computes the first matching span of elements and returns a tuple of the first span * and the remaining elements. @@ -766,7 +786,7 @@ namespace ts { * @param end The offset in `from` at which to stop copying values (non-inclusive). */ export function addRange(to: T[] | undefined, from: ReadonlyArray | undefined, start?: number, end?: number): T[] | undefined { - if (from === undefined) return to; + if (from === undefined || from.length === 0) return to; if (to === undefined) return from.slice(start, end); start = start === undefined ? 0 : toOffset(from, start); end = end === undefined ? from.length : toOffset(from, end); @@ -1222,6 +1242,13 @@ namespace ts { return Array.isArray ? Array.isArray(value) : value instanceof Array; } + /** + * Tests whether a value is string + */ + export function isString(text: any): text is string { + return typeof text === "string"; + } + export function tryCast(value: TIn | undefined, test: (value: TIn) => value is TOut): TOut | undefined { return value !== undefined && test(value) ? value : undefined; } @@ -1232,7 +1259,13 @@ namespace ts { } /** Does nothing. */ - export function noop(): void {} + export function noop(): void { } + + /** Do nothing and return false */ + export function returnFalse(): false { return false; } + + /** Do nothing and return true */ + export function returnTrue(): true { return true; } /** Returns its argument. */ export function identity(x: T) { return x; } @@ -1476,16 +1509,16 @@ namespace ts { function compareMessageText(text1: string | DiagnosticMessageChain, text2: string | DiagnosticMessageChain): Comparison { while (text1 && text2) { // We still have both chains. - const string1 = typeof text1 === "string" ? text1 : text1.messageText; - const string2 = typeof text2 === "string" ? text2 : text2.messageText; + const string1 = isString(text1) ? text1 : text1.messageText; + const string2 = isString(text2) ? text2 : text2.messageText; const res = compareValues(string1, string2); if (res) { return res; } - text1 = typeof text1 === "string" ? undefined : text1.next; - text2 = typeof text2 === "string" ? undefined : text2.next; + text1 = isString(text1) ? undefined : text1.next; + text2 = isString(text2) ? undefined : text2.next; } if (!text1 && !text2) { @@ -1811,6 +1844,8 @@ namespace ts { * Removes a trailing directory separator from a path. * @param path The path. */ + export function removeTrailingDirectorySeparator(path: Path): Path; + export function removeTrailingDirectorySeparator(path: string): string; export function removeTrailingDirectorySeparator(path: string) { if (path.charAt(path.length - 1) === directorySeparator) { return path.substr(0, path.length - 1); @@ -2073,8 +2108,8 @@ namespace ts { } export interface FileSystemEntries { - files: ReadonlyArray; - directories: ReadonlyArray; + readonly files: ReadonlyArray; + readonly directories: ReadonlyArray; } export interface FileMatcherPatterns { @@ -2227,7 +2262,7 @@ namespace ts { return ScriptKind.TS; case Extension.Tsx: return ScriptKind.TSX; - case ".json": + case Extension.Json: return ScriptKind.JSON; default: return ScriptKind.Unknown; @@ -2631,5 +2666,203 @@ namespace ts { return (arg: T) => f(arg) && g(arg); } - export function assertTypeIsNever(_: never): void {} + export function assertTypeIsNever(_: never): void { } + + export interface CachedDirectoryStructureHost extends DirectoryStructureHost { + addOrDeleteFileOrDirectory(fileOrDirectory: string, fileOrDirectoryPath: Path): void; + addOrDeleteFile(fileName: string, filePath: Path, eventKind: FileWatcherEventKind): void; + clearCache(): void; + } + + interface MutableFileSystemEntries { + readonly files: string[]; + readonly directories: string[]; + } + + export function createCachedDirectoryStructureHost(host: DirectoryStructureHost): CachedDirectoryStructureHost { + const cachedReadDirectoryResult = createMap(); + const getCurrentDirectory = memoize(() => host.getCurrentDirectory()); + const getCanonicalFileName = createGetCanonicalFileName(host.useCaseSensitiveFileNames); + return { + useCaseSensitiveFileNames: host.useCaseSensitiveFileNames, + newLine: host.newLine, + readFile: (path, encoding) => host.readFile(path, encoding), + write: s => host.write(s), + writeFile, + fileExists, + directoryExists, + createDirectory, + getCurrentDirectory, + getDirectories, + readDirectory, + addOrDeleteFileOrDirectory, + addOrDeleteFile, + clearCache, + exit: code => host.exit(code) + }; + + function toPath(fileName: string) { + return ts.toPath(fileName, getCurrentDirectory(), getCanonicalFileName); + } + + function getCachedFileSystemEntries(rootDirPath: Path): MutableFileSystemEntries | undefined { + return cachedReadDirectoryResult.get(rootDirPath); + } + + function getCachedFileSystemEntriesForBaseDir(path: Path): MutableFileSystemEntries | undefined { + return getCachedFileSystemEntries(getDirectoryPath(path)); + } + + function getBaseNameOfFileName(fileName: string) { + return getBaseFileName(normalizePath(fileName)); + } + + function createCachedFileSystemEntries(rootDir: string, rootDirPath: Path) { + const resultFromHost: MutableFileSystemEntries = { + files: map(host.readDirectory(rootDir, /*extensions*/ undefined, /*exclude*/ undefined, /*include*/["*.*"]), getBaseNameOfFileName) || [], + directories: host.getDirectories(rootDir) || [] + }; + + cachedReadDirectoryResult.set(rootDirPath, resultFromHost); + return resultFromHost; + } + + /** + * If the readDirectory result was already cached, it returns that + * Otherwise gets result from host and caches it. + * The host request is done under try catch block to avoid caching incorrect result + */ + function tryReadDirectory(rootDir: string, rootDirPath: Path): MutableFileSystemEntries | undefined { + const cachedResult = getCachedFileSystemEntries(rootDirPath); + if (cachedResult) { + return cachedResult; + } + + try { + return createCachedFileSystemEntries(rootDir, rootDirPath); + } + catch (_e) { + // If there is exception to read directories, dont cache the result and direct the calls to host + Debug.assert(!cachedReadDirectoryResult.has(rootDirPath)); + return undefined; + } + } + + function fileNameEqual(name1: string, name2: string) { + return getCanonicalFileName(name1) === getCanonicalFileName(name2); + } + + function hasEntry(entries: ReadonlyArray, name: string) { + return some(entries, file => fileNameEqual(file, name)); + } + + function updateFileSystemEntry(entries: string[], baseName: string, isValid: boolean) { + if (hasEntry(entries, baseName)) { + if (!isValid) { + return filterMutate(entries, entry => !fileNameEqual(entry, baseName)); + } + } + else if (isValid) { + return entries.push(baseName); + } + } + + function writeFile(fileName: string, data: string, writeByteOrderMark?: boolean): void { + const path = toPath(fileName); + const result = getCachedFileSystemEntriesForBaseDir(path); + if (result) { + updateFilesOfFileSystemEntry(result, getBaseNameOfFileName(fileName), /*fileExists*/ true); + } + return host.writeFile(fileName, data, writeByteOrderMark); + } + + function fileExists(fileName: string): boolean { + const path = toPath(fileName); + const result = getCachedFileSystemEntriesForBaseDir(path); + return result && hasEntry(result.files, getBaseNameOfFileName(fileName)) || + host.fileExists(fileName); + } + + function directoryExists(dirPath: string): boolean { + const path = toPath(dirPath); + return cachedReadDirectoryResult.has(path) || host.directoryExists(dirPath); + } + + function createDirectory(dirPath: string) { + const path = toPath(dirPath); + const result = getCachedFileSystemEntriesForBaseDir(path); + const baseFileName = getBaseNameOfFileName(dirPath); + if (result) { + updateFileSystemEntry(result.directories, baseFileName, /*isValid*/ true); + } + host.createDirectory(dirPath); + } + + function getDirectories(rootDir: string): string[] { + const rootDirPath = toPath(rootDir); + const result = tryReadDirectory(rootDir, rootDirPath); + if (result) { + return result.directories.slice(); + } + return host.getDirectories(rootDir); + } + + function readDirectory(rootDir: string, extensions?: ReadonlyArray, excludes?: ReadonlyArray, includes?: ReadonlyArray, depth?: number): string[] { + const rootDirPath = toPath(rootDir); + const result = tryReadDirectory(rootDir, rootDirPath); + if (result) { + return matchFiles(rootDir, extensions, excludes, includes, host.useCaseSensitiveFileNames, getCurrentDirectory(), depth, getFileSystemEntries); + } + return host.readDirectory(rootDir, extensions, excludes, includes, depth); + + function getFileSystemEntries(dir: string) { + const path = toPath(dir); + if (path === rootDirPath) { + return result; + } + return getCachedFileSystemEntries(path) || createCachedFileSystemEntries(dir, path); + } + } + + function addOrDeleteFileOrDirectory(fileOrDirectory: string, fileOrDirectoryPath: Path) { + const existingResult = getCachedFileSystemEntries(fileOrDirectoryPath); + if (existingResult) { + // This was a folder already present, remove it if this doesnt exist any more + if (!host.directoryExists(fileOrDirectory)) { + cachedReadDirectoryResult.delete(fileOrDirectoryPath); + } + } + else { + // This was earlier a file (hence not in cached directory contents) + // or we never cached the directory containing it + const parentResult = getCachedFileSystemEntriesForBaseDir(fileOrDirectoryPath); + if (parentResult) { + const baseName = getBaseNameOfFileName(fileOrDirectory); + if (parentResult) { + updateFilesOfFileSystemEntry(parentResult, baseName, host.fileExists(fileOrDirectoryPath)); + updateFileSystemEntry(parentResult.directories, baseName, host.directoryExists(fileOrDirectoryPath)); + } + } + } + } + + function addOrDeleteFile(fileName: string, filePath: Path, eventKind: FileWatcherEventKind) { + if (eventKind === FileWatcherEventKind.Changed) { + return; + } + + const parentResult = getCachedFileSystemEntriesForBaseDir(filePath); + if (parentResult) { + updateFilesOfFileSystemEntry(parentResult, getBaseNameOfFileName(fileName), eventKind === FileWatcherEventKind.Created); + } + } + + function updateFilesOfFileSystemEntry(parentResult: MutableFileSystemEntries, baseName: string, fileExists: boolean) { + updateFileSystemEntry(parentResult.files, baseName, fileExists); + } + + function clearCache() { + cachedReadDirectoryResult.clear(); + } + } } diff --git a/src/compiler/diagnosticMessages.json b/src/compiler/diagnosticMessages.json index 854d09761cd4a..2c743106aec93 100644 --- a/src/compiler/diagnosticMessages.json +++ b/src/compiler/diagnosticMessages.json @@ -3090,10 +3090,6 @@ "category": "Message", "code": 6128 }, - "The config file '{0}' found doesn't contain any source files.": { - "category": "Error", - "code": 6129 - }, "Resolving real path for '{0}', result '{1}'.": { "category": "Message", "code": 6130 diff --git a/src/compiler/factory.ts b/src/compiler/factory.ts index 6dcf9fab06786..f4a532fa83d1a 100644 --- a/src/compiler/factory.ts +++ b/src/compiler/factory.ts @@ -81,7 +81,7 @@ namespace ts { if (typeof value === "boolean") { return value ? createTrue() : createFalse(); } - if (typeof value === "string") { + if (isString(value)) { return createStringLiteral(value); } return createLiteralFromNode(value); @@ -2204,7 +2204,7 @@ namespace ts { export function createCatchClause(variableDeclaration: string | VariableDeclaration | undefined, block: Block) { const node = createSynthesizedNode(SyntaxKind.CatchClause); - node.variableDeclaration = typeof variableDeclaration === "string" ? createVariableDeclaration(variableDeclaration) : variableDeclaration; + node.variableDeclaration = isString(variableDeclaration) ? createVariableDeclaration(variableDeclaration) : variableDeclaration; node.block = block; return node; } @@ -2530,11 +2530,11 @@ namespace ts { function asName(name: string | EntityName): EntityName; function asName(name: string | Identifier | ThisTypeNode): Identifier | ThisTypeNode; function asName(name: string | Identifier | BindingName | PropertyName | QualifiedName | ThisTypeNode) { - return typeof name === "string" ? createIdentifier(name) : name; + return isString(name) ? createIdentifier(name) : name; } function asExpression(value: string | number | Expression) { - return typeof value === "string" || typeof value === "number" ? createLiteral(value) : value; + return isString(value) || typeof value === "number" ? createLiteral(value) : value; } function asNodeArray(array: ReadonlyArray | undefined): NodeArray | undefined { diff --git a/src/compiler/moduleNameResolver.ts b/src/compiler/moduleNameResolver.ts index 84256b3a1b1d9..560beb39557d4 100644 --- a/src/compiler/moduleNameResolver.ts +++ b/src/compiler/moduleNameResolver.ts @@ -98,7 +98,7 @@ namespace ts { } const fileName = jsonContent[fieldName]; - if (typeof fileName !== "string") { + if (!isString(fileName)) { if (state.traceEnabled) { trace(state.host, Diagnostics.Expected_type_of_0_field_in_package_json_to_be_string_got_1, fieldName, typeof fileName); } @@ -663,8 +663,8 @@ namespace ts { } if (matchedPattern) { - const matchedStar = typeof matchedPattern === "string" ? undefined : matchedText(matchedPattern, moduleName); - const matchedPatternText = typeof matchedPattern === "string" ? matchedPattern : patternText(matchedPattern); + const matchedStar = isString(matchedPattern) ? undefined : matchedText(matchedPattern, moduleName); + const matchedPatternText = isString(matchedPattern) ? matchedPattern : patternText(matchedPattern); if (state.traceEnabled) { trace(state.host, Diagnostics.Module_name_0_matched_pattern_1, moduleName, matchedPatternText); } @@ -1153,21 +1153,4 @@ namespace ts { function toSearchResult(value: T | undefined): SearchResult { return value !== undefined ? { value } : undefined; } - - /** Calls `callback` on `directory` and every ancestor directory it has, returning the first defined result. */ - function forEachAncestorDirectory(directory: string, callback: (directory: string) => SearchResult): SearchResult { - while (true) { - const result = callback(directory); - if (result !== undefined) { - return result; - } - - const parentPath = getDirectoryPath(directory); - if (parentPath === directory) { - return undefined; - } - - directory = parentPath; - } - } } diff --git a/src/compiler/program.ts b/src/compiler/program.ts index 76a0b618a35be..36bb0a737298e 100755 --- a/src/compiler/program.ts +++ b/src/compiler/program.ts @@ -1,6 +1,7 @@ /// /// /// +/// namespace ts { const ignoreDiagnosticCommentRegEx = /(^\s*$)|(^\s*\/\/\/?\s*(@ts-ignore)?)/; @@ -229,19 +230,25 @@ namespace ts { let output = ""; for (const diagnostic of diagnostics) { - if (diagnostic.file) { - const { line, character } = getLineAndCharacterOfPosition(diagnostic.file, diagnostic.start); - const fileName = diagnostic.file.fileName; - const relativeFileName = convertToRelativePath(fileName, host.getCurrentDirectory(), fileName => host.getCanonicalFileName(fileName)); - output += `${relativeFileName}(${line + 1},${character + 1}): `; - } - - const category = DiagnosticCategory[diagnostic.category].toLowerCase(); - output += `${category} TS${diagnostic.code}: ${flattenDiagnosticMessageText(diagnostic.messageText, host.getNewLine())}${host.getNewLine()}`; + output += formatDiagnostic(diagnostic, host); } return output; } + export function formatDiagnostic(diagnostic: Diagnostic, host: FormatDiagnosticsHost): string { + const category = DiagnosticCategory[diagnostic.category].toLowerCase(); + const errorMessage = `${category} TS${diagnostic.code}: ${flattenDiagnosticMessageText(diagnostic.messageText, host.getNewLine())}${host.getNewLine()}`; + + if (diagnostic.file) { + const { line, character } = getLineAndCharacterOfPosition(diagnostic.file, diagnostic.start); + const fileName = diagnostic.file.fileName; + const relativeFileName = convertToRelativePath(fileName, host.getCurrentDirectory(), fileName => host.getCanonicalFileName(fileName)); + return `${relativeFileName}(${line + 1},${character + 1}): ` + errorMessage; + } + + return errorMessage; + } + const redForegroundEscapeSequence = "\u001b[91m"; const yellowForegroundEscapeSequence = "\u001b[93m"; const blueForegroundEscapeSequence = "\u001b[93m"; @@ -344,7 +351,7 @@ namespace ts { } export function flattenDiagnosticMessageText(messageText: string | DiagnosticMessageChain, newLine: string): string { - if (typeof messageText === "string") { + if (isString(messageText)) { return messageText; } else { @@ -393,6 +400,78 @@ namespace ts { allDiagnostics?: Diagnostic[]; } + /** + * Determines if program structure is upto date or needs to be recreated + */ + export function isProgramUptoDate( + program: Program | undefined, + rootFileNames: string[], + newOptions: CompilerOptions, + getSourceVersion: (path: Path) => string, + fileExists: (fileName: string) => boolean, + hasInvalidatedResolution: HasInvalidatedResolution, + hasChangedAutomaticTypeDirectiveNames: boolean, + ): boolean { + // If we haven't create a program yet or has changed automatic type directives, then it is not up-to-date + if (!program || hasChangedAutomaticTypeDirectiveNames) { + return false; + } + + // If number of files in the program do not match, it is not up-to-date + if (program.getRootFileNames().length !== rootFileNames.length) { + return false; + } + + // If any file is not up-to-date, then the whole program is not up-to-date + if (program.getSourceFiles().some(sourceFileNotUptoDate)) { + return false; + } + + // If any of the missing file paths are now created + if (program.getMissingFilePaths().some(fileExists)) { + return false; + } + + const currentOptions = program.getCompilerOptions(); + // If the compilation settings do no match, then the program is not up-to-date + if (!compareDataObjects(currentOptions, newOptions)) { + return false; + } + + // If everything matches but the text of config file is changed, + // error locations can change for program options, so update the program + if (currentOptions.configFile && newOptions.configFile) { + return currentOptions.configFile.text === newOptions.configFile.text; + } + + return true; + + function sourceFileNotUptoDate(sourceFile: SourceFile): boolean { + return sourceFile.version !== getSourceVersion(sourceFile.path) || + hasInvalidatedResolution(sourceFile.path); + } + } + + /** + * Determined if source file needs to be re-created even if its text hasnt changed + */ + function shouldProgramCreateNewSourceFiles(program: Program, newOptions: CompilerOptions) { + // If any of these options change, we cant reuse old source file even if version match + // The change in options like these could result in change in syntax tree change + const oldOptions = program && program.getCompilerOptions(); + return oldOptions && ( + oldOptions.target !== newOptions.target || + oldOptions.module !== newOptions.module || + oldOptions.moduleResolution !== newOptions.moduleResolution || + oldOptions.noResolve !== newOptions.noResolve || + oldOptions.jsx !== newOptions.jsx || + oldOptions.allowJs !== newOptions.allowJs || + oldOptions.disableSizeLimit !== newOptions.disableSizeLimit || + oldOptions.baseUrl !== newOptions.baseUrl || + !equalOwnProperties(oldOptions.paths, newOptions.paths) + ); + } + /** * Create a new 'Program' instance. A Program is an immutable collection of 'SourceFile's and a 'CompilerOptions' * that represent a compilation unit. @@ -454,9 +533,10 @@ namespace ts { let _compilerOptionsObjectLiteralSyntax: ObjectLiteralExpression; let moduleResolutionCache: ModuleResolutionCache; - let resolveModuleNamesWorker: (moduleNames: string[], containingFile: string) => ResolvedModuleFull[]; + let resolveModuleNamesWorker: (moduleNames: string[], containingFile: string, reusedNames?: string[]) => ResolvedModuleFull[]; + const hasInvalidatedResolution = host.hasInvalidatedResolution || returnFalse; if (host.resolveModuleNames) { - resolveModuleNamesWorker = (moduleNames, containingFile) => host.resolveModuleNames(checkAllDefined(moduleNames), containingFile).map(resolved => { + resolveModuleNamesWorker = (moduleNames, containingFile, reusedNames) => host.resolveModuleNames(checkAllDefined(moduleNames), containingFile, reusedNames).map(resolved => { // An older host may have omitted extension, in which case we should infer it from the file extension of resolvedFileName. if (!resolved || (resolved as ResolvedModuleFull).extension !== undefined) { return resolved as ResolvedModuleFull; @@ -491,10 +571,12 @@ namespace ts { let redirectTargetsSet = createMap(); const filesByName = createMap(); + let missingFilePaths: ReadonlyArray; // stores 'filename -> file association' ignoring case // used to track cases when two file names differ only in casing const filesByNameIgnoreCase = host.useCaseSensitiveFileNames() ? createMap() : undefined; + const shouldCreateNewSourceFile = shouldProgramCreateNewSourceFiles(oldProgram, options); const structuralIsReused = tryReuseStructureFromOldProgram(); if (structuralIsReused !== StructureIsReused.Completely) { forEach(rootNames, name => processRootFile(name, /*isDefaultLib*/ false)); @@ -528,13 +610,26 @@ namespace ts { }); } } + + missingFilePaths = arrayFrom(filesByName.keys(), p => p).filter(p => !filesByName.get(p)); } - const missingFilePaths = arrayFrom(filesByName.keys(), p => p).filter(p => !filesByName.get(p)); + Debug.assert(!!missingFilePaths); // unconditionally set moduleResolutionCache to undefined to avoid unnecessary leaks moduleResolutionCache = undefined; + // Release any files we have acquired in the old program but are + // not part of the new program. + if (oldProgram && host.onReleaseOldSourceFile) { + const oldSourceFiles = oldProgram.getSourceFiles(); + for (const oldSourceFile of oldSourceFiles) { + if (!getSourceFile(oldSourceFile.path) || shouldCreateNewSourceFile) { + host.onReleaseOldSourceFile(oldSourceFile, oldProgram.getCompilerOptions()); + } + } + } + // unconditionally set oldProgram to undefined to prevent it from being captured in closure oldProgram = undefined; @@ -568,6 +663,7 @@ namespace ts { getSourceFileFromReference, sourceFileToPackageName, redirectTargetsSet, + hasInvalidatedResolution }; verifyCompilerOptions(); @@ -662,21 +758,22 @@ namespace ts { * * ResolvedModuleFull instance: can be reused. */ let result: ResolvedModuleFull[]; + let reusedNames: string[]; /** A transient placeholder used to mark predicted resolution in the result list. */ const predictedToResolveToAmbientModuleMarker: ResolvedModuleFull = {}; for (let i = 0; i < moduleNames.length; i++) { const moduleName = moduleNames[i]; - // If we want to reuse resolutions more aggressively, we can refine this to check for whether the - // text of the corresponding modulenames has changed. - if (file === oldSourceFile) { + // If the source file is unchanged and doesnt have invalidated resolution, reuse the module resolutions + if (file === oldSourceFile && !hasInvalidatedResolution(oldSourceFile.path)) { const oldResolvedModule = oldSourceFile && oldSourceFile.resolvedModules.get(moduleName); if (oldResolvedModule) { if (isTraceEnabled(options, host)) { trace(host, Diagnostics.Reusing_resolution_of_module_0_to_file_1_from_old_program, moduleName, containingFile); } (result || (result = new Array(moduleNames.length)))[i] = oldResolvedModule; + (reusedNames || (reusedNames = [])).push(moduleName); continue; } } @@ -705,7 +802,7 @@ namespace ts { } const resolutions = unknownModuleNames && unknownModuleNames.length - ? resolveModuleNamesWorker(unknownModuleNames, containingFile) + ? resolveModuleNamesWorker(unknownModuleNames, containingFile, reusedNames) : emptyArray; // Combine results of resolutions and predicted results @@ -793,14 +890,21 @@ namespace ts { const modifiedSourceFiles: { oldFile: SourceFile, newFile: SourceFile }[] = []; oldProgram.structureIsReused = StructureIsReused.Completely; + // If the missing file paths are now present, it can change the progam structure, + // and hence cant reuse the structure. + // This is same as how we dont reuse the structure if one of the file from old program is now missing + if (oldProgram.getMissingFilePaths().some(missingFilePath => host.fileExists(missingFilePath))) { + return oldProgram.structureIsReused = StructureIsReused.Not; + } + const oldSourceFiles = oldProgram.getSourceFiles(); const enum SeenPackageName { Exists, Modified } const seenPackageNames = createMap(); for (const oldSourceFile of oldSourceFiles) { let newSourceFile = host.getSourceFileByPath - ? host.getSourceFileByPath(oldSourceFile.fileName, oldSourceFile.path, options.target) - : host.getSourceFile(oldSourceFile.fileName, options.target); + ? host.getSourceFileByPath(oldSourceFile.fileName, oldSourceFile.path, options.target, /*onError*/ undefined, shouldCreateNewSourceFile) + : host.getSourceFile(oldSourceFile.fileName, options.target, /*onError*/ undefined, shouldCreateNewSourceFile); if (!newSourceFile) { return oldProgram.structureIsReused = StructureIsReused.Not; @@ -883,6 +987,13 @@ namespace ts { // tentatively approve the file modifiedSourceFiles.push({ oldFile: oldSourceFile, newFile: newSourceFile }); } + else if (hasInvalidatedResolution(oldSourceFile.path)) { + // 'module/types' references could have changed + oldProgram.structureIsReused = StructureIsReused.SafeModules; + + // add file to the modified list so that we will resolve it later + modifiedSourceFiles.push({ oldFile: oldSourceFile, newFile: newSourceFile }); + } // if file has passed all checks it should be safe to reuse it newSourceFiles.push(newSourceFile); @@ -929,20 +1040,11 @@ namespace ts { return oldProgram.structureIsReused; } - // If a file has ceased to be missing, then we need to discard some of the old - // structure in order to pick it up. - // Caution: if the file has created and then deleted between since it was discovered to - // be missing, then the corresponding file watcher will have been closed and no new one - // will be created until we encounter a change that prevents complete structure reuse. - // During this interval, creation of the file will go unnoticed. We expect this to be - // both rare and low-impact. - if (oldProgram.getMissingFilePaths().some(missingFilePath => host.fileExists(missingFilePath))) { + if (host.hasChangedAutomaticTypeDirectiveNames) { return oldProgram.structureIsReused = StructureIsReused.SafeModules; } - for (const p of oldProgram.getMissingFilePaths()) { - filesByName.set(p, undefined); - } + missingFilePaths = oldProgram.getMissingFilePaths(); // update fileName -> file mapping for (let i = 0; i < newSourceFiles.length; i++) { @@ -1689,7 +1791,7 @@ namespace ts { else { fileProcessingDiagnostics.add(createCompilerDiagnostic(Diagnostics.Cannot_read_file_0_Colon_1, fileName, hostErrorMessage)); } - }); + }, shouldCreateNewSourceFile); if (packageId) { const packageIdKey = `${packageId.name}/${packageId.subModuleName}@${packageId.version}`; diff --git a/src/compiler/resolutionCache.ts b/src/compiler/resolutionCache.ts new file mode 100644 index 0000000000000..aecc698989134 --- /dev/null +++ b/src/compiler/resolutionCache.ts @@ -0,0 +1,595 @@ +/// +/// +/// + +/*@internal*/ +namespace ts { + /** This is the cache of module/typedirectives resolution that can be retained across program */ + export interface ResolutionCache { + startRecordingFilesWithChangedResolutions(): void; + finishRecordingFilesWithChangedResolutions(): Path[]; + + resolveModuleNames(moduleNames: string[], containingFile: string, reusedNames: string[] | undefined, logChanges: boolean): ResolvedModuleFull[]; + resolveTypeReferenceDirectives(typeDirectiveNames: string[], containingFile: string): ResolvedTypeReferenceDirective[]; + + invalidateResolutionOfFile(filePath: Path): void; + removeResolutionsOfFile(filePath: Path): void; + createHasInvalidatedResolution(): HasInvalidatedResolution; + + startCachingPerDirectoryResolution(): void; + finishCachingPerDirectoryResolution(): void; + + updateTypeRootsWatch(): void; + closeTypeRootsWatch(): void; + + clear(): void; + } + + interface ResolutionWithFailedLookupLocations { + readonly failedLookupLocations: ReadonlyArray; + isInvalidated?: boolean; + } + + interface ResolutionWithResolvedFileName { + resolvedFileName: string | undefined; + } + + interface ResolvedModuleWithFailedLookupLocations extends ts.ResolvedModuleWithFailedLookupLocations, ResolutionWithFailedLookupLocations { + } + + interface ResolvedTypeReferenceDirectiveWithFailedLookupLocations extends ts.ResolvedTypeReferenceDirectiveWithFailedLookupLocations, ResolutionWithFailedLookupLocations { + } + + export interface ResolutionCacheHost extends ModuleResolutionHost { + toPath(fileName: string): Path; + getCompilationSettings(): CompilerOptions; + watchDirectoryOfFailedLookupLocation(directory: string, cb: DirectoryWatcherCallback, flags: WatchDirectoryFlags): FileWatcher; + onInvalidatedResolution(): void; + watchTypeRootsDirectory(directory: string, cb: DirectoryWatcherCallback, flags: WatchDirectoryFlags): FileWatcher; + onChangedAutomaticTypeDirectiveNames(): void; + getCachedDirectoryStructureHost?(): CachedDirectoryStructureHost; + projectName?: string; + getGlobalCache?(): string | undefined; + writeLog(s: string): void; + maxNumberOfFilesToIterateForInvalidation?: number; + } + + interface DirectoryWatchesOfFailedLookup { + /** watcher for the directory of failed lookup */ + watcher: FileWatcher; + /** ref count keeping this directory watch alive */ + refCount: number; + } + + interface DirectoryOfFailedLookupWatch { + dir: string; + dirPath: Path; + } + + export const maxNumberOfFilesToIterateForInvalidation = 256; + + interface GetResolutionWithResolvedFileName { + (resolution: T): R; + } + + export function createResolutionCache(resolutionHost: ResolutionCacheHost, rootDirForResolution: string): ResolutionCache { + let filesWithChangedSetOfUnresolvedImports: Path[] | undefined; + let filesWithInvalidatedResolutions: Map | undefined; + let allFilesHaveInvalidatedResolution = false; + + // The resolvedModuleNames and resolvedTypeReferenceDirectives are the cache of resolutions per file. + // The key in the map is source file's path. + // The values are Map of resolutions with key being name lookedup. + const resolvedModuleNames = createMap>(); + const perDirectoryResolvedModuleNames = createMap>(); + + const resolvedTypeReferenceDirectives = createMap>(); + const perDirectoryResolvedTypeReferenceDirectives = createMap>(); + + const getCurrentDirectory = memoize(() => resolutionHost.getCurrentDirectory()); + + /** + * These are the extensions that failed lookup files will have by default, + * any other extension of failed lookup will be store that path in custom failed lookup path + * This helps in not having to comb through all resolutions when files are added/removed + * Note that .d.ts file also has .d.ts extension hence will be part of default extensions + */ + const failedLookupDefaultExtensions = [Extension.Ts, Extension.Tsx, Extension.Js, Extension.Jsx, Extension.Json]; + const customFailedLookupPaths = createMap(); + + const directoryWatchesOfFailedLookups = createMap(); + const rootDir = rootDirForResolution && removeTrailingDirectorySeparator(getNormalizedAbsolutePath(rootDirForResolution, getCurrentDirectory())); + const rootPath = rootDir && resolutionHost.toPath(rootDir); + + // TypeRoot watches for the types that get added as part of getAutomaticTypeDirectiveNames + const typeRootsWatches = createMap(); + + return { + startRecordingFilesWithChangedResolutions, + finishRecordingFilesWithChangedResolutions, + startCachingPerDirectoryResolution, + finishCachingPerDirectoryResolution, + resolveModuleNames, + resolveTypeReferenceDirectives, + removeResolutionsOfFile, + invalidateResolutionOfFile, + createHasInvalidatedResolution, + updateTypeRootsWatch, + closeTypeRootsWatch, + clear + }; + + function getResolvedModule(resolution: ResolvedModuleWithFailedLookupLocations) { + return resolution.resolvedModule; + } + + function getResolvedTypeReferenceDirective(resolution: ResolvedTypeReferenceDirectiveWithFailedLookupLocations) { + return resolution.resolvedTypeReferenceDirective; + } + + function isInDirectoryPath(dir: Path, file: Path) { + if (dir === undefined || file.length <= dir.length) { + return false; + } + return startsWith(file, dir) && file[dir.length] === directorySeparator; + } + + function clear() { + clearMap(directoryWatchesOfFailedLookups, closeFileWatcherOf); + customFailedLookupPaths.clear(); + closeTypeRootsWatch(); + resolvedModuleNames.clear(); + resolvedTypeReferenceDirectives.clear(); + allFilesHaveInvalidatedResolution = false; + Debug.assert(perDirectoryResolvedModuleNames.size === 0 && perDirectoryResolvedTypeReferenceDirectives.size === 0); + } + + function startRecordingFilesWithChangedResolutions() { + filesWithChangedSetOfUnresolvedImports = []; + } + + function finishRecordingFilesWithChangedResolutions() { + const collected = filesWithChangedSetOfUnresolvedImports; + filesWithChangedSetOfUnresolvedImports = undefined; + return collected; + } + + function createHasInvalidatedResolution(): HasInvalidatedResolution { + if (allFilesHaveInvalidatedResolution) { + // Any file asked would have invalidated resolution + filesWithInvalidatedResolutions = undefined; + return returnTrue; + } + const collected = filesWithInvalidatedResolutions; + filesWithInvalidatedResolutions = undefined; + return path => collected && collected.has(path); + } + + function startCachingPerDirectoryResolution() { + Debug.assert(perDirectoryResolvedModuleNames.size === 0 && perDirectoryResolvedTypeReferenceDirectives.size === 0); + } + + function finishCachingPerDirectoryResolution() { + allFilesHaveInvalidatedResolution = false; + directoryWatchesOfFailedLookups.forEach((watcher, path) => { + if (watcher.refCount === 0) { + directoryWatchesOfFailedLookups.delete(path); + watcher.watcher.close(); + } + }); + + perDirectoryResolvedModuleNames.clear(); + perDirectoryResolvedTypeReferenceDirectives.clear(); + } + + function resolveModuleName(moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost): ResolvedModuleWithFailedLookupLocations { + const primaryResult = ts.resolveModuleName(moduleName, containingFile, compilerOptions, host); + // return result immediately only if global cache support is not enabled or if it is .ts, .tsx or .d.ts + if (!resolutionHost.getGlobalCache) { + return primaryResult; + } + + // otherwise try to load typings from @types + const globalCache = resolutionHost.getGlobalCache(); + if (globalCache !== undefined && !isExternalModuleNameRelative(moduleName) && !(primaryResult.resolvedModule && extensionIsTypeScript(primaryResult.resolvedModule.extension))) { + // create different collection of failed lookup locations for second pass + // if it will fail and we've already found something during the first pass - we don't want to pollute its results + const { resolvedModule, failedLookupLocations } = loadModuleFromGlobalCache(moduleName, resolutionHost.projectName, compilerOptions, host, globalCache); + if (resolvedModule) { + return { resolvedModule, failedLookupLocations: addRange(primaryResult.failedLookupLocations as string[], failedLookupLocations) }; + } + } + + // Default return the result from the first pass + return primaryResult; + } + + function resolveNamesWithLocalCache( + names: string[], + containingFile: string, + cache: Map>, + perDirectoryCache: Map>, + loader: (name: string, containingFile: string, options: CompilerOptions, host: ModuleResolutionHost) => T, + getResolutionWithResolvedFileName: GetResolutionWithResolvedFileName, + reusedNames: string[] | undefined, + logChanges: boolean): R[] { + + const path = resolutionHost.toPath(containingFile); + const resolutionsInFile = cache.get(path) || cache.set(path, createMap()).get(path); + const dirPath = getDirectoryPath(path); + let perDirectoryResolution = perDirectoryCache.get(dirPath); + if (!perDirectoryResolution) { + perDirectoryResolution = createMap(); + perDirectoryCache.set(dirPath, perDirectoryResolution); + } + + const resolvedModules: R[] = []; + const compilerOptions = resolutionHost.getCompilationSettings(); + + const seenNamesInFile = createMap(); + for (const name of names) { + let resolution = resolutionsInFile.get(name); + // Resolution is valid if it is present and not invalidated + if (!seenNamesInFile.has(name) && + allFilesHaveInvalidatedResolution || !resolution || resolution.isInvalidated) { + const existingResolution = resolution; + const resolutionInDirectory = perDirectoryResolution.get(name); + if (resolutionInDirectory) { + resolution = resolutionInDirectory; + } + else { + resolution = loader(name, containingFile, compilerOptions, resolutionHost); + perDirectoryResolution.set(name, resolution); + } + resolutionsInFile.set(name, resolution); + if (resolution.failedLookupLocations) { + if (existingResolution && existingResolution.failedLookupLocations) { + watchAndStopWatchDiffFailedLookupLocations(resolution, existingResolution); + } + else { + watchFailedLookupLocationOfResolution(resolution, 0); + } + } + else if (existingResolution) { + stopWatchFailedLookupLocationOfResolution(existingResolution); + } + if (logChanges && filesWithChangedSetOfUnresolvedImports && !resolutionIsEqualTo(existingResolution, resolution)) { + filesWithChangedSetOfUnresolvedImports.push(path); + // reset log changes to avoid recording the same file multiple times + logChanges = false; + } + } + Debug.assert(resolution !== undefined && !resolution.isInvalidated); + seenNamesInFile.set(name, true); + resolvedModules.push(getResolutionWithResolvedFileName(resolution)); + } + + // Stop watching and remove the unused name + resolutionsInFile.forEach((resolution, name) => { + if (!seenNamesInFile.has(name) && !contains(reusedNames, name)) { + stopWatchFailedLookupLocationOfResolution(resolution); + resolutionsInFile.delete(name); + } + }); + + return resolvedModules; + + function resolutionIsEqualTo(oldResolution: T, newResolution: T): boolean { + if (oldResolution === newResolution) { + return true; + } + if (!oldResolution || !newResolution || oldResolution.isInvalidated) { + return false; + } + const oldResult = getResolutionWithResolvedFileName(oldResolution); + const newResult = getResolutionWithResolvedFileName(newResolution); + if (oldResult === newResult) { + return true; + } + if (!oldResult || !newResult) { + return false; + } + return oldResult.resolvedFileName === newResult.resolvedFileName; + } + } + + function resolveTypeReferenceDirectives(typeDirectiveNames: string[], containingFile: string): ResolvedTypeReferenceDirective[] { + return resolveNamesWithLocalCache( + typeDirectiveNames, containingFile, + resolvedTypeReferenceDirectives, perDirectoryResolvedTypeReferenceDirectives, + resolveTypeReferenceDirective, getResolvedTypeReferenceDirective, + /*reusedNames*/ undefined, /*logChanges*/ false + ); + } + + function resolveModuleNames(moduleNames: string[], containingFile: string, reusedNames: string[] | undefined, logChanges: boolean): ResolvedModuleFull[] { + return resolveNamesWithLocalCache( + moduleNames, containingFile, + resolvedModuleNames, perDirectoryResolvedModuleNames, + resolveModuleName, getResolvedModule, + reusedNames, logChanges + ); + } + + function isNodeModulesDirectory(dirPath: Path) { + return endsWith(dirPath, "/node_modules"); + } + + function getDirectoryToWatchFailedLookupLocation(failedLookupLocation: string, failedLookupLocationPath: Path): DirectoryOfFailedLookupWatch { + if (isInDirectoryPath(rootPath, failedLookupLocationPath)) { + return { dir: rootDir, dirPath: rootPath }; + } + + let dir = getDirectoryPath(getNormalizedAbsolutePath(failedLookupLocation, getCurrentDirectory())); + let dirPath = getDirectoryPath(failedLookupLocationPath); + + // If the directory is node_modules use it to watch + if (isNodeModulesDirectory(dirPath)) { + return { dir, dirPath }; + } + + // If directory path contains node module, get the node_modules directory for watching + if (dirPath.indexOf("/node_modules/") !== -1) { + while (!isNodeModulesDirectory(dirPath)) { + dir = getDirectoryPath(dir); + dirPath = getDirectoryPath(dirPath); + } + return { dir, dirPath }; + } + + // Use some ancestor of the root directory + if (rootPath !== undefined) { + while (!isInDirectoryPath(dirPath, rootPath)) { + const parentPath = getDirectoryPath(dirPath); + if (parentPath === dirPath) { + break; + } + dirPath = parentPath; + dir = getDirectoryPath(dir); + } + } + + return { dir, dirPath }; + } + + function isPathWithDefaultFailedLookupExtension(path: Path) { + return fileExtensionIsOneOf(path, failedLookupDefaultExtensions); + } + + function watchAndStopWatchDiffFailedLookupLocations(resolution: ResolutionWithFailedLookupLocations, existingResolution: ResolutionWithFailedLookupLocations) { + const failedLookupLocations = resolution.failedLookupLocations; + const existingFailedLookupLocations = existingResolution.failedLookupLocations; + for (let index = 0; index < failedLookupLocations.length; index++) { + if (index === existingFailedLookupLocations.length) { + // Additional failed lookup locations, watch from this index + watchFailedLookupLocationOfResolution(resolution, index); + return; + } + else if (failedLookupLocations[index] !== existingFailedLookupLocations[index]) { + // Different failed lookup locations, + // Watch new resolution failed lookup locations from this index and + // stop watching existing resolutions from this index + watchFailedLookupLocationOfResolution(resolution, index); + stopWatchFailedLookupLocationOfResolutionFrom(existingResolution, index); + return; + } + } + + // All new failed lookup locations are already watched (and are same), + // Stop watching failed lookup locations of existing resolution after failed lookup locations length + stopWatchFailedLookupLocationOfResolutionFrom(existingResolution, failedLookupLocations.length); + } + + function watchFailedLookupLocationOfResolution({ failedLookupLocations }: ResolutionWithFailedLookupLocations, startIndex: number) { + for (let i = startIndex; i < failedLookupLocations.length; i++) { + const failedLookupLocation = failedLookupLocations[i]; + const failedLookupLocationPath = resolutionHost.toPath(failedLookupLocation); + // If the failed lookup location path is not one of the supported extensions, + // store it in the custom path + if (!isPathWithDefaultFailedLookupExtension(failedLookupLocationPath)) { + const refCount = customFailedLookupPaths.get(failedLookupLocationPath) || 0; + customFailedLookupPaths.set(failedLookupLocationPath, refCount + 1); + } + const { dir, dirPath } = getDirectoryToWatchFailedLookupLocation(failedLookupLocation, failedLookupLocationPath); + const dirWatcher = directoryWatchesOfFailedLookups.get(dirPath); + if (dirWatcher) { + dirWatcher.refCount++; + } + else { + directoryWatchesOfFailedLookups.set(dirPath, { watcher: createDirectoryWatcher(dir, dirPath), refCount: 1 }); + } + } + } + + function stopWatchFailedLookupLocationOfResolution(resolution: ResolutionWithFailedLookupLocations) { + if (resolution.failedLookupLocations) { + stopWatchFailedLookupLocationOfResolutionFrom(resolution, 0); + } + } + + function stopWatchFailedLookupLocationOfResolutionFrom({ failedLookupLocations }: ResolutionWithFailedLookupLocations, startIndex: number) { + for (let i = startIndex; i < failedLookupLocations.length; i++) { + const failedLookupLocation = failedLookupLocations[i]; + const failedLookupLocationPath = resolutionHost.toPath(failedLookupLocation); + const refCount = customFailedLookupPaths.get(failedLookupLocationPath); + if (refCount) { + if (refCount === 1) { + customFailedLookupPaths.delete(failedLookupLocationPath); + } + else { + Debug.assert(refCount > 1); + customFailedLookupPaths.set(failedLookupLocationPath, refCount - 1); + } + } + const { dirPath } = getDirectoryToWatchFailedLookupLocation(failedLookupLocation, failedLookupLocationPath); + const dirWatcher = directoryWatchesOfFailedLookups.get(dirPath); + // Do not close the watcher yet since it might be needed by other failed lookup locations. + dirWatcher.refCount--; + } + } + + function createDirectoryWatcher(directory: string, dirPath: Path) { + return resolutionHost.watchDirectoryOfFailedLookupLocation(directory, fileOrDirectory => { + const fileOrDirectoryPath = resolutionHost.toPath(fileOrDirectory); + if (resolutionHost.getCachedDirectoryStructureHost) { + // Since the file existance changed, update the sourceFiles cache + resolutionHost.getCachedDirectoryStructureHost().addOrDeleteFileOrDirectory(fileOrDirectory, fileOrDirectoryPath); + } + + // If the files are added to project root or node_modules directory, always run through the invalidation process + // Otherwise run through invalidation only if adding to the immediate directory + if (!allFilesHaveInvalidatedResolution && + dirPath === rootPath || isNodeModulesDirectory(dirPath) || getDirectoryPath(fileOrDirectoryPath) === dirPath) { + if (invalidateResolutionOfFailedLookupLocation(fileOrDirectoryPath, dirPath === fileOrDirectoryPath)) { + resolutionHost.onInvalidatedResolution(); + } + } + }, WatchDirectoryFlags.Recursive); + } + + function removeResolutionsOfFileFromCache(cache: Map>, filePath: Path) { + // Deleted file, stop watching failed lookups for all the resolutions in the file + const resolutions = cache.get(filePath); + if (resolutions) { + resolutions.forEach(stopWatchFailedLookupLocationOfResolution); + cache.delete(filePath); + } + } + + function removeResolutionsOfFile(filePath: Path) { + removeResolutionsOfFileFromCache(resolvedModuleNames, filePath); + removeResolutionsOfFileFromCache(resolvedTypeReferenceDirectives, filePath); + } + + function invalidateResolutionCache( + cache: Map>, + isInvalidatedResolution: (resolution: T, getResolutionWithResolvedFileName: GetResolutionWithResolvedFileName) => boolean, + getResolutionWithResolvedFileName: GetResolutionWithResolvedFileName + ) { + const seen = createMap>(); + cache.forEach((resolutions, containingFilePath) => { + const dirPath = getDirectoryPath(containingFilePath); + let seenInDir = seen.get(dirPath); + if (!seenInDir) { + seenInDir = createMap(); + seen.set(dirPath, seenInDir); + } + resolutions.forEach((resolution, name) => { + if (seenInDir.has(name)) { + return; + } + seenInDir.set(name, true); + if (!resolution.isInvalidated && isInvalidatedResolution(resolution, getResolutionWithResolvedFileName)) { + // Mark the file as needing re-evaluation of module resolution instead of using it blindly. + resolution.isInvalidated = true; + (filesWithInvalidatedResolutions || (filesWithInvalidatedResolutions = createMap())).set(containingFilePath, true); + } + }); + }); + } + + function hasReachedResolutionIterationLimit() { + const maxSize = resolutionHost.maxNumberOfFilesToIterateForInvalidation || maxNumberOfFilesToIterateForInvalidation; + return resolvedModuleNames.size > maxSize || resolvedTypeReferenceDirectives.size > maxSize; + } + + function invalidateResolutions( + isInvalidatedResolution: (resolution: ResolutionWithFailedLookupLocations, getResolutionWithResolvedFileName: GetResolutionWithResolvedFileName) => boolean, + ) { + // If more than maxNumberOfFilesToIterateForInvalidation present, + // just invalidated all files and recalculate the resolutions for files instead + if (hasReachedResolutionIterationLimit()) { + allFilesHaveInvalidatedResolution = true; + return; + } + invalidateResolutionCache(resolvedModuleNames, isInvalidatedResolution, getResolvedModule); + invalidateResolutionCache(resolvedTypeReferenceDirectives, isInvalidatedResolution, getResolvedTypeReferenceDirective); + } + + function invalidateResolutionOfFile(filePath: Path) { + removeResolutionsOfFile(filePath); + invalidateResolutions( + // Resolution is invalidated if the resulting file name is same as the deleted file path + (resolution, getResolutionWithResolvedFileName) => { + const result = getResolutionWithResolvedFileName(resolution); + return result && resolutionHost.toPath(result.resolvedFileName) === filePath; + } + ); + } + + function invalidateResolutionOfFailedLookupLocation(fileOrDirectoryPath: Path, isCreatingWatchedDirectory: boolean) { + let isChangedFailedLookupLocation: (location: string) => boolean; + if (isCreatingWatchedDirectory) { + // Watching directory is created + // Invalidate any resolution has failed lookup in this directory + isChangedFailedLookupLocation = location => isInDirectoryPath(fileOrDirectoryPath, resolutionHost.toPath(location)); + } + else { + // Some file or directory in the watching directory is created + // Return early if it does not have any of the watching extension or not the custom failed lookup path + if (!isPathWithDefaultFailedLookupExtension(fileOrDirectoryPath) && !customFailedLookupPaths.has(fileOrDirectoryPath)) { + return false; + } + // Resolution need to be invalidated if failed lookup location is same as the file or directory getting created + isChangedFailedLookupLocation = location => resolutionHost.toPath(location) === fileOrDirectoryPath; + } + const hasChangedFailedLookupLocation = (resolution: ResolutionWithFailedLookupLocations) => some(resolution.failedLookupLocations, isChangedFailedLookupLocation); + const invalidatedFilesCount = filesWithInvalidatedResolutions && filesWithInvalidatedResolutions.size; + invalidateResolutions( + // Resolution is invalidated if the resulting file name is same as the deleted file path + hasChangedFailedLookupLocation + ); + return allFilesHaveInvalidatedResolution || filesWithInvalidatedResolutions && filesWithInvalidatedResolutions.size !== invalidatedFilesCount; + } + + function closeTypeRootsWatch() { + clearMap(typeRootsWatches, closeFileWatcher); + } + + function createTypeRootsWatch(_typeRootPath: string, typeRoot: string): FileWatcher { + // Create new watch and recursive info + return resolutionHost.watchTypeRootsDirectory(typeRoot, fileOrDirectory => { + const fileOrDirectoryPath = resolutionHost.toPath(fileOrDirectory); + if (resolutionHost.getCachedDirectoryStructureHost) { + // Since the file existance changed, update the sourceFiles cache + resolutionHost.getCachedDirectoryStructureHost().addOrDeleteFileOrDirectory(fileOrDirectory, fileOrDirectoryPath); + } + + // For now just recompile + // We could potentially store more data here about whether it was/would be really be used or not + // and with that determine to trigger compilation but for now this is enough + resolutionHost.onChangedAutomaticTypeDirectiveNames(); + }, WatchDirectoryFlags.Recursive); + } + + /** + * Watches the types that would get added as part of getAutomaticTypeDirectiveNames + * To be called when compiler options change + */ + function updateTypeRootsWatch() { + const options = resolutionHost.getCompilationSettings(); + if (options.types) { + // No need to do any watch since resolution cache is going to handle the failed lookups + // for the types added by this + closeTypeRootsWatch(); + return; + } + + // we need to assume the directories exist to ensure that we can get all the type root directories that get included + const typeRoots = getEffectiveTypeRoots(options, { directoryExists: returnTrue, getCurrentDirectory }); + if (typeRoots) { + mutateMap( + typeRootsWatches, + arrayToMap(typeRoots, tr => resolutionHost.toPath(tr)), + { + createNewValue: createTypeRootsWatch, + onDeleteValue: closeFileWatcher + } + ); + } + else { + closeTypeRootsWatch(); + } + } + } +} diff --git a/src/compiler/sys.ts b/src/compiler/sys.ts index 8a4c4ad22cc5b..fa3ec22f586cd 100644 --- a/src/compiler/sys.ts +++ b/src/compiler/sys.ts @@ -30,14 +30,27 @@ namespace ts { mtime?: Date; } - export interface System { - args: string[]; + /** + * Partial interface of the System thats needed to support the caching of directory structure + */ + export interface DirectoryStructureHost { newLine: string; useCaseSensitiveFileNames: boolean; write(s: string): void; readFile(path: string, encoding?: string): string | undefined; - getFileSize?(path: string): number; writeFile(path: string, data: string, writeByteOrderMark?: boolean): void; + fileExists(path: string): boolean; + directoryExists(path: string): boolean; + createDirectory(path: string): void; + getCurrentDirectory(): string; + getDirectories(path: string): string[]; + readDirectory(path: string, extensions?: ReadonlyArray, exclude?: ReadonlyArray, include?: ReadonlyArray, depth?: number): string[]; + exit(exitCode?: number): void; + } + + export interface System extends DirectoryStructureHost { + args: string[]; + getFileSize?(path: string): number; /** * @pollingInterval - this parameter is used in polling-based watchers and ignored in watchers that * use native OS file watching @@ -45,13 +58,7 @@ namespace ts { watchFile?(path: string, callback: FileWatcherCallback, pollingInterval?: number): FileWatcher; watchDirectory?(path: string, callback: DirectoryWatcherCallback, recursive?: boolean): FileWatcher; resolvePath(path: string): string; - fileExists(path: string): boolean; - directoryExists(path: string): boolean; - createDirectory(path: string): void; getExecutingFilePath(): string; - getCurrentDirectory(): string; - getDirectories(path: string): string[]; - readDirectory(path: string, extensions?: ReadonlyArray, exclude?: ReadonlyArray, include?: ReadonlyArray, depth?: number): string[]; getModifiedTime?(path: string): Date; /** * This should be cryptographically secure. @@ -59,7 +66,6 @@ namespace ts { */ createHash?(data: string): string; getMemoryUsage?(): number; - exit(exitCode?: number): void; realpath?(path: string): string; /*@internal*/ getEnvironmentVariable(name: string): string; /*@internal*/ tryEnableSourceMapsForHost?(): void; @@ -72,8 +78,7 @@ namespace ts { close(): void; } - export interface DirectoryWatcher extends FileWatcher { - directoryName: string; + interface DirectoryWatcher extends FileWatcher { referenceCount: number; } @@ -152,11 +157,10 @@ namespace ts { watcher.referenceCount += 1; return; } - watcher = _fs.watch( + watcher = fsWatchDirectory( dirPath || ".", - { persistent: true }, (eventName: string, relativeFileName: string) => fileEventHandler(eventName, relativeFileName, dirPath) - ); + ) as DirectoryWatcher; watcher.referenceCount = 1; dirWatchers.set(dirPath, watcher); return; @@ -182,9 +186,9 @@ namespace ts { fileWatcherCallbacks.remove(filePath, callback); } - function fileEventHandler(eventName: string, relativeFileName: string, baseDirPath: string) { + function fileEventHandler(eventName: string, relativeFileName: string | undefined, baseDirPath: string) { // When files are deleted from disk, the triggered "rename" event would have a relativefileName of "undefined" - const fileName = typeof relativeFileName !== "string" + const fileName = !isString(relativeFileName) ? undefined : ts.getNormalizedAbsolutePath(relativeFileName, baseDirPath); // Some applications save a working file via rename operations @@ -223,6 +227,95 @@ namespace ts { const platform: string = _os.platform(); const useCaseSensitiveFileNames = isFileSystemCaseSensitive(); + function fsWatchFile(fileName: string, callback: FileWatcherCallback, pollingInterval?: number): FileWatcher { + _fs.watchFile(fileName, { persistent: true, interval: pollingInterval || 250 }, fileChanged); + return { + close: () => _fs.unwatchFile(fileName, fileChanged) + }; + + function fileChanged(curr: any, prev: any) { + const isCurrZero = +curr.mtime === 0; + const isPrevZero = +prev.mtime === 0; + const created = !isCurrZero && isPrevZero; + const deleted = isCurrZero && !isPrevZero; + + const eventKind = created + ? FileWatcherEventKind.Created + : deleted + ? FileWatcherEventKind.Deleted + : FileWatcherEventKind.Changed; + + if (eventKind === FileWatcherEventKind.Changed && +curr.mtime <= +prev.mtime) { + return; + } + + callback(fileName, eventKind); + } + } + + function fsWatchDirectory(directoryName: string, callback: (eventName: string, relativeFileName: string) => void, recursive?: boolean): FileWatcher { + let options: any; + /** Watcher for the directory depending on whether it is missing or present */ + let watcher = !directoryExists(directoryName) ? + watchMissingDirectory() : + watchPresentDirectory(); + return { + close: () => { + // Close the watcher (either existing directory watcher or missing directory watcher) + watcher.close(); + } + }; + + /** + * Watch the directory that is currently present + * and when the watched directory is deleted, switch to missing directory watcher + */ + function watchPresentDirectory(): FileWatcher { + // Node 4.0 `fs.watch` function supports the "recursive" option on both OSX and Windows + // (ref: https://github.com/nodejs/node/pull/2649 and https://github.com/Microsoft/TypeScript/issues/4643) + if (options === undefined) { + if (isNode4OrLater && (process.platform === "win32" || process.platform === "darwin")) { + options = { persistent: true, recursive: !!recursive }; + } + else { + options = { persistent: true }; + } + } + + const dirWatcher = _fs.watch( + directoryName, + options, + callback + ); + dirWatcher.on("error", () => { + if (!directoryExists(directoryName)) { + // Deleting directory + watcher = watchMissingDirectory(); + // Call the callback for current directory + callback("rename", ""); + } + }); + return dirWatcher; + } + + /** + * Watch the directory that is missing + * and switch to existing directory when the directory is created + */ + function watchMissingDirectory(): FileWatcher { + return fsWatchFile(directoryName, (_fileName, eventKind) => { + if (eventKind === FileWatcherEventKind.Created && directoryExists(directoryName)) { + watcher.close(); + watcher = watchPresentDirectory(); + // Call the callback for current directory + // For now it could be callback for the inner directory creation, + // but just return current directory, better than current no-op + callback("rename", ""); + } + }); + } + } + function readFile(fileName: string, _encoding?: string): string | undefined { if (!fileExists(fileName)) { return undefined; @@ -340,7 +433,6 @@ namespace ts { return filter(_fs.readdirSync(path), dir => fileSystemEntryExists(combinePaths(path, dir), FileSystemEntryKind.Directory)); } - const noOpFileWatcher: FileWatcher = { close: noop }; const nodeSystem: System = { args: process.argv.slice(2), newLine: _os.EOL, @@ -358,60 +450,21 @@ namespace ts { }; } else { - _fs.watchFile(fileName, { persistent: true, interval: pollingInterval || 250 }, fileChanged); - return { - close: () => _fs.unwatchFile(fileName, fileChanged) - }; - } - - function fileChanged(curr: any, prev: any) { - const isCurrZero = +curr.mtime === 0; - const isPrevZero = +prev.mtime === 0; - const created = !isCurrZero && isPrevZero; - const deleted = isCurrZero && !isPrevZero; - - const eventKind = created - ? FileWatcherEventKind.Created - : deleted - ? FileWatcherEventKind.Deleted - : FileWatcherEventKind.Changed; - - if (eventKind === FileWatcherEventKind.Changed && +curr.mtime <= +prev.mtime) { - return; - } - - callback(fileName, eventKind); + return fsWatchFile(fileName, callback, pollingInterval); } }, watchDirectory: (directoryName, callback, recursive) => { // Node 4.0 `fs.watch` function supports the "recursive" option on both OSX and Windows // (ref: https://github.com/nodejs/node/pull/2649 and https://github.com/Microsoft/TypeScript/issues/4643) - let options: any; - if (!directoryExists(directoryName)) { - // do nothing if target folder does not exist - return noOpFileWatcher; - } - - if (isNode4OrLater && (process.platform === "win32" || process.platform === "darwin")) { - options = { persistent: true, recursive: !!recursive }; - } - else { - options = { persistent: true }; - } - - return _fs.watch( - directoryName, - options, - (eventName: string, relativeFileName: string) => { - // In watchDirectory we only care about adding and removing files (when event name is - // "rename"); changes made within files are handled by corresponding fileWatchers (when - // event name is "change") - if (eventName === "rename") { - // When deleting a file, the passed baseFileName is null - callback(!relativeFileName ? relativeFileName : normalizePath(combinePaths(directoryName, relativeFileName))); - } + return fsWatchDirectory(directoryName, (eventName, relativeFileName) => { + // In watchDirectory we only care about adding and removing files (when event name is + // "rename"); changes made within files are handled by corresponding fileWatchers (when + // event name is "change") + if (eventName === "rename") { + // When deleting a file, the passed baseFileName is null + callback(!relativeFileName ? relativeFileName : normalizePath(combinePaths(directoryName, relativeFileName))); } - ); + }, recursive); }, resolvePath: path => _path.resolve(path), fileExists, diff --git a/src/compiler/tsc.ts b/src/compiler/tsc.ts index b109b166f5ca1..680daeeb4856f 100644 --- a/src/compiler/tsc.ts +++ b/src/compiler/tsc.ts @@ -1,48 +1,13 @@ /// +/// /// namespace ts { - export interface SourceFile { - fileWatcher?: FileWatcher; - } - interface Statistic { name: string; value: string; } - const defaultFormatDiagnosticsHost: FormatDiagnosticsHost = { - getCurrentDirectory: () => sys.getCurrentDirectory(), - getNewLine: () => sys.newLine, - getCanonicalFileName: createGetCanonicalFileName(sys.useCaseSensitiveFileNames) - }; - - let reportDiagnosticWorker = reportDiagnosticSimply; - - function reportDiagnostic(diagnostic: Diagnostic, host: FormatDiagnosticsHost) { - reportDiagnosticWorker(diagnostic, host || defaultFormatDiagnosticsHost); - } - - function reportDiagnostics(diagnostics: Diagnostic[], host: FormatDiagnosticsHost): void { - for (const diagnostic of diagnostics) { - reportDiagnostic(diagnostic, host); - } - } - - function reportEmittedFiles(files: string[]): void { - if (!files || files.length === 0) { - return; - } - - const currentDir = sys.getCurrentDirectory(); - - for (const file of files) { - const filepath = getNormalizedAbsolutePath(file, currentDir); - - sys.write(`TSFILE: ${filepath}${sys.newLine}`); - } - } - function countLines(program: Program): number { let count = 0; forEach(program.getSourceFiles(), file => { @@ -56,25 +21,11 @@ namespace ts { return diagnostic.messageText; } - function reportDiagnosticSimply(diagnostic: Diagnostic, host: FormatDiagnosticsHost): void { - sys.write(ts.formatDiagnostics([diagnostic], host)); - } - - function reportDiagnosticWithColorAndContext(diagnostic: Diagnostic, host: FormatDiagnosticsHost): void { - sys.write(ts.formatDiagnosticsWithColorAndContext([diagnostic], host) + sys.newLine); - } - - function reportWatchDiagnostic(diagnostic: Diagnostic) { - let output = new Date().toLocaleTimeString() + " - "; - - if (diagnostic.file) { - const loc = getLineAndCharacterOfPosition(diagnostic.file, diagnostic.start); - output += `${ diagnostic.file.fileName }(${ loc.line + 1 },${ loc.character + 1 }): `; + let reportDiagnostic = createDiagnosticReporter(sys, reportDiagnosticSimply); + function udpateReportDiagnostic(options: CompilerOptions) { + if (options.pretty) { + reportDiagnostic = createDiagnosticReporter(sys, reportDiagnosticWithColorAndContext); } - - output += `${ flattenDiagnosticMessageText(diagnostic.messageText, sys.newLine) }${ sys.newLine + sys.newLine + sys.newLine }`; - - sys.write(output); } function padLeft(s: string, length: number) { @@ -98,25 +49,12 @@ namespace ts { export function executeCommandLine(args: string[]): void { const commandLine = parseCommandLine(args); - let configFileName: string; // Configuration file name (if any) - let cachedConfigFileText: string; // Cached configuration file text, used for reparsing (if any) - let directoryWatcher: FileWatcher; // Directory watcher to monitor source file addition/removal - let cachedProgram: Program; // Program cached from last compilation - let rootFileNames: string[]; // Root fileNames for compilation - let compilerOptions: CompilerOptions; // Compiler options for compilation - let compilerHost: CompilerHost; // Compiler host - let hostGetSourceFile: typeof compilerHost.getSourceFile; // getSourceFile method from default host - let timerHandleForRecompilation: any; // Handle for 0.25s wait timer to trigger recompilation - let timerHandleForDirectoryChanges: any; // Handle for 0.25s wait timer to trigger directory change handler - - // This map stores and reuses results of fileExists check that happen inside 'createProgram' - // This allows to save time in module resolution heavy scenarios when existence of the same file might be checked multiple times. - let cachedExistingFiles: Map; - let hostFileExists: typeof compilerHost.fileExists; + // Configuration file name (if any) + let configFileName: string; if (commandLine.options.locale) { if (!isJSONSupported()) { - reportDiagnostic(createCompilerDiagnostic(Diagnostics.The_current_host_does_not_support_the_0_option, "--locale"), /* host */ undefined); + reportDiagnostic(createCompilerDiagnostic(Diagnostics.The_current_host_does_not_support_the_0_option, "--locale")); return sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); } validateLocaleAndSetLanguage(commandLine.options.locale, sys, commandLine.errors); @@ -125,7 +63,7 @@ namespace ts { // If there are any errors due to command line parsing and/or // setting up localization, report them and quit. if (commandLine.errors.length > 0) { - reportDiagnostics(commandLine.errors, compilerHost); + reportDiagnostics(commandLine.errors, reportDiagnostic); return sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); } @@ -147,11 +85,11 @@ namespace ts { if (commandLine.options.project) { if (!isJSONSupported()) { - reportDiagnostic(createCompilerDiagnostic(Diagnostics.The_current_host_does_not_support_the_0_option, "--project"), /* host */ undefined); + reportDiagnostic(createCompilerDiagnostic(Diagnostics.The_current_host_does_not_support_the_0_option, "--project")); return sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); } if (commandLine.fileNames.length !== 0) { - reportDiagnostic(createCompilerDiagnostic(Diagnostics.Option_project_cannot_be_mixed_with_source_files_on_a_command_line), /* host */ undefined); + reportDiagnostic(createCompilerDiagnostic(Diagnostics.Option_project_cannot_be_mixed_with_source_files_on_a_command_line)); return sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); } @@ -159,14 +97,14 @@ namespace ts { if (!fileOrDirectory /* current directory "." */ || sys.directoryExists(fileOrDirectory)) { configFileName = combinePaths(fileOrDirectory, "tsconfig.json"); if (!sys.fileExists(configFileName)) { - reportDiagnostic(createCompilerDiagnostic(Diagnostics.Cannot_find_a_tsconfig_json_file_at_the_specified_directory_Colon_0, commandLine.options.project), /* host */ undefined); + reportDiagnostic(createCompilerDiagnostic(Diagnostics.Cannot_find_a_tsconfig_json_file_at_the_specified_directory_Colon_0, commandLine.options.project)); return sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); } } else { configFileName = fileOrDirectory; if (!sys.fileExists(configFileName)) { - reportDiagnostic(createCompilerDiagnostic(Diagnostics.The_specified_path_does_not_exist_Colon_0, commandLine.options.project), /* host */ undefined); + reportDiagnostic(createCompilerDiagnostic(Diagnostics.The_specified_path_does_not_exist_Colon_0, commandLine.options.project)); return sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); } } @@ -182,249 +120,94 @@ namespace ts { return sys.exit(ExitStatus.Success); } - if (isWatchSet(commandLine.options)) { - if (!sys.watchFile) { - reportDiagnostic(createCompilerDiagnostic(Diagnostics.The_current_host_does_not_support_the_0_option, "--watch"), /* host */ undefined); - return sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); - } - if (configFileName) { - sys.watchFile(configFileName, configFileChanged); - } - if (sys.watchDirectory && configFileName) { - const directory = ts.getDirectoryPath(configFileName); - directoryWatcher = sys.watchDirectory( - // When the configFileName is just "tsconfig.json", the watched directory should be - // the current directory; if there is a given "project" parameter, then the configFileName - // is an absolute file name. - directory === "" ? "." : directory, - watchedDirectoryChanged, /*recursive*/ true); - } - } - - performCompilation(); - - function parseConfigFile(): ParsedCommandLine { - if (!cachedConfigFileText) { - try { - cachedConfigFileText = sys.readFile(configFileName); - } - catch (e) { - const error = createCompilerDiagnostic(Diagnostics.Cannot_read_file_0_Colon_1, configFileName, e.message); - reportWatchDiagnostic(error); - sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); - return; - } - } - if (!cachedConfigFileText) { - const error = createCompilerDiagnostic(Diagnostics.File_0_not_found, configFileName); - reportDiagnostics([error], /* compilerHost */ undefined); - sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); - return; - } - - const result = parseJsonText(configFileName, cachedConfigFileText); - reportDiagnostics(result.parseDiagnostics, /* compilerHost */ undefined); - - const cwd = sys.getCurrentDirectory(); - const configParseResult = parseJsonSourceFileConfigFileContent(result, sys, getNormalizedAbsolutePath(getDirectoryPath(configFileName), cwd), commandLine.options, getNormalizedAbsolutePath(configFileName, cwd)); - reportDiagnostics(configParseResult.errors, /* compilerHost */ undefined); - + const commandLineOptions = commandLine.options; + if (configFileName) { + const reportWatchDiagnostic = createWatchDiagnosticReporter(); + const configParseResult = parseConfigFile(configFileName, commandLineOptions, sys, reportDiagnostic, reportWatchDiagnostic); + udpateReportDiagnostic(configParseResult.options); if (isWatchSet(configParseResult.options)) { - if (!sys.watchFile) { - reportDiagnostic(createCompilerDiagnostic(Diagnostics.The_current_host_does_not_support_the_0_option, "--watch"), /* host */ undefined); - sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); - } - - if (!directoryWatcher && sys.watchDirectory && configFileName) { - const directory = ts.getDirectoryPath(configFileName); - directoryWatcher = sys.watchDirectory( - // When the configFileName is just "tsconfig.json", the watched directory should be - // the current directory; if there is a given "project" parameter, then the configFileName - // is an absolute file name. - directory === "" ? "." : directory, - watchedDirectoryChanged, /*recursive*/ true); - } + reportWatchModeWithoutSysSupport(); + createWatchModeWithConfigFile(configParseResult, commandLineOptions, createWatchingSystemHost(reportWatchDiagnostic)); } - return configParseResult; - } - - // Invoked to perform initial compilation or re-compilation in watch mode - function performCompilation() { - - if (!cachedProgram) { - if (configFileName) { - const configParseResult = parseConfigFile(); - rootFileNames = configParseResult.fileNames; - compilerOptions = configParseResult.options; - } - else { - rootFileNames = commandLine.fileNames; - compilerOptions = commandLine.options; - } - compilerHost = createCompilerHost(compilerOptions); - hostGetSourceFile = compilerHost.getSourceFile; - compilerHost.getSourceFile = getSourceFile; - - hostFileExists = compilerHost.fileExists; - compilerHost.fileExists = cachedFileExists; - } - - if (compilerOptions.pretty) { - reportDiagnosticWorker = reportDiagnosticWithColorAndContext; - } - - // reset the cache of existing files - cachedExistingFiles = createMap(); - - const compileResult = compile(rootFileNames, compilerOptions, compilerHost); - - if (!isWatchSet(compilerOptions)) { - return sys.exit(compileResult.exitStatus); - } - - setCachedProgram(compileResult.program); - reportWatchDiagnostic(createCompilerDiagnostic(Diagnostics.Compilation_complete_Watching_for_file_changes)); - - const missingPaths = compileResult.program.getMissingFilePaths(); - missingPaths.forEach(path => { - const fileWatcher = sys.watchFile(path, (_fileName, eventKind) => { - if (eventKind === FileWatcherEventKind.Created) { - fileWatcher.close(); - startTimerForRecompilation(); - } - }); - }); - } - - function cachedFileExists(fileName: string): boolean { - let fileExists = cachedExistingFiles.get(fileName); - if (fileExists === undefined) { - cachedExistingFiles.set(fileName, fileExists = hostFileExists(fileName)); + else { + performCompilation(configParseResult.fileNames, configParseResult.options); } - return fileExists; } - - function getSourceFile(fileName: string, languageVersion: ScriptTarget, onError?: (message: string) => void) { - // Return existing SourceFile object if one is available - if (cachedProgram) { - const sourceFile = cachedProgram.getSourceFile(fileName); - // A modified source file has no watcher and should not be reused - if (sourceFile && sourceFile.fileWatcher) { - return sourceFile; - } + else { + udpateReportDiagnostic(commandLineOptions); + if (isWatchSet(commandLineOptions)) { + reportWatchModeWithoutSysSupport(); + createWatchModeWithoutConfigFile(commandLine.fileNames, commandLineOptions, createWatchingSystemHost()); } - // Use default host function - const sourceFile = hostGetSourceFile(fileName, languageVersion, onError); - if (sourceFile && isWatchSet(compilerOptions) && sys.watchFile) { - // Attach a file watcher - sourceFile.fileWatcher = sys.watchFile(sourceFile.fileName, (_fileName, eventKind) => sourceFileChanged(sourceFile, eventKind)); + else { + performCompilation(commandLine.fileNames, commandLineOptions); } - return sourceFile; } + } - // Change cached program to the given program - function setCachedProgram(program: Program) { - if (cachedProgram) { - const newSourceFiles = program ? program.getSourceFiles() : undefined; - forEach(cachedProgram.getSourceFiles(), sourceFile => { - if (!(newSourceFiles && contains(newSourceFiles, sourceFile))) { - if (sourceFile.fileWatcher) { - sourceFile.fileWatcher.close(); - sourceFile.fileWatcher = undefined; - } - } - }); - } - cachedProgram = program; + function reportWatchModeWithoutSysSupport() { + if (!sys.watchFile || !sys.watchDirectory) { + reportDiagnostic(createCompilerDiagnostic(Diagnostics.The_current_host_does_not_support_the_0_option, "--watch")); + sys.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); } + } - // If a source file changes, mark it as unwatched and start the recompilation timer - function sourceFileChanged(sourceFile: SourceFile, eventKind: FileWatcherEventKind) { - sourceFile.fileWatcher.close(); - sourceFile.fileWatcher = undefined; - if (eventKind === FileWatcherEventKind.Deleted) { - unorderedRemoveItem(rootFileNames, sourceFile.fileName); - } - startTimerForRecompilation(); - } + function performCompilation(rootFileNames: string[], compilerOptions: CompilerOptions) { + const compilerHost = createCompilerHost(compilerOptions); + enableStatistics(compilerOptions); - // If the configuration file changes, forget cached program and start the recompilation timer - function configFileChanged() { - setCachedProgram(undefined); - cachedConfigFileText = undefined; - startTimerForRecompilation(); - } + const program = createProgram(rootFileNames, compilerOptions, compilerHost); + const exitStatus = compileProgram(program); - function watchedDirectoryChanged(fileName: string) { - if (fileName && !ts.isSupportedSourceFileName(fileName, compilerOptions)) { - return; - } + reportStatistics(program); + return sys.exit(exitStatus); + } - startTimerForHandlingDirectoryChanges(); - } + function createWatchingSystemHost(reportWatchDiagnostic?: DiagnosticReporter) { + const watchingHost = ts.createWatchingSystemHost(/*pretty*/ undefined, sys, parseConfigFile, reportDiagnostic, reportWatchDiagnostic); + watchingHost.beforeCompile = enableStatistics; + const afterCompile = watchingHost.afterCompile; + watchingHost.afterCompile = (host, program, builder) => { + afterCompile(host, program, builder); + reportStatistics(program); + }; + return watchingHost; + } - function startTimerForHandlingDirectoryChanges() { - if (!sys.setTimeout || !sys.clearTimeout) { - return; - } + function compileProgram(program: Program): ExitStatus { + let diagnostics: Diagnostic[]; - if (timerHandleForDirectoryChanges) { - sys.clearTimeout(timerHandleForDirectoryChanges); - } - timerHandleForDirectoryChanges = sys.setTimeout(directoryChangeHandler, 250); - } + // First get and report any syntactic errors. + diagnostics = program.getSyntacticDiagnostics().slice(); - function directoryChangeHandler() { - const parsedCommandLine = parseConfigFile(); - const newFileNames = ts.map(parsedCommandLine.fileNames, compilerHost.getCanonicalFileName); - const canonicalRootFileNames = ts.map(rootFileNames, compilerHost.getCanonicalFileName); + // If we didn't have any syntactic errors, then also try getting the global and + // semantic errors. + if (diagnostics.length === 0) { + diagnostics = program.getOptionsDiagnostics().concat(program.getGlobalDiagnostics()); - // We check if the project file list has changed. If so, we just throw away the old program and start fresh. - if (!arrayIsEqualTo(newFileNames && newFileNames.sort(), canonicalRootFileNames && canonicalRootFileNames.sort())) { - setCachedProgram(undefined); - startTimerForRecompilation(); + if (diagnostics.length === 0) { + diagnostics = program.getSemanticDiagnostics().slice(); } } - // Upon detecting a file change, wait for 250ms and then perform a recompilation. This gives batch - // operations (such as saving all modified files in an editor) a chance to complete before we kick - // off a new compilation. - function startTimerForRecompilation() { - if (!sys.setTimeout || !sys.clearTimeout) { - return; - } + // Emit and report any errors we ran into. + const { emittedFiles, emitSkipped, diagnostics: emitDiagnostics } = program.emit(); + addRange(diagnostics, emitDiagnostics); - if (timerHandleForRecompilation) { - sys.clearTimeout(timerHandleForRecompilation); - } - timerHandleForRecompilation = sys.setTimeout(recompile, 250); - } + return handleEmitOutputAndReportErrors(sys, program, emittedFiles, emitSkipped, diagnostics, reportDiagnostic); + } - function recompile() { - timerHandleForRecompilation = undefined; - reportWatchDiagnostic(createCompilerDiagnostic(Diagnostics.File_change_detected_Starting_incremental_compilation)); - performCompilation(); + function enableStatistics(compilerOptions: CompilerOptions) { + if (compilerOptions.diagnostics || compilerOptions.extendedDiagnostics) { + performance.enable(); } } - function compile(fileNames: string[], compilerOptions: CompilerOptions, compilerHost: CompilerHost) { - const hasDiagnostics = compilerOptions.diagnostics || compilerOptions.extendedDiagnostics; + function reportStatistics(program: Program) { let statistics: Statistic[]; - if (hasDiagnostics) { - performance.enable(); + const compilerOptions = program.getCompilerOptions(); + if (compilerOptions.diagnostics || compilerOptions.extendedDiagnostics) { statistics = []; - } - - const program = createProgram(fileNames, compilerOptions, compilerHost); - const exitStatus = compileProgram(); - - if (compilerOptions.listFiles) { - forEach(program.getSourceFiles(), file => { - sys.write(file.fileName + sys.newLine); - }); - } - - if (hasDiagnostics) { const memoryUsed = sys.getMemoryUsage ? sys.getMemoryUsage() : -1; reportCountStatistic("Files", program.getSourceFiles().length); reportCountStatistic("Lines", countLines(program)); @@ -462,44 +245,6 @@ namespace ts { performance.disable(); } - return { program, exitStatus }; - - function compileProgram(): ExitStatus { - let diagnostics: Diagnostic[]; - - // First get and report any syntactic errors. - diagnostics = program.getSyntacticDiagnostics().slice(); - - // If we didn't have any syntactic errors, then also try getting the global and - // semantic errors. - if (diagnostics.length === 0) { - diagnostics = program.getOptionsDiagnostics().concat(program.getGlobalDiagnostics()); - - if (diagnostics.length === 0) { - diagnostics = program.getSemanticDiagnostics().slice(); - } - } - - // Otherwise, emit and report any errors we ran into. - const emitOutput = program.emit(); - addRange(diagnostics, emitOutput.diagnostics); - - reportDiagnostics(sortAndDeduplicateDiagnostics(diagnostics), compilerHost); - - reportEmittedFiles(emitOutput.emittedFiles); - - if (emitOutput.emitSkipped && diagnostics.length > 0) { - // If the emitter didn't emit anything, then pass that value along. - return ExitStatus.DiagnosticsPresent_OutputsSkipped; - } - else if (diagnostics.length > 0) { - // The emitter emitted something, inform the caller if that happened in the presence - // of diagnostics or not. - return ExitStatus.DiagnosticsPresent_OutputsGenerated; - } - return ExitStatus.Success; - } - function reportStatistics() { let nameSize = 0; let valueSize = 0; @@ -653,19 +398,17 @@ namespace ts { const currentDirectory = sys.getCurrentDirectory(); const file = normalizePath(combinePaths(currentDirectory, "tsconfig.json")); if (sys.fileExists(file)) { - reportDiagnostic(createCompilerDiagnostic(Diagnostics.A_tsconfig_json_file_is_already_defined_at_Colon_0, file), /* host */ undefined); + reportDiagnostic(createCompilerDiagnostic(Diagnostics.A_tsconfig_json_file_is_already_defined_at_Colon_0, file)); } else { sys.writeFile(file, generateTSConfig(options, fileNames, sys.newLine)); - reportDiagnostic(createCompilerDiagnostic(Diagnostics.Successfully_created_a_tsconfig_json_file), /* host */ undefined); + reportDiagnostic(createCompilerDiagnostic(Diagnostics.Successfully_created_a_tsconfig_json_file)); } return; } } -ts.setStackTraceLimit(); - if (ts.Debug.isDebugging) { ts.Debug.enableDebugInfo(); } @@ -673,5 +416,4 @@ if (ts.Debug.isDebugging) { if (ts.sys.tryEnableSourceMapsForHost && /^development$/i.test(ts.sys.getEnvironmentVariable("NODE_ENV"))) { ts.sys.tryEnableSourceMapsForHost(); } - ts.executeCommandLine(ts.sys.args); diff --git a/src/compiler/tsconfig.json b/src/compiler/tsconfig.json index c048359fcb7a4..07f69ddfe2830 100644 --- a/src/compiler/tsconfig.json +++ b/src/compiler/tsconfig.json @@ -36,7 +36,11 @@ "sourcemap.ts", "declarationEmitter.ts", "emitter.ts", + "watchUtilities.ts", "program.ts", + "builder.ts", + "resolutionCache.ts", + "watch.ts", "commandLineParser.ts", "tsc.ts", "diagnosticInformationMap.generated.ts" diff --git a/src/compiler/types.ts b/src/compiler/types.ts index 010368629809d..3ea2170d2db92 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -2408,6 +2408,7 @@ namespace ts { /* @internal */ patternAmbientModules?: PatternAmbientModule[]; /* @internal */ ambientModuleNames: ReadonlyArray; /* @internal */ checkJsDirective: CheckJsDirective | undefined; + /* @internal */ version: string; } export interface Bundle extends Node { @@ -2524,6 +2525,8 @@ namespace ts { /* @internal */ sourceFileToPackageName: Map; /** Set of all source files that some other source file redirects to. */ /* @internal */ redirectTargetsSet: Map; + /** Returns true when file in the program had invalidated resolution at the time of program creation. */ + hasInvalidatedResolution: HasInvalidatedResolution; } /* @internal */ @@ -3659,6 +3662,7 @@ namespace ts { charset?: string; checkJs?: boolean; /* @internal */ configFilePath?: string; + /** configFile is set as non enumerable property so as to avoid checking of json source files */ /* @internal */ readonly configFile?: JsonSourceFile; declaration?: boolean; declarationDir?: string; @@ -3828,6 +3832,7 @@ namespace ts { errors: Diagnostic[]; wildcardDirectories?: MapLike; compileOnSave?: boolean; + configFileSpecs?: ConfigFileSpecs; } export const enum WatchDirectoryFlags { @@ -3835,9 +3840,25 @@ namespace ts { Recursive = 1 << 0, } + export interface ConfigFileSpecs { + filesSpecs: ReadonlyArray; + /** + * Present to report errors (user specified specs), validatedIncludeSpecs are used for file name matching + */ + includeSpecs: ReadonlyArray; + /** + * Present to report errors (user specified specs), validatedExcludeSpecs are used for file name matching + */ + excludeSpecs: ReadonlyArray; + validatedIncludeSpecs: ReadonlyArray; + validatedExcludeSpecs: ReadonlyArray; + wildcardDirectories: MapLike; + } + export interface ExpandResult { fileNames: string[]; wildcardDirectories: MapLike; + spec: ConfigFileSpecs; } /* @internal */ @@ -4086,31 +4107,36 @@ namespace ts { Tsx = ".tsx", Dts = ".d.ts", Js = ".js", - Jsx = ".jsx" + Jsx = ".jsx", + Json = ".json" } export interface ResolvedModuleWithFailedLookupLocations { - resolvedModule: ResolvedModuleFull | undefined; + readonly resolvedModule: ResolvedModuleFull | undefined; /* @internal */ - failedLookupLocations: string[]; + readonly failedLookupLocations: ReadonlyArray; } export interface ResolvedTypeReferenceDirective { // True if the type declaration file was found in a primary lookup location primary: boolean; // The location of the .d.ts file we located, or undefined if resolution failed - resolvedFileName?: string; + resolvedFileName: string | undefined; packageId?: PackageId; } export interface ResolvedTypeReferenceDirectiveWithFailedLookupLocations { - resolvedTypeReferenceDirective: ResolvedTypeReferenceDirective; - failedLookupLocations: string[]; + readonly resolvedTypeReferenceDirective: ResolvedTypeReferenceDirective; + readonly failedLookupLocations: ReadonlyArray; + } + + export interface HasInvalidatedResolution { + (sourceFile: Path): boolean; } export interface CompilerHost extends ModuleResolutionHost { - getSourceFile(fileName: string, languageVersion: ScriptTarget, onError?: (message: string) => void): SourceFile | undefined; - getSourceFileByPath?(fileName: string, path: Path, languageVersion: ScriptTarget, onError?: (message: string) => void): SourceFile | undefined; + getSourceFile(fileName: string, languageVersion: ScriptTarget, onError?: (message: string) => void, shouldCreateNewSourceFile?: boolean): SourceFile | undefined; + getSourceFileByPath?(fileName: string, path: Path, languageVersion: ScriptTarget, onError?: (message: string) => void, shouldCreateNewSourceFile?: boolean): SourceFile | undefined; getCancellationToken?(): CancellationToken; getDefaultLibFileName(options: CompilerOptions): string; getDefaultLibLocation?(): string; @@ -4128,12 +4154,15 @@ namespace ts { * If resolveModuleNames is implemented then implementation for members from ModuleResolutionHost can be just * 'throw new Error("NotImplemented")' */ - resolveModuleNames?(moduleNames: string[], containingFile: string): ResolvedModule[]; + resolveModuleNames?(moduleNames: string[], containingFile: string, reusedNames?: string[]): ResolvedModule[]; /** * This method is a companion for 'resolveModuleNames' and is used to resolve 'types' references to actual type declaration files */ resolveTypeReferenceDirectives?(typeReferenceDirectiveNames: string[], containingFile: string): ResolvedTypeReferenceDirective[]; getEnvironmentVariable?(name: string): string; + onReleaseOldSourceFile?(oldSourceFile: SourceFile, oldOptions: CompilerOptions): void; + hasInvalidatedResolution?: HasInvalidatedResolution; + hasChangedAutomaticTypeDirectiveNames?: boolean; } /* @internal */ diff --git a/src/compiler/utilities.ts b/src/compiler/utilities.ts index 23888f0b6d566..f0eb394adb7dd 100644 --- a/src/compiler/utilities.ts +++ b/src/compiler/utilities.ts @@ -377,7 +377,7 @@ namespace ts { } export function getTextOfConstantValue(value: string | number) { - return typeof value === "string" ? '"' + escapeNonAsciiString(value) + '"' : "" + value; + return isString(value) ? '"' + escapeNonAsciiString(value) + '"' : "" + value; } // Add an extra underscore to identifiers that start with two underscores to avoid issues with magic names like '__proto__' @@ -414,7 +414,10 @@ namespace ts { ((node).name.kind === SyntaxKind.StringLiteral || isGlobalScopeAugmentation(node)); } - /* @internal */ + export function isModuleWithStringLiteralName(node: Node): node is ModuleDeclaration { + return isModuleDeclaration(node) && node.name.kind === SyntaxKind.StringLiteral; + } + export function isNonGlobalAmbientModule(node: Node): node is ModuleDeclaration & { name: StringLiteral } { return isModuleDeclaration(node) && isStringLiteral(node.name); } @@ -1459,8 +1462,8 @@ namespace ts { if (node.kind === SyntaxKind.ExportDeclaration) { return (node).moduleSpecifier; } - if (node.kind === SyntaxKind.ModuleDeclaration && (node).name.kind === SyntaxKind.StringLiteral) { - return (node).name; + if (isModuleWithStringLiteralName(node)) { + return node.name; } } @@ -3195,17 +3198,14 @@ namespace ts { const carriageReturnLineFeed = "\r\n"; const lineFeed = "\n"; - export function getNewLineCharacter(options: CompilerOptions | PrinterOptions): string { + export function getNewLineCharacter(options: CompilerOptions | PrinterOptions, system?: System): string { switch (options.newLine) { case NewLineKind.CarriageReturnLineFeed: return carriageReturnLineFeed; case NewLineKind.LineFeed: return lineFeed; } - if (sys) { - return sys.newLine; - } - return carriageReturnLineFeed; + return system ? system.newLine : sys ? sys.newLine : carriageReturnLineFeed; } /** @@ -3499,6 +3499,93 @@ namespace ts { return parent.parent && parent.parent.kind === SyntaxKind.ExpressionStatement ? AccessKind.Write : AccessKind.ReadWrite; } } + + export function compareDataObjects(dst: any, src: any): boolean { + if (!dst || !src || Object.keys(dst).length !== Object.keys(src).length) { + return false; + } + + for (const e in dst) { + if (typeof dst[e] === "object") { + if (!compareDataObjects(dst[e], src[e])) { + return false; + } + } + else if (typeof dst[e] !== "function") { + if (dst[e] !== src[e]) { + return false; + } + } + } + return true; + } + + /** + * clears already present map by calling onDeleteExistingValue callback before deleting that key/value + */ + export function clearMap(map: Map, onDeleteValue: (valueInMap: T, key: string) => void) { + // Remove all + map.forEach(onDeleteValue); + map.clear(); + } + + export interface MutateMapOptions { + createNewValue(key: string, valueInNewMap: U): T; + onDeleteValue(existingValue: T, key: string): void; + + /** + * If present this is called with the key when there is value for that key both in new map as well as existing map provided + * Caller can then decide to update or remove this key. + * If the key is removed, caller will get callback of createNewValue for that key. + * If this callback is not provided, the value of such keys is not updated. + */ + onExistingValue?(existingValue: T, valueInNewMap: U, key: string): void; + } + + /** + * Mutates the map with newMap such that keys in map will be same as newMap. + */ + export function mutateMap(map: Map, newMap: ReadonlyMap, options: MutateMapOptions) { + const { createNewValue, onDeleteValue, onExistingValue } = options; + // Needs update + map.forEach((existingValue, key) => { + const valueInNewMap = newMap.get(key); + // Not present any more in new map, remove it + if (valueInNewMap === undefined) { + map.delete(key); + onDeleteValue(existingValue, key); + } + // If present notify about existing values + else if (onExistingValue) { + onExistingValue(existingValue, valueInNewMap, key); + } + }); + + // Add new values that are not already present + newMap.forEach((valueInNewMap, key) => { + if (!map.has(key)) { + // New values + map.set(key, createNewValue(key, valueInNewMap)); + } + }); + } + + /** Calls `callback` on `directory` and every ancestor directory it has, returning the first defined result. */ + export function forEachAncestorDirectory(directory: string, callback: (directory: string) => T): T { + while (true) { + const result = callback(directory); + if (result !== undefined) { + return result; + } + + const parentPath = getDirectoryPath(directory); + if (parentPath === directory) { + return undefined; + } + + directory = parentPath; + } + } } namespace ts { diff --git a/src/compiler/watch.ts b/src/compiler/watch.ts new file mode 100644 index 0000000000000..2900737b09f8b --- /dev/null +++ b/src/compiler/watch.ts @@ -0,0 +1,638 @@ +/// +/// +/// + +/* @internal */ +namespace ts { + export type DiagnosticReporter = (diagnostic: Diagnostic) => void; + export type ParseConfigFile = (configFileName: string, optionsToExtend: CompilerOptions, system: DirectoryStructureHost, reportDiagnostic: DiagnosticReporter, reportWatchDiagnostic: DiagnosticReporter) => ParsedCommandLine; + export interface WatchingSystemHost { + // FS system to use + system: System; + + // parse config file + parseConfigFile: ParseConfigFile; + + // Reporting errors + reportDiagnostic: DiagnosticReporter; + reportWatchDiagnostic: DiagnosticReporter; + + // Callbacks to do custom action before creating program and after creating program + beforeCompile(compilerOptions: CompilerOptions): void; + afterCompile(host: DirectoryStructureHost, program: Program, builder: Builder): void; + } + + const defaultFormatDiagnosticsHost: FormatDiagnosticsHost = sys ? { + getCurrentDirectory: () => sys.getCurrentDirectory(), + getNewLine: () => sys.newLine, + getCanonicalFileName: createGetCanonicalFileName(sys.useCaseSensitiveFileNames) + } : undefined; + + export function createDiagnosticReporter(system = sys, worker = reportDiagnosticSimply, formatDiagnosticsHost?: FormatDiagnosticsHost): DiagnosticReporter { + return diagnostic => worker(diagnostic, getFormatDiagnosticsHost(), system); + + function getFormatDiagnosticsHost() { + return formatDiagnosticsHost || (formatDiagnosticsHost = system === sys ? defaultFormatDiagnosticsHost : { + getCurrentDirectory: () => system.getCurrentDirectory(), + getNewLine: () => system.newLine, + getCanonicalFileName: createGetCanonicalFileName(system.useCaseSensitiveFileNames), + }); + } + } + + export function createWatchDiagnosticReporter(system = sys): DiagnosticReporter { + return diagnostic => { + let output = new Date().toLocaleTimeString() + " - "; + output += `${flattenDiagnosticMessageText(diagnostic.messageText, system.newLine)}${system.newLine + system.newLine + system.newLine}`; + system.write(output); + }; + } + + export function reportDiagnostics(diagnostics: Diagnostic[], reportDiagnostic: DiagnosticReporter): void { + for (const diagnostic of diagnostics) { + reportDiagnostic(diagnostic); + } + } + + export function reportDiagnosticSimply(diagnostic: Diagnostic, host: FormatDiagnosticsHost, system: System): void { + system.write(ts.formatDiagnostic(diagnostic, host)); + } + + export function reportDiagnosticWithColorAndContext(diagnostic: Diagnostic, host: FormatDiagnosticsHost, system: System): void { + system.write(ts.formatDiagnosticsWithColorAndContext([diagnostic], host) + host.getNewLine()); + } + + export function parseConfigFile(configFileName: string, optionsToExtend: CompilerOptions, system: DirectoryStructureHost, reportDiagnostic: DiagnosticReporter, reportWatchDiagnostic: DiagnosticReporter): ParsedCommandLine { + let configFileText: string; + try { + configFileText = system.readFile(configFileName); + } + catch (e) { + const error = createCompilerDiagnostic(Diagnostics.Cannot_read_file_0_Colon_1, configFileName, e.message); + reportWatchDiagnostic(error); + system.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); + return; + } + if (!configFileText) { + const error = createCompilerDiagnostic(Diagnostics.File_0_not_found, configFileName); + reportDiagnostics([error], reportDiagnostic); + system.exit(ExitStatus.DiagnosticsPresent_OutputsSkipped); + return; + } + + const result = parseJsonText(configFileName, configFileText); + reportDiagnostics(result.parseDiagnostics, reportDiagnostic); + + const cwd = system.getCurrentDirectory(); + const configParseResult = parseJsonSourceFileConfigFileContent(result, system, getNormalizedAbsolutePath(getDirectoryPath(configFileName), cwd), optionsToExtend, getNormalizedAbsolutePath(configFileName, cwd)); + reportDiagnostics(configParseResult.errors, reportDiagnostic); + + return configParseResult; + } + + function reportEmittedFiles(files: string[], system: DirectoryStructureHost): void { + if (!files || files.length === 0) { + return; + } + const currentDir = system.getCurrentDirectory(); + for (const file of files) { + const filepath = getNormalizedAbsolutePath(file, currentDir); + system.write(`TSFILE: ${filepath}${system.newLine}`); + } + } + + export function handleEmitOutputAndReportErrors(system: DirectoryStructureHost, program: Program, + emittedFiles: string[], emitSkipped: boolean, + diagnostics: Diagnostic[], reportDiagnostic: DiagnosticReporter + ): ExitStatus { + reportDiagnostics(sortAndDeduplicateDiagnostics(diagnostics), reportDiagnostic); + reportEmittedFiles(emittedFiles, system); + + if (program.getCompilerOptions().listFiles) { + forEach(program.getSourceFiles(), file => { + system.write(file.fileName + system.newLine); + }); + } + + if (emitSkipped && diagnostics.length > 0) { + // If the emitter didn't emit anything, then pass that value along. + return ExitStatus.DiagnosticsPresent_OutputsSkipped; + } + else if (diagnostics.length > 0) { + // The emitter emitted something, inform the caller if that happened in the presence + // of diagnostics or not. + return ExitStatus.DiagnosticsPresent_OutputsGenerated; + } + return ExitStatus.Success; + } + + export function createWatchingSystemHost(pretty?: DiagnosticStyle, system = sys, + parseConfigFile?: ParseConfigFile, reportDiagnostic?: DiagnosticReporter, + reportWatchDiagnostic?: DiagnosticReporter + ): WatchingSystemHost { + reportDiagnostic = reportDiagnostic || createDiagnosticReporter(system, pretty ? reportDiagnosticWithColorAndContext : reportDiagnosticSimply); + reportWatchDiagnostic = reportWatchDiagnostic || createWatchDiagnosticReporter(system); + parseConfigFile = parseConfigFile || ts.parseConfigFile; + return { + system, + parseConfigFile, + reportDiagnostic, + reportWatchDiagnostic, + beforeCompile: noop, + afterCompile: compileWatchedProgram, + }; + + function compileWatchedProgram(host: DirectoryStructureHost, program: Program, builder: Builder) { + // First get and report any syntactic errors. + let diagnostics = program.getSyntacticDiagnostics().slice(); + let reportSemanticDiagnostics = false; + + // If we didn't have any syntactic errors, then also try getting the global and + // semantic errors. + if (diagnostics.length === 0) { + diagnostics = program.getOptionsDiagnostics().concat(program.getGlobalDiagnostics()); + + if (diagnostics.length === 0) { + reportSemanticDiagnostics = true; + } + } + + // Emit and report any errors we ran into. + const emittedFiles: string[] = program.getCompilerOptions().listEmittedFiles ? [] : undefined; + let sourceMaps: SourceMapData[]; + let emitSkipped: boolean; + + const result = builder.emitChangedFiles(program); + if (result.length === 0) { + emitSkipped = true; + } + else { + for (const emitOutput of result) { + if (emitOutput.emitSkipped) { + emitSkipped = true; + } + diagnostics = concatenate(diagnostics, emitOutput.diagnostics); + sourceMaps = concatenate(sourceMaps, emitOutput.sourceMaps); + writeOutputFiles(emitOutput.outputFiles); + } + } + + if (reportSemanticDiagnostics) { + diagnostics = diagnostics.concat(builder.getSemanticDiagnostics(program)); + } + return handleEmitOutputAndReportErrors(host, program, emittedFiles, emitSkipped, + diagnostics, reportDiagnostic); + + function ensureDirectoriesExist(directoryPath: string) { + if (directoryPath.length > getRootLength(directoryPath) && !host.directoryExists(directoryPath)) { + const parentDirectory = getDirectoryPath(directoryPath); + ensureDirectoriesExist(parentDirectory); + host.createDirectory(directoryPath); + } + } + + function writeFile(fileName: string, data: string, writeByteOrderMark: boolean) { + try { + performance.mark("beforeIOWrite"); + ensureDirectoriesExist(getDirectoryPath(normalizePath(fileName))); + + host.writeFile(fileName, data, writeByteOrderMark); + + performance.mark("afterIOWrite"); + performance.measure("I/O Write", "beforeIOWrite", "afterIOWrite"); + } + catch (e) { + return createCompilerDiagnostic(Diagnostics.Could_not_write_file_0_Colon_1, fileName, e); + } + } + + function writeOutputFiles(outputFiles: OutputFile[]) { + if (outputFiles) { + for (const outputFile of outputFiles) { + const error = writeFile(outputFile.name, outputFile.text, outputFile.writeByteOrderMark); + if (error) { + diagnostics.push(error); + } + if (emittedFiles) { + emittedFiles.push(outputFile.name); + } + } + } + } + } + } + + export function createWatchModeWithConfigFile(configParseResult: ParsedCommandLine, optionsToExtend: CompilerOptions = {}, watchingHost?: WatchingSystemHost) { + return createWatchMode(configParseResult.fileNames, configParseResult.options, watchingHost, configParseResult.options.configFilePath, configParseResult.configFileSpecs, configParseResult.wildcardDirectories, optionsToExtend); + } + + export function createWatchModeWithoutConfigFile(rootFileNames: string[], compilerOptions: CompilerOptions, watchingHost?: WatchingSystemHost) { + return createWatchMode(rootFileNames, compilerOptions, watchingHost); + } + + interface HostFileInfo { + version: number; + sourceFile: SourceFile; + fileWatcher: FileWatcher; + } + + function createWatchMode(rootFileNames: string[], compilerOptions: CompilerOptions, watchingHost?: WatchingSystemHost, configFileName?: string, configFileSpecs?: ConfigFileSpecs, configFileWildCardDirectories?: MapLike, optionsToExtendForConfigFile?: CompilerOptions) { + let program: Program; + let needsReload: boolean; // true if the config file changed and needs to reload it from the disk + let missingFilesMap: Map; // Map of file watchers for the missing files + let watchedWildcardDirectories: Map; // map of watchers for the wild card directories in the config file + let timerToUpdateProgram: any; // timer callback to recompile the program + + const sourceFilesCache = createMap(); // Cache that stores the source file and version info + let missingFilePathsRequestedForRelease: Path[]; // These paths are held temparirly so that we can remove the entry from source file cache if the file is not tracked by missing files + let hasChangedCompilerOptions = false; // True if the compiler options have changed between compilations + let hasChangedAutomaticTypeDirectiveNames = false; // True if the automatic type directives have changed + + const loggingEnabled = compilerOptions.diagnostics || compilerOptions.extendedDiagnostics; + const writeLog: (s: string) => void = loggingEnabled ? s => system.write(s) : noop; + const watchFile = loggingEnabled ? ts.addFileWatcherWithLogging : ts.addFileWatcher; + const watchFilePath = loggingEnabled ? ts.addFilePathWatcherWithLogging : ts.addFilePathWatcher; + const watchDirectoryWorker = loggingEnabled ? ts.addDirectoryWatcherWithLogging : ts.addDirectoryWatcher; + + watchingHost = watchingHost || createWatchingSystemHost(compilerOptions.pretty); + const { system, parseConfigFile, reportDiagnostic, reportWatchDiagnostic, beforeCompile, afterCompile } = watchingHost; + + const directoryStructureHost = configFileName ? createCachedDirectoryStructureHost(system) : system; + if (configFileName) { + watchFile(system, configFileName, scheduleProgramReload, writeLog); + } + + const getCurrentDirectory = memoize(() => directoryStructureHost.getCurrentDirectory()); + const realpath = system.realpath && ((path: string) => system.realpath(path)); + const getCachedDirectoryStructureHost = configFileName && (() => directoryStructureHost as CachedDirectoryStructureHost); + const getCanonicalFileName = createGetCanonicalFileName(system.useCaseSensitiveFileNames); + let newLine = getNewLineCharacter(compilerOptions, system); + + const compilerHost: CompilerHost & ResolutionCacheHost = { + // Members for CompilerHost + getSourceFile: (fileName, languageVersion, onError?, shouldCreateNewSourceFile?) => getVersionedSourceFileByPath(fileName, toPath(fileName), languageVersion, onError, shouldCreateNewSourceFile), + getSourceFileByPath: getVersionedSourceFileByPath, + getDefaultLibLocation, + getDefaultLibFileName: options => combinePaths(getDefaultLibLocation(), getDefaultLibFileName(options)), + writeFile: notImplemented, + getCurrentDirectory, + useCaseSensitiveFileNames: () => system.useCaseSensitiveFileNames, + getCanonicalFileName, + getNewLine: () => newLine, + fileExists, + readFile: fileName => system.readFile(fileName), + trace: s => system.write(s + newLine), + directoryExists: directoryName => directoryStructureHost.directoryExists(directoryName), + getEnvironmentVariable: name => system.getEnvironmentVariable ? system.getEnvironmentVariable(name) : "", + getDirectories: path => directoryStructureHost.getDirectories(path), + realpath, + resolveTypeReferenceDirectives: (typeDirectiveNames, containingFile) => resolutionCache.resolveTypeReferenceDirectives(typeDirectiveNames, containingFile), + resolveModuleNames: (moduleNames, containingFile, reusedNames?) => resolutionCache.resolveModuleNames(moduleNames, containingFile, reusedNames, /*logChanges*/ false), + onReleaseOldSourceFile, + // Members for ResolutionCacheHost + toPath, + getCompilationSettings: () => compilerOptions, + watchDirectoryOfFailedLookupLocation: watchDirectory, + watchTypeRootsDirectory: watchDirectory, + getCachedDirectoryStructureHost, + onInvalidatedResolution: scheduleProgramUpdate, + onChangedAutomaticTypeDirectiveNames: () => { + hasChangedAutomaticTypeDirectiveNames = true; + scheduleProgramUpdate(); + }, + writeLog + }; + // Cache for the module resolution + const resolutionCache = createResolutionCache(compilerHost, configFileName ? + getDirectoryPath(getNormalizedAbsolutePath(configFileName, getCurrentDirectory())) : + getCurrentDirectory() + ); + // There is no extra check needed since we can just rely on the program to decide emit + const builder = createBuilder(getCanonicalFileName, getFileEmitOutput, computeHash, _sourceFile => true); + + synchronizeProgram(); + + // Update the wild card directory watch + watchConfigFileWildCardDirectories(); + + return () => program; + + function synchronizeProgram() { + writeLog(`Synchronizing program`); + + if (hasChangedCompilerOptions) { + newLine = getNewLineCharacter(compilerOptions, system); + } + + const hasInvalidatedResolution = resolutionCache.createHasInvalidatedResolution(); + if (isProgramUptoDate(program, rootFileNames, compilerOptions, getSourceVersion, fileExists, hasInvalidatedResolution, hasChangedAutomaticTypeDirectiveNames)) { + return; + } + + if (hasChangedCompilerOptions && changesAffectModuleResolution(program && program.getCompilerOptions(), compilerOptions)) { + resolutionCache.clear(); + } + const needsUpdateInTypeRootWatch = hasChangedCompilerOptions || !program; + hasChangedCompilerOptions = false; + beforeCompile(compilerOptions); + + // Compile the program + resolutionCache.startCachingPerDirectoryResolution(); + compilerHost.hasInvalidatedResolution = hasInvalidatedResolution; + compilerHost.hasChangedAutomaticTypeDirectiveNames = hasChangedAutomaticTypeDirectiveNames; + program = createProgram(rootFileNames, compilerOptions, compilerHost, program); + resolutionCache.finishCachingPerDirectoryResolution(); + builder.updateProgram(program); + + // Update watches + updateMissingFilePathsWatch(program, missingFilesMap || (missingFilesMap = createMap()), watchMissingFilePath); + if (needsUpdateInTypeRootWatch) { + resolutionCache.updateTypeRootsWatch(); + } + + if (missingFilePathsRequestedForRelease) { + // These are the paths that program creater told us as not in use any more but were missing on the disk. + // We didnt remove the entry for them from sourceFiles cache so that we dont have to do File IO, + // if there is already watcher for it (for missing files) + // At this point our watches were updated, hence now we know that these paths are not tracked and need to be removed + // so that at later time we have correct result of their presence + for (const missingFilePath of missingFilePathsRequestedForRelease) { + if (!missingFilesMap.has(missingFilePath)) { + sourceFilesCache.delete(missingFilePath); + } + } + missingFilePathsRequestedForRelease = undefined; + } + + afterCompile(directoryStructureHost, program, builder); + reportWatchDiagnostic(createCompilerDiagnostic(Diagnostics.Compilation_complete_Watching_for_file_changes)); + } + + function toPath(fileName: string) { + return ts.toPath(fileName, getCurrentDirectory(), getCanonicalFileName); + } + + function fileExists(fileName: string) { + const path = toPath(fileName); + const hostSourceFileInfo = sourceFilesCache.get(path); + if (hostSourceFileInfo !== undefined) { + return !isString(hostSourceFileInfo); + } + + return directoryStructureHost.fileExists(fileName); + } + + function getDefaultLibLocation(): string { + return getDirectoryPath(normalizePath(system.getExecutingFilePath())); + } + + function getVersionedSourceFileByPath(fileName: string, path: Path, languageVersion: ScriptTarget, onError?: (message: string) => void, shouldCreateNewSourceFile?: boolean): SourceFile { + const hostSourceFile = sourceFilesCache.get(path); + // No source file on the host + if (isString(hostSourceFile)) { + return undefined; + } + + // Create new source file if requested or the versions dont match + if (!hostSourceFile || shouldCreateNewSourceFile || hostSourceFile.version.toString() !== hostSourceFile.sourceFile.version) { + const sourceFile = getNewSourceFile(); + if (hostSourceFile) { + if (shouldCreateNewSourceFile) { + hostSourceFile.version++; + } + if (sourceFile) { + hostSourceFile.sourceFile = sourceFile; + sourceFile.version = hostSourceFile.version.toString(); + if (!hostSourceFile.fileWatcher) { + hostSourceFile.fileWatcher = watchFilePath(system, fileName, onSourceFileChange, path, writeLog); + } + } + else { + // There is no source file on host any more, close the watch, missing file paths will track it + hostSourceFile.fileWatcher.close(); + sourceFilesCache.set(path, hostSourceFile.version.toString()); + } + } + else { + let fileWatcher: FileWatcher; + if (sourceFile) { + sourceFile.version = "0"; + fileWatcher = watchFilePath(system, fileName, onSourceFileChange, path, writeLog); + sourceFilesCache.set(path, { sourceFile, version: 0, fileWatcher }); + } + else { + sourceFilesCache.set(path, "0"); + } + } + return sourceFile; + } + return hostSourceFile.sourceFile; + + function getNewSourceFile() { + let text: string; + try { + performance.mark("beforeIORead"); + text = system.readFile(fileName, compilerOptions.charset); + performance.mark("afterIORead"); + performance.measure("I/O Read", "beforeIORead", "afterIORead"); + } + catch (e) { + if (onError) { + onError(e.message); + } + } + + return text !== undefined ? createSourceFile(fileName, text, languageVersion) : undefined; + } + } + + function removeSourceFile(path: Path) { + const hostSourceFile = sourceFilesCache.get(path); + if (hostSourceFile !== undefined) { + if (!isString(hostSourceFile)) { + hostSourceFile.fileWatcher.close(); + resolutionCache.invalidateResolutionOfFile(path); + } + sourceFilesCache.delete(path); + } + } + + function getSourceVersion(path: Path): string { + const hostSourceFile = sourceFilesCache.get(path); + return !hostSourceFile || isString(hostSourceFile) ? undefined : hostSourceFile.version.toString(); + } + + function onReleaseOldSourceFile(oldSourceFile: SourceFile, _oldOptions: CompilerOptions) { + const hostSourceFileInfo = sourceFilesCache.get(oldSourceFile.path); + // If this is the source file thats in the cache and new program doesnt need it, + // remove the cached entry. + // Note we arent deleting entry if file became missing in new program or + // there was version update and new source file was created. + if (hostSourceFileInfo) { + // record the missing file paths so they can be removed later if watchers arent tracking them + if (isString(hostSourceFileInfo)) { + (missingFilePathsRequestedForRelease || (missingFilePathsRequestedForRelease = [])).push(oldSourceFile.path); + } + else if (hostSourceFileInfo.sourceFile === oldSourceFile) { + sourceFilesCache.delete(oldSourceFile.path); + resolutionCache.removeResolutionsOfFile(oldSourceFile.path); + } + } + } + + // Upon detecting a file change, wait for 250ms and then perform a recompilation. This gives batch + // operations (such as saving all modified files in an editor) a chance to complete before we kick + // off a new compilation. + function scheduleProgramUpdate() { + if (!system.setTimeout || !system.clearTimeout) { + return; + } + + if (timerToUpdateProgram) { + system.clearTimeout(timerToUpdateProgram); + } + timerToUpdateProgram = system.setTimeout(updateProgram, 250); + } + + function scheduleProgramReload() { + Debug.assert(!!configFileName); + needsReload = true; + scheduleProgramUpdate(); + } + + function updateProgram() { + timerToUpdateProgram = undefined; + reportWatchDiagnostic(createCompilerDiagnostic(Diagnostics.File_change_detected_Starting_incremental_compilation)); + + if (needsReload) { + reloadConfigFile(); + } + else { + synchronizeProgram(); + } + } + + function reloadConfigFile() { + writeLog(`Reloading config file: ${configFileName}`); + needsReload = false; + + const cachedHost = directoryStructureHost as CachedDirectoryStructureHost; + cachedHost.clearCache(); + const configParseResult = parseConfigFile(configFileName, optionsToExtendForConfigFile, cachedHost, reportDiagnostic, reportWatchDiagnostic); + rootFileNames = configParseResult.fileNames; + compilerOptions = configParseResult.options; + hasChangedCompilerOptions = true; + configFileSpecs = configParseResult.configFileSpecs; + configFileWildCardDirectories = configParseResult.wildcardDirectories; + + synchronizeProgram(); + + // Update the wild card directory watch + watchConfigFileWildCardDirectories(); + } + + function onSourceFileChange(fileName: string, eventKind: FileWatcherEventKind, path: Path) { + updateCachedSystemWithFile(fileName, path, eventKind); + const hostSourceFile = sourceFilesCache.get(path); + if (hostSourceFile) { + // Update the cache + if (eventKind === FileWatcherEventKind.Deleted) { + resolutionCache.invalidateResolutionOfFile(path); + if (!isString(hostSourceFile)) { + hostSourceFile.fileWatcher.close(); + sourceFilesCache.set(path, (hostSourceFile.version++).toString()); + } + } + else { + // Deleted file created + if (isString(hostSourceFile)) { + sourceFilesCache.delete(path); + } + else { + // file changed - just update the version + hostSourceFile.version++; + } + } + } + + // Update the program + scheduleProgramUpdate(); + } + + function updateCachedSystemWithFile(fileName: string, path: Path, eventKind: FileWatcherEventKind) { + if (configFileName) { + (directoryStructureHost as CachedDirectoryStructureHost).addOrDeleteFile(fileName, path, eventKind); + } + } + + function watchDirectory(directory: string, cb: DirectoryWatcherCallback, flags: WatchDirectoryFlags) { + return watchDirectoryWorker(system, directory, cb, flags, writeLog); + } + + function watchMissingFilePath(missingFilePath: Path) { + return watchFilePath(system, missingFilePath, onMissingFileChange, missingFilePath, writeLog); + } + + function onMissingFileChange(fileName: string, eventKind: FileWatcherEventKind, missingFilePath: Path) { + updateCachedSystemWithFile(fileName, missingFilePath, eventKind); + + if (eventKind === FileWatcherEventKind.Created && missingFilesMap.has(missingFilePath)) { + missingFilesMap.get(missingFilePath).close(); + missingFilesMap.delete(missingFilePath); + + // Delete the entry in the source files cache so that new source file is created + removeSourceFile(missingFilePath); + + // When a missing file is created, we should update the graph. + scheduleProgramUpdate(); + } + } + + function watchConfigFileWildCardDirectories() { + updateWatchingWildcardDirectories( + watchedWildcardDirectories || (watchedWildcardDirectories = createMap()), + createMapFromTemplate(configFileWildCardDirectories), + watchWildcardDirectory + ); + } + + function watchWildcardDirectory(directory: string, flags: WatchDirectoryFlags) { + return watchDirectory( + directory, + fileOrDirectory => { + Debug.assert(!!configFileName); + + const fileOrDirectoryPath = toPath(fileOrDirectory); + + // Since the file existance changed, update the sourceFiles cache + (directoryStructureHost as CachedDirectoryStructureHost).addOrDeleteFileOrDirectory(fileOrDirectory, fileOrDirectoryPath); + removeSourceFile(fileOrDirectoryPath); + + // If the the added or created file or directory is not supported file name, ignore the file + // But when watched directory is added/removed, we need to reload the file list + if (fileOrDirectoryPath !== directory && !isSupportedSourceFileName(fileOrDirectory, compilerOptions)) { + writeLog(`Project: ${configFileName} Detected file add/remove of non supported extension: ${fileOrDirectory}`); + return; + } + + // Reload is pending, do the reload + if (!needsReload) { + const result = getFileNamesFromConfigSpecs(configFileSpecs, getDirectoryPath(configFileName), compilerOptions, directoryStructureHost); + if (!configFileSpecs.filesSpecs && result.fileNames.length === 0) { + reportDiagnostic(getErrorForNoInputFiles(configFileSpecs, configFileName)); + } + rootFileNames = result.fileNames; + + // Schedule Update the program + scheduleProgramUpdate(); + } + }, + flags + ); + } + + function computeHash(data: string) { + return system.createHash ? system.createHash(data) : data; + } + } +} diff --git a/src/compiler/watchUtilities.ts b/src/compiler/watchUtilities.ts new file mode 100644 index 0000000000000..de4152939545e --- /dev/null +++ b/src/compiler/watchUtilities.ts @@ -0,0 +1,136 @@ +/// + +namespace ts { + /** + * Updates the existing missing file watches with the new set of missing files after new program is created + */ + export function updateMissingFilePathsWatch( + program: Program, + missingFileWatches: Map, + createMissingFileWatch: (missingFilePath: Path) => FileWatcher, + ) { + const missingFilePaths = program.getMissingFilePaths(); + const newMissingFilePathMap = arrayToSet(missingFilePaths); + // Update the missing file paths watcher + mutateMap( + missingFileWatches, + newMissingFilePathMap, + { + // Watch the missing files + createNewValue: createMissingFileWatch, + // Files that are no longer missing (e.g. because they are no longer required) + // should no longer be watched. + onDeleteValue: closeFileWatcher + } + ); + } + + export interface WildcardDirectoryWatcher { + watcher: FileWatcher; + flags: WatchDirectoryFlags; + } + + /** + * Updates the existing wild card directory watches with the new set of wild card directories from the config file + * after new program is created because the config file was reloaded or program was created first time from the config file + * Note that there is no need to call this function when the program is updated with additional files without reloading config files, + * as wildcard directories wont change unless reloading config file + */ + export function updateWatchingWildcardDirectories( + existingWatchedForWildcards: Map, + wildcardDirectories: Map, + watchDirectory: (directory: string, flags: WatchDirectoryFlags) => FileWatcher + ) { + mutateMap( + existingWatchedForWildcards, + wildcardDirectories, + { + // Create new watch and recursive info + createNewValue: createWildcardDirectoryWatcher, + // Close existing watch thats not needed any more + onDeleteValue: closeFileWatcherOf, + // Close existing watch that doesnt match in the flags + onExistingValue: updateWildcardDirectoryWatcher + } + ); + + function createWildcardDirectoryWatcher(directory: string, flags: WatchDirectoryFlags): WildcardDirectoryWatcher { + // Create new watch and recursive info + return { + watcher: watchDirectory(directory, flags), + flags + }; + } + + function updateWildcardDirectoryWatcher(existingWatcher: WildcardDirectoryWatcher, flags: WatchDirectoryFlags, directory: string) { + // Watcher needs to be updated if the recursive flags dont match + if (existingWatcher.flags === flags) { + return; + } + + existingWatcher.watcher.close(); + existingWatchedForWildcards.set(directory, createWildcardDirectoryWatcher(directory, flags)); + } + } +} + +/* @internal */ +namespace ts { + export function addFileWatcher(host: System, file: string, cb: FileWatcherCallback): FileWatcher { + return host.watchFile(file, cb); + } + + export function addFileWatcherWithLogging(host: System, file: string, cb: FileWatcherCallback, log: (s: string) => void): FileWatcher { + const watcherCaption = `FileWatcher:: `; + return createWatcherWithLogging(addFileWatcher, watcherCaption, log, host, file, cb); + } + + export type FilePathWatcherCallback = (fileName: string, eventKind: FileWatcherEventKind, filePath: Path) => void; + export function addFilePathWatcher(host: System, file: string, cb: FilePathWatcherCallback, path: Path): FileWatcher { + return host.watchFile(file, (fileName, eventKind) => cb(fileName, eventKind, path)); + } + + export function addFilePathWatcherWithLogging(host: System, file: string, cb: FilePathWatcherCallback, path: Path, log: (s: string) => void): FileWatcher { + const watcherCaption = `FileWatcher:: `; + return createWatcherWithLogging(addFileWatcher, watcherCaption, log, host, file, cb, path); + } + + export function addDirectoryWatcher(host: System, directory: string, cb: DirectoryWatcherCallback, flags: WatchDirectoryFlags): FileWatcher { + const recursive = (flags & WatchDirectoryFlags.Recursive) !== 0; + return host.watchDirectory(directory, cb, recursive); + } + + export function addDirectoryWatcherWithLogging(host: System, directory: string, cb: DirectoryWatcherCallback, flags: WatchDirectoryFlags, log: (s: string) => void): FileWatcher { + const watcherCaption = `DirectoryWatcher ${(flags & WatchDirectoryFlags.Recursive) !== 0 ? "recursive" : ""}:: `; + return createWatcherWithLogging(addDirectoryWatcher, watcherCaption, log, host, directory, cb, flags); + } + + type WatchCallback = (fileName: string, cbOptional1?: T, optional?: U) => void; + type AddWatch = (host: System, file: string, cb: WatchCallback, optional?: U) => FileWatcher; + function createWatcherWithLogging(addWatch: AddWatch, watcherCaption: string, log: (s: string) => void, host: System, file: string, cb: WatchCallback, optional?: U): FileWatcher { + const info = `PathInfo: ${file}`; + log(`${watcherCaption}Added: ${info}`); + const watcher = addWatch(host, file, (fileName, cbOptional1?) => { + const optionalInfo = cbOptional1 !== undefined ? ` ${cbOptional1}` : ""; + log(`${watcherCaption}Trigger: ${fileName}${optionalInfo} ${info}`); + const start = timestamp(); + cb(fileName, cbOptional1, optional); + const elapsed = timestamp() - start; + log(`${watcherCaption}Elapsed: ${elapsed}ms Trigger: ${fileName}${optionalInfo} ${info}`); + }, optional); + return { + close: () => { + log(`${watcherCaption}Close: ${info}`); + watcher.close(); + } + }; + } + + export function closeFileWatcher(watcher: FileWatcher) { + watcher.close(); + } + + export function closeFileWatcherOf(objWithWatcher: T) { + objWithWatcher.watcher.close(); + } +} diff --git a/src/harness/fourslash.ts b/src/harness/fourslash.ts index e84d8c609500f..debb729067898 100644 --- a/src/harness/fourslash.ts +++ b/src/harness/fourslash.ts @@ -390,7 +390,7 @@ namespace FourSlash { // Entry points from fourslash.ts public goToMarker(name: string | Marker = "") { - const marker = typeof name === "string" ? this.getMarkerByName(name) : name; + const marker = ts.isString(name) ? this.getMarkerByName(name) : name; if (this.activeFile.fileName !== marker.fileName) { this.openFile(marker.fileName); } @@ -399,7 +399,7 @@ namespace FourSlash { if (marker.position === -1 || marker.position > content.length) { throw new Error(`Marker "${name}" has been invalidated by unrecoverable edits to the file.`); } - const mName = typeof name === "string" ? name : this.markerName(marker); + const mName = ts.isString(name) ? name : this.markerName(marker); this.lastKnownMarker = mName; this.goToPosition(marker.position); } @@ -1027,7 +1027,7 @@ namespace FourSlash { public verifyNoReferences(markerNameOrRange?: string | Range) { if (markerNameOrRange) { - if (typeof markerNameOrRange === "string") { + if (ts.isString(markerNameOrRange)) { this.goToMarker(markerNameOrRange); } else { @@ -1522,7 +1522,7 @@ Actual: ${stringify(fullActual)}`); resultString += "Diagnostics:" + Harness.IO.newLine(); const diagnostics = ts.getPreEmitDiagnostics(this.languageService.getProgram()); for (const diagnostic of diagnostics) { - if (typeof diagnostic.messageText !== "string") { + if (!ts.isString(diagnostic.messageText)) { let chainedMessage = diagnostic.messageText; let indentation = " "; while (chainedMessage) { @@ -2976,7 +2976,7 @@ Actual: ${stringify(fullActual)}`); result = this.testData.files[index]; } } - else if (typeof indexOrName === "string") { + else if (ts.isString(indexOrName)) { let name = indexOrName; // names are stored in the compiler with this relative path, this allows people to use goTo.file on just the fileName diff --git a/src/harness/harness.ts b/src/harness/harness.ts index 429362ab6cb08..a0fb88213edde 100644 --- a/src/harness/harness.ts +++ b/src/harness/harness.ts @@ -227,7 +227,7 @@ namespace Utils { return JSON.stringify(file, (_, v) => isNodeOrArray(v) ? serializeNode(v) : v, " "); function getKindName(k: number | string): string { - if (typeof k === "string") { + if (ts.isString(k)) { return k; } @@ -757,6 +757,10 @@ namespace Harness { } } + export function mockHash(s: string): string { + return `hash-${s}`; + } + const environment = Utils.getExecutionEnvironment(); switch (environment) { case Utils.ExecutionEnvironment.Node: diff --git a/src/harness/harnessLanguageService.ts b/src/harness/harnessLanguageService.ts index 23bc08108c764..ad79c96d833f8 100644 --- a/src/harness/harnessLanguageService.ts +++ b/src/harness/harnessLanguageService.ts @@ -855,8 +855,4 @@ namespace Harness.LanguageService { getClassifier(): ts.Classifier { throw new Error("getClassifier is not available using the server interface."); } getPreProcessedFileInfo(): ts.PreProcessedFileInfo { throw new Error("getPreProcessedFileInfo is not available using the server interface."); } } - - export function mockHash(s: string): string { - return `hash-${s}`; - } } diff --git a/src/harness/projectsRunner.ts b/src/harness/projectsRunner.ts index 6c13261d757a3..ceab3ae8f3b82 100644 --- a/src/harness/projectsRunner.ts +++ b/src/harness/projectsRunner.ts @@ -254,7 +254,7 @@ class ProjectRunner extends RunnerBase { if (option) { const optType = option.type; let value = testCase[name]; - if (typeof optType !== "string") { + if (!ts.isString(optType)) { const key = value.toLowerCase(); const optTypeValue = optType.get(key); if (optTypeValue) { diff --git a/src/harness/tsconfig.json b/src/harness/tsconfig.json index 88999b2d979ff..eed8578291119 100644 --- a/src/harness/tsconfig.json +++ b/src/harness/tsconfig.json @@ -1,4 +1,4 @@ -{ +{ "extends": "../tsconfig-base", "compilerOptions": { "removeComments": false, @@ -45,6 +45,7 @@ "../compiler/declarationEmitter.ts", "../compiler/emitter.ts", "../compiler/program.ts", + "../compiler/builder.ts", "../compiler/commandLineParser.ts", "../compiler/diagnosticInformationMap.generated.ts", "../services/breakpoints.ts", @@ -96,6 +97,7 @@ "./parallel/host.ts", "./parallel/worker.ts", "runner.ts", + "virtualFileSystemWithWatch.ts", "../server/protocol.ts", "../server/session.ts", "../server/client.ts", @@ -112,7 +114,6 @@ "./unittests/convertToBase64.ts", "./unittests/transpile.ts", "./unittests/reuseProgramStructure.ts", - "./unittests/cachingInServerLSHost.ts", "./unittests/moduleResolution.ts", "./unittests/tsconfigParsing.ts", "./unittests/commandLineParsing.ts", @@ -120,6 +121,7 @@ "./unittests/convertCompilerOptionsFromJson.ts", "./unittests/convertTypeAcquisitionFromJson.ts", "./unittests/tsserverProjectSystem.ts", + "./unittests/tscWatchMode.ts", "./unittests/matchFiles.ts", "./unittests/initializeTSConfig.ts", "./unittests/compileOnSave.ts", diff --git a/src/harness/unittests/cachingInServerLSHost.ts b/src/harness/unittests/cachingInServerLSHost.ts deleted file mode 100644 index 489c3c5fdd3bb..0000000000000 --- a/src/harness/unittests/cachingInServerLSHost.ts +++ /dev/null @@ -1,202 +0,0 @@ -/// - -namespace ts { - interface File { - name: string; - content: string; - } - - function createDefaultServerHost(fileMap: Map): server.ServerHost { - const existingDirectories = createMap(); - forEachKey(fileMap, name => { - let dir = getDirectoryPath(name); - let previous: string; - do { - existingDirectories.set(dir, true); - previous = dir; - dir = getDirectoryPath(dir); - } while (dir !== previous); - }); - return { - args: [], - newLine: "\r\n", - useCaseSensitiveFileNames: false, - write: noop, - readFile: path => { - const file = fileMap.get(path); - return file && file.content; - }, - writeFile: notImplemented, - resolvePath: notImplemented, - fileExists: path => fileMap.has(path), - directoryExists: path => existingDirectories.get(path) || false, - createDirectory: noop, - getExecutingFilePath: () => "", - getCurrentDirectory: () => "", - getDirectories: () => [], - getEnvironmentVariable: () => "", - readDirectory: notImplemented, - exit: noop, - watchFile: () => ({ - close: noop - }), - watchDirectory: () => ({ - close: noop - }), - setTimeout, - clearTimeout, - setImmediate: typeof setImmediate !== "undefined" ? setImmediate : action => setTimeout(action, 0), - clearImmediate: typeof clearImmediate !== "undefined" ? clearImmediate : clearTimeout, - createHash: Harness.LanguageService.mockHash, - }; - } - - function createProject(rootFile: string, serverHost: server.ServerHost): { project: server.Project, rootScriptInfo: server.ScriptInfo } { - const svcOpts: server.ProjectServiceOptions = { - host: serverHost, - logger: projectSystem.nullLogger, - cancellationToken: { isCancellationRequested: () => false }, - useSingleInferredProject: false, - useInferredProjectPerProjectRoot: false, - typingsInstaller: undefined - }; - const projectService = new server.ProjectService(svcOpts); - const rootScriptInfo = projectService.getOrCreateScriptInfo(rootFile, /* openedByClient */ true, /*containingProject*/ undefined); - - const project = projectService.createInferredProjectWithRootFileIfNecessary(rootScriptInfo); - project.setCompilerOptions({ module: ts.ModuleKind.AMD, noLib: true }); - return { - project, - rootScriptInfo - }; - } - - describe("Caching in LSHost", () => { - it("works using legacy resolution logic", () => { - const root: File = { - name: "c:/d/f0.ts", - content: `import {x} from "f1"` - }; - - const imported: File = { - name: "c:/f1.ts", - content: `foo()` - }; - - const serverHost = createDefaultServerHost(createMapFromTemplate({ [root.name]: root, [imported.name]: imported })); - const { project, rootScriptInfo } = createProject(root.name, serverHost); - - // ensure that imported file was found - let diags = project.getLanguageService().getSemanticDiagnostics(imported.name); - assert.equal(diags.length, 1); - - - const originalFileExists = serverHost.fileExists; - { - // patch fileExists to make sure that disk is not touched - serverHost.fileExists = notImplemented; - - const newContent = `import {x} from "f1" - var x: string = 1;`; - rootScriptInfo.editContent(0, root.content.length, newContent); - // trigger synchronization to make sure that import will be fetched from the cache - diags = project.getLanguageService().getSemanticDiagnostics(imported.name); - // ensure file has correct number of errors after edit - assert.equal(diags.length, 1); - } - { - let fileExistsIsCalled = false; - serverHost.fileExists = (fileName): boolean => { - if (fileName === "lib.d.ts") { - return false; - } - fileExistsIsCalled = true; - assert.isTrue(fileName.indexOf("/f2.") !== -1); - return originalFileExists.call(serverHost, fileName); - }; - const newContent = `import {x} from "f2"`; - rootScriptInfo.editContent(0, root.content.length, newContent); - - try { - // trigger synchronization to make sure that LSHost will try to find 'f2' module on disk - project.getLanguageService().getSemanticDiagnostics(imported.name); - assert.isTrue(false, `should not find file '${imported.name}'`); - } - catch (e) { - assert.isTrue(e.message.indexOf(`Could not find file: '${imported.name}'.`) === 0); - } - - assert.isTrue(fileExistsIsCalled); - } - { - let fileExistsCalled = false; - serverHost.fileExists = (fileName): boolean => { - if (fileName === "lib.d.ts") { - return false; - } - fileExistsCalled = true; - assert.isTrue(fileName.indexOf("/f1.") !== -1); - return originalFileExists.call(serverHost, fileName); - }; - - const newContent = `import {x} from "f1"`; - rootScriptInfo.editContent(0, root.content.length, newContent); - project.getLanguageService().getSemanticDiagnostics(imported.name); - assert.isTrue(fileExistsCalled); - - // setting compiler options discards module resolution cache - fileExistsCalled = false; - - const compilerOptions = ts.cloneCompilerOptions(project.getCompilerOptions()); - compilerOptions.target = ts.ScriptTarget.ES5; - project.setCompilerOptions(compilerOptions); - - project.getLanguageService().getSemanticDiagnostics(imported.name); - assert.isTrue(fileExistsCalled); - } - }); - - it("loads missing files from disk", () => { - const root: File = { - name: `c:/foo.ts`, - content: `import {x} from "bar"` - }; - - const imported: File = { - name: `c:/bar.d.ts`, - content: `export var y = 1` - }; - - const fileMap = createMapFromTemplate({ [root.name]: root }); - const serverHost = createDefaultServerHost(fileMap); - const originalFileExists = serverHost.fileExists; - - let fileExistsCalledForBar = false; - serverHost.fileExists = fileName => { - if (fileName === "lib.d.ts") { - return false; - } - if (!fileExistsCalledForBar) { - fileExistsCalledForBar = fileName.indexOf("/bar.") !== -1; - } - - return originalFileExists.call(serverHost, fileName); - }; - - const { project, rootScriptInfo } = createProject(root.name, serverHost); - - let diags = project.getLanguageService().getSemanticDiagnostics(root.name); - assert.isTrue(fileExistsCalledForBar, "'fileExists' should be called"); - assert.isTrue(diags.length === 1, "one diagnostic expected"); - assert.isTrue(typeof diags[0].messageText === "string" && ((diags[0].messageText).indexOf("Cannot find module") === 0), "should be 'cannot find module' message"); - - fileMap.set(imported.name, imported); - fileExistsCalledForBar = false; - rootScriptInfo.editContent(0, root.content.length, `import {y} from "bar"`); - - diags = project.getLanguageService().getSemanticDiagnostics(root.name); - assert.isTrue(fileExistsCalledForBar, "'fileExists' should be called."); - assert.isTrue(diags.length === 0, "The import should succeed once the imported file appears on disk."); - }); - }); -} diff --git a/src/harness/unittests/compileOnSave.ts b/src/harness/unittests/compileOnSave.ts index 1545390de8df1..4ddda5fcbea6d 100644 --- a/src/harness/unittests/compileOnSave.ts +++ b/src/harness/unittests/compileOnSave.ts @@ -198,7 +198,6 @@ namespace ts.projectSystem { file1Consumer1.content = `let y = 10;`; host.reloadFS([moduleFile1, file1Consumer1, file1Consumer2, configFile, libFile]); - host.triggerFileWatcherCallback(file1Consumer1.path, FileWatcherEventKind.Changed); session.executeCommand(changeModuleFile1ShapeRequest1); sendAffectedFileRequestAndCheckResult(session, moduleFile1FileListRequest, [{ projectFileName: configFile.path, files: [moduleFile1, file1Consumer2] }]); @@ -215,7 +214,6 @@ namespace ts.projectSystem { session.executeCommand(changeModuleFile1ShapeRequest1); // Delete file1Consumer2 host.reloadFS([moduleFile1, file1Consumer1, configFile, libFile]); - host.triggerFileWatcherCallback(file1Consumer2.path, FileWatcherEventKind.Deleted); sendAffectedFileRequestAndCheckResult(session, moduleFile1FileListRequest, [{ projectFileName: configFile.path, files: [moduleFile1, file1Consumer1] }]); }); @@ -232,7 +230,6 @@ namespace ts.projectSystem { content: `import {Foo} from "./moduleFile1"; let y = Foo();` }; host.reloadFS([moduleFile1, file1Consumer1, file1Consumer2, file1Consumer3, globalFile3, configFile, libFile]); - host.triggerDirectoryWatcherCallback(ts.getDirectoryPath(file1Consumer3.path), file1Consumer3.path); host.runQueuedTimeoutCallbacks(); session.executeCommand(changeModuleFile1ShapeRequest1); sendAffectedFileRequestAndCheckResult(session, moduleFile1FileListRequest, [{ projectFileName: configFile.path, files: [moduleFile1, file1Consumer1, file1Consumer2, file1Consumer3] }]); @@ -465,7 +462,6 @@ namespace ts.projectSystem { openFilesForSession([referenceFile1], session); host.reloadFS([referenceFile1, configFile]); - host.triggerFileWatcherCallback(moduleFile1.path, FileWatcherEventKind.Deleted); const request = makeSessionRequest(CommandNames.CompileOnSaveAffectedFileList, { file: referenceFile1.path }); sendAffectedFileRequestAndCheckResult(session, request, [ @@ -593,4 +589,4 @@ namespace ts.projectSystem { assert.isTrue(outFileContent.indexOf(file3.content) === -1); }); }); -} \ No newline at end of file +} diff --git a/src/harness/unittests/configurationExtension.ts b/src/harness/unittests/configurationExtension.ts index b46d98f1524df..0032505aba14f 100644 --- a/src/harness/unittests/configurationExtension.ts +++ b/src/harness/unittests/configurationExtension.ts @@ -104,7 +104,7 @@ namespace ts { "/dev/tests/scenarios/first.json": "", "/dev/tests/baselines/first/output.ts": "" }); - const testContents = mapEntries(testContentsJson, (k, v) => [k, typeof v === "string" ? v : JSON.stringify(v)]); + const testContents = mapEntries(testContentsJson, (k, v) => [k, isString(v) ? v : JSON.stringify(v)]); const caseInsensitiveBasePath = "c:/dev/"; const caseInsensitiveHost = new Utils.MockParseConfigHost(caseInsensitiveBasePath, /*useCaseSensitiveFileNames*/ false, mapEntries(testContents, (key, content) => [`c:${key}`, content])); diff --git a/src/harness/unittests/programMissingFiles.ts b/src/harness/unittests/programMissingFiles.ts index ea644599de1c6..ce8abc232e746 100644 --- a/src/harness/unittests/programMissingFiles.ts +++ b/src/harness/unittests/programMissingFiles.ts @@ -1,6 +1,18 @@ /// namespace ts { + function verifyMissingFilePaths(missingPaths: ReadonlyArray, expected: ReadonlyArray) { + assert.isDefined(missingPaths); + const map = arrayToSet(expected) as Map; + for (const missing of missingPaths) { + const value = map.get(missing); + assert.isTrue(value, `${missing} to be ${value === undefined ? "not present" : "present only once"}, in actual: ${missingPaths} expected: ${expected}`); + map.set(missing, false); + } + const notFound = mapDefinedIter(map.keys(), k => map.get(k) === true ? k : undefined); + assert.equal(notFound.length, 0, `Not found ${notFound} in actual: ${missingPaths} expected: ${expected}`); + } + describe("Program.getMissingFilePaths", () => { const options: CompilerOptions = { @@ -40,34 +52,31 @@ namespace ts { it("handles no missing root files", () => { const program = createProgram([emptyFileRelativePath], options, testCompilerHost); const missing = program.getMissingFilePaths(); - assert.isDefined(missing); - assert.deepEqual(missing, []); + verifyMissingFilePaths(missing, []); }); it("handles missing root file", () => { const program = createProgram(["./nonexistent.ts"], options, testCompilerHost); const missing = program.getMissingFilePaths(); - assert.isDefined(missing); - assert.deepEqual(missing, ["d:/pretend/nonexistent.ts" as Path]); // Absolute path + verifyMissingFilePaths(missing, ["d:/pretend/nonexistent.ts"]); // Absolute path }); it("handles multiple missing root files", () => { const program = createProgram(["./nonexistent0.ts", "./nonexistent1.ts"], options, testCompilerHost); - const missing = program.getMissingFilePaths().slice().sort(); - assert.deepEqual(missing, ["d:/pretend/nonexistent0.ts", "d:/pretend/nonexistent1.ts"]); + const missing = program.getMissingFilePaths(); + verifyMissingFilePaths(missing, ["d:/pretend/nonexistent0.ts", "d:/pretend/nonexistent1.ts"]); }); it("handles a mix of present and missing root files", () => { const program = createProgram(["./nonexistent0.ts", emptyFileRelativePath, "./nonexistent1.ts"], options, testCompilerHost); - const missing = program.getMissingFilePaths().slice().sort(); - assert.deepEqual(missing, ["d:/pretend/nonexistent0.ts", "d:/pretend/nonexistent1.ts"]); + const missing = program.getMissingFilePaths(); + verifyMissingFilePaths(missing, ["d:/pretend/nonexistent0.ts", "d:/pretend/nonexistent1.ts"]); }); it("handles repeatedly specified root files", () => { const program = createProgram(["./nonexistent.ts", "./nonexistent.ts"], options, testCompilerHost); const missing = program.getMissingFilePaths(); - assert.isDefined(missing); - assert.deepEqual(missing, ["d:/pretend/nonexistent.ts" as Path]); + verifyMissingFilePaths(missing, ["d:/pretend/nonexistent.ts"]); }); it("normalizes file paths", () => { @@ -81,9 +90,8 @@ namespace ts { it("handles missing triple slash references", () => { const program = createProgram([referenceFileRelativePath], options, testCompilerHost); - const missing = program.getMissingFilePaths().slice().sort(); - assert.isDefined(missing); - assert.deepEqual(missing, [ + const missing = program.getMissingFilePaths(); + verifyMissingFilePaths(missing, [ // From absolute reference "d:/imaginary/nonexistent1.ts", @@ -100,4 +108,4 @@ namespace ts { ]); }); }); -} \ No newline at end of file +} diff --git a/src/harness/unittests/projectErrors.ts b/src/harness/unittests/projectErrors.ts index 4a76ed2c98c76..d72168383c170 100644 --- a/src/harness/unittests/projectErrors.ts +++ b/src/harness/unittests/projectErrors.ts @@ -20,19 +20,34 @@ namespace ts.projectSystem { } } + function checkDiagnosticsWithLinePos(errors: server.protocol.DiagnosticWithLinePosition[], expectedErrors: string[]) { + assert.equal(errors ? errors.length : 0, expectedErrors.length, `expected ${expectedErrors.length} error in the list`); + if (expectedErrors.length) { + zipWith(errors, expectedErrors, ({ message: actualMessage }, expectedMessage) => { + assert.isTrue(startsWith(actualMessage, actualMessage), `error message does not match, expected ${actualMessage} to start with ${expectedMessage}`); + }); + } + } + it("external project - diagnostics for missing files", () => { const file1 = { path: "/a/b/app.ts", content: "" }; const file2 = { - path: "/a/b/lib.ts", + path: "/a/b/applib.ts", content: "" }; - // only file1 exists - expect error - const host = createServerHost([file1]); - const projectService = createProjectService(host); + const host = createServerHost([file1, libFile]); + const session = createSession(host); + const projectService = session.getProjectService(); const projectFileName = "/a/b/test.csproj"; + const compilerOptionsRequest: server.protocol.CompilerOptionsDiagnosticsRequest = { + type: "request", + command: server.CommandNames.CompilerOptionsDiagnosticsFull, + seq: 2, + arguments: { projectFileName } + }; { projectService.openExternalProject({ @@ -41,35 +56,25 @@ namespace ts.projectSystem { rootFiles: toExternalFiles([file1.path, file2.path]) }); - projectService.checkNumberOfProjects({ externalProjects: 1 }); - const knownProjects = projectService.synchronizeProjectList([]); - checkProjectErrors(knownProjects[0], ["File '/a/b/lib.ts' not found."]); + checkNumberOfProjects(projectService, { externalProjects: 1 }); + const diags = session.executeCommand(compilerOptionsRequest).response; + // only file1 exists - expect error + checkDiagnosticsWithLinePos(diags, ["File '/a/b/applib.ts' not found."]); } - // only file2 exists - expect error - host.reloadFS([file2]); + host.reloadFS([file2, libFile]); { - projectService.openExternalProject({ - projectFileName, - options: {}, - rootFiles: toExternalFiles([file1.path, file2.path]) - }); - projectService.checkNumberOfProjects({ externalProjects: 1 }); - const knownProjects = projectService.synchronizeProjectList([]); - checkProjectErrors(knownProjects[0], ["File '/a/b/app.ts' not found."]); + // only file2 exists - expect error + checkNumberOfProjects(projectService, { externalProjects: 1 }); + const diags = session.executeCommand(compilerOptionsRequest).response; + checkDiagnosticsWithLinePos(diags, ["File '/a/b/app.ts' not found."]); } - // both files exist - expect no errors - host.reloadFS([file1, file2]); + host.reloadFS([file1, file2, libFile]); { - projectService.openExternalProject({ - projectFileName, - options: {}, - rootFiles: toExternalFiles([file1.path, file2.path]) - }); - - projectService.checkNumberOfProjects({ externalProjects: 1 }); - const knownProjects = projectService.synchronizeProjectList([]); - checkProjectErrors(knownProjects[0], []); + // both files exist - expect no errors + checkNumberOfProjects(projectService, { externalProjects: 1 }); + const diags = session.executeCommand(compilerOptionsRequest).response; + checkDiagnosticsWithLinePos(diags, []); } }); @@ -79,25 +84,33 @@ namespace ts.projectSystem { content: "" }; const file2 = { - path: "/a/b/lib.ts", + path: "/a/b/applib.ts", content: "" }; const config = { path: "/a/b/tsconfig.json", content: JSON.stringify({ files: [file1, file2].map(f => getBaseFileName(f.path)) }) }; - const host = createServerHost([file1, config]); - const projectService = createProjectService(host); - - projectService.openClientFile(file1.path); - projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectErrors(projectService.synchronizeProjectList([])[0], ["File '/a/b/lib.ts' not found."]); + const host = createServerHost([file1, config, libFile]); + const session = createSession(host); + const projectService = session.getProjectService(); + openFilesForSession([file1], session); + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + const project = configuredProjectAt(projectService, 0); + const compilerOptionsRequest: server.protocol.CompilerOptionsDiagnosticsRequest = { + type: "request", + command: server.CommandNames.CompilerOptionsDiagnosticsFull, + seq: 2, + arguments: { projectFileName: project.getProjectName() } + }; + let diags = session.executeCommand(compilerOptionsRequest).response; + checkDiagnosticsWithLinePos(diags, ["File '/a/b/applib.ts' not found."]); - host.reloadFS([file1, file2, config]); + host.reloadFS([file1, file2, config, libFile]); - projectService.openClientFile(file1.path); - projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectErrors(projectService.synchronizeProjectList([])[0], []); + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + diags = session.executeCommand(compilerOptionsRequest).response; + checkDiagnosticsWithLinePos(diags, []); }); it("configured projects - diagnostics for corrupted config 1", () => { @@ -126,7 +139,7 @@ namespace ts.projectSystem { const configuredProject = forEach(projectService.synchronizeProjectList([]), f => f.info.projectName === corruptedConfig.path && f); assert.isTrue(configuredProject !== undefined, "should find configured project"); checkProjectErrors(configuredProject, []); - const projectErrors = projectService.configuredProjects[0].getAllProjectErrors(); + const projectErrors = configuredProjectAt(projectService, 0).getAllProjectErrors(); checkProjectErrorsWorker(projectErrors, [ "'{' expected." ]); @@ -135,13 +148,12 @@ namespace ts.projectSystem { } // fix config and trigger watcher host.reloadFS([file1, file2, correctConfig]); - host.triggerFileWatcherCallback(correctConfig.path, FileWatcherEventKind.Changed); { projectService.checkNumberOfProjects({ configuredProjects: 1 }); const configuredProject = forEach(projectService.synchronizeProjectList([]), f => f.info.projectName === corruptedConfig.path && f); assert.isTrue(configuredProject !== undefined, "should find configured project"); checkProjectErrors(configuredProject, []); - const projectErrors = projectService.configuredProjects[0].getAllProjectErrors(); + const projectErrors = configuredProjectAt(projectService, 0).getAllProjectErrors(); checkProjectErrorsWorker(projectErrors, []); } }); @@ -172,18 +184,17 @@ namespace ts.projectSystem { const configuredProject = forEach(projectService.synchronizeProjectList([]), f => f.info.projectName === corruptedConfig.path && f); assert.isTrue(configuredProject !== undefined, "should find configured project"); checkProjectErrors(configuredProject, []); - const projectErrors = projectService.configuredProjects[0].getAllProjectErrors(); + const projectErrors = configuredProjectAt(projectService, 0).getAllProjectErrors(); checkProjectErrorsWorker(projectErrors, []); } // break config and trigger watcher host.reloadFS([file1, file2, corruptedConfig]); - host.triggerFileWatcherCallback(corruptedConfig.path, FileWatcherEventKind.Changed); { projectService.checkNumberOfProjects({ configuredProjects: 1 }); const configuredProject = forEach(projectService.synchronizeProjectList([]), f => f.info.projectName === corruptedConfig.path && f); assert.isTrue(configuredProject !== undefined, "should find configured project"); checkProjectErrors(configuredProject, []); - const projectErrors = projectService.configuredProjects[0].getAllProjectErrors(); + const projectErrors = configuredProjectAt(projectService, 0).getAllProjectErrors(); checkProjectErrorsWorker(projectErrors, [ "'{' expected." ]); diff --git a/src/harness/unittests/reuseProgramStructure.ts b/src/harness/unittests/reuseProgramStructure.ts index 8c8c10346777e..5c2b9edd9d0ba 100644 --- a/src/harness/unittests/reuseProgramStructure.ts +++ b/src/harness/unittests/reuseProgramStructure.ts @@ -346,7 +346,7 @@ namespace ts { const program_2 = updateProgram(program_1, ["a.ts"], options, noop, newTexts); assert.deepEqual(emptyArray, program_2.getMissingFilePaths()); - assert.equal(StructureIsReused.SafeModules, program_1.structureIsReused); + assert.equal(StructureIsReused.Not, program_1.structureIsReused); }); it("resolution cache follows imports", () => { @@ -869,4 +869,172 @@ namespace ts { createProgram([], {}); }); }); + + import TestSystem = ts.TestFSWithWatch.TestServerHost; + type FileOrFolder = ts.TestFSWithWatch.FileOrFolder; + import createTestSystem = ts.TestFSWithWatch.createWatchedSystem; + import libFile = ts.TestFSWithWatch.libFile; + + describe("isProgramUptoDate should return true when there is no change in compiler options and", () => { + function verifyProgramIsUptoDate( + program: Program, + newRootFileNames: string[], + newOptions: CompilerOptions + ) { + const actual = isProgramUptoDate( + program, newRootFileNames, newOptions, + path => program.getSourceFileByPath(path).version, /*fileExists*/ returnFalse, + /*hasInvalidatedResolution*/ returnFalse, + /*hasChangedAutomaticTypeDirectiveNames*/ false + ); + assert.isTrue(actual); + } + + function duplicate(options: CompilerOptions): CompilerOptions; + function duplicate(fileNames: string[]): string[]; + function duplicate(filesOrOptions: CompilerOptions | string[]) { + return JSON.parse(JSON.stringify(filesOrOptions)); + } + + function createWatchingSystemHost(host: TestSystem) { + return ts.createWatchingSystemHost(/*pretty*/ undefined, host); + } + + function verifyProgramWithoutConfigFile(watchingSystemHost: WatchingSystemHost, rootFiles: string[], options: CompilerOptions) { + const program = createWatchModeWithoutConfigFile(rootFiles, options, watchingSystemHost)(); + verifyProgramIsUptoDate(program, duplicate(rootFiles), duplicate(options)); + } + + function getConfigParseResult(watchingSystemHost: WatchingSystemHost, configFileName: string) { + return parseConfigFile(configFileName, {}, watchingSystemHost.system, watchingSystemHost.reportDiagnostic, watchingSystemHost.reportWatchDiagnostic); + } + + function verifyProgramWithConfigFile(watchingSystemHost: WatchingSystemHost, configFile: string) { + const result = getConfigParseResult(watchingSystemHost, configFile); + const program = createWatchModeWithConfigFile(result, {}, watchingSystemHost)(); + const { fileNames, options } = getConfigParseResult(watchingSystemHost, configFile); + verifyProgramIsUptoDate(program, fileNames, options); + } + + function verifyProgram(files: FileOrFolder[], rootFiles: string[], options: CompilerOptions, configFile: string) { + const watchingSystemHost = createWatchingSystemHost(createTestSystem(files)); + verifyProgramWithoutConfigFile(watchingSystemHost, rootFiles, options); + verifyProgramWithConfigFile(watchingSystemHost, configFile); + } + + it("has empty options", () => { + const file1: FileOrFolder = { + path: "/a/b/file1.ts", + content: "let x = 1" + }; + const file2: FileOrFolder = { + path: "/a/b/file2.ts", + content: "let y = 1" + }; + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: "{}" + }; + verifyProgram([file1, file2, libFile, configFile], [file1.path, file2.path], {}, configFile.path); + }); + + it("has lib specified in the options", () => { + const compilerOptions: CompilerOptions = { lib: ["es5", "es2015.promise"] }; + const app: FileOrFolder = { + path: "/src/app.ts", + content: "var x: Promise;" + }; + const configFile: FileOrFolder = { + path: "/src/tsconfig.json", + content: JSON.stringify({ compilerOptions }) + }; + const es5Lib: FileOrFolder = { + path: "/compiler/lib.es5.d.ts", + content: "declare const eval: any" + }; + const es2015Promise: FileOrFolder = { + path: "/compiler/lib.es2015.promise.d.ts", + content: "declare class Promise {}" + }; + + verifyProgram([app, configFile, es5Lib, es2015Promise], [app.path], compilerOptions, configFile.path); + }); + + it("has paths specified in the options", () => { + const compilerOptions: CompilerOptions = { + baseUrl: ".", + paths: { + "*": [ + "packages/mail/data/*", + "packages/styles/*", + "*" + ] + } + }; + const app: FileOrFolder = { + path: "/src/packages/framework/app.ts", + content: 'import classc from "module1/lib/file1";\ + import classD from "module3/file3";\ + let x = new classc();\ + let y = new classD();' + }; + const module1: FileOrFolder = { + path: "/src/packages/mail/data/module1/lib/file1.ts", + content: 'import classc from "module2/file2";export default classc;', + }; + const module2: FileOrFolder = { + path: "/src/packages/mail/data/module1/lib/module2/file2.ts", + content: 'class classc { method2() { return "hello"; } }\nexport default classc', + }; + const module3: FileOrFolder = { + path: "/src/packages/styles/module3/file3.ts", + content: "class classD { method() { return 10; } }\nexport default classD;" + }; + const configFile: FileOrFolder = { + path: "/src/tsconfig.json", + content: JSON.stringify({ compilerOptions }) + }; + + verifyProgram([app, module1, module2, module3, libFile, configFile], [app.path], compilerOptions, configFile.path); + }); + + it("has include paths specified in tsconfig file", () => { + const compilerOptions: CompilerOptions = { + baseUrl: ".", + paths: { + "*": [ + "packages/mail/data/*", + "packages/styles/*", + "*" + ] + } + }; + const app: FileOrFolder = { + path: "/src/packages/framework/app.ts", + content: 'import classc from "module1/lib/file1";\ + import classD from "module3/file3";\ + let x = new classc();\ + let y = new classD();' + }; + const module1: FileOrFolder = { + path: "/src/packages/mail/data/module1/lib/file1.ts", + content: 'import classc from "module2/file2";export default classc;', + }; + const module2: FileOrFolder = { + path: "/src/packages/mail/data/module1/lib/module2/file2.ts", + content: 'class classc { method2() { return "hello"; } }\nexport default classc', + }; + const module3: FileOrFolder = { + path: "/src/packages/styles/module3/file3.ts", + content: "class classD { method() { return 10; } }\nexport default classD;" + }; + const configFile: FileOrFolder = { + path: "/src/tsconfig.json", + content: JSON.stringify({ compilerOptions, include: ["packages/**/ *.ts"] }) + }; + + const watchingSystemHost = createWatchingSystemHost(createTestSystem([app, module1, module2, module3, libFile, configFile])); + verifyProgramWithConfigFile(watchingSystemHost, configFile.path); + }); + }); } diff --git a/src/harness/unittests/session.ts b/src/harness/unittests/session.ts index f37c9ea23920a..1e5d9847ce893 100644 --- a/src/harness/unittests/session.ts +++ b/src/harness/unittests/session.ts @@ -25,7 +25,7 @@ namespace ts.server { clearTimeout: noop, setImmediate: () => 0, clearImmediate: noop, - createHash: Harness.LanguageService.mockHash, + createHash: Harness.mockHash, }; class TestSession extends Session { diff --git a/src/harness/unittests/telemetry.ts b/src/harness/unittests/telemetry.ts index 7f3428c839324..7c09806632180 100644 --- a/src/harness/unittests/telemetry.ts +++ b/src/harness/unittests/telemetry.ts @@ -11,18 +11,24 @@ namespace ts.projectSystem { }); it("only sends an event once", () => { - const file = makeFile("/a.ts"); - const tsconfig = makeFile("/tsconfig.json", {}); + const file = makeFile("/a/a.ts"); + const file2 = makeFile("/b.ts"); + const tsconfig = makeFile("/a/tsconfig.json", {}); - const et = new EventTracker([file, tsconfig]); + const et = new EventTracker([file, file2, tsconfig]); et.service.openClientFile(file.path); - et.assertProjectInfoTelemetryEvent({}); + et.assertProjectInfoTelemetryEvent({}, tsconfig.path); et.service.closeClientFile(file.path); - checkNumberOfProjects(et.service, { configuredProjects: 0 }); + checkNumberOfProjects(et.service, { configuredProjects: 1 }); + + et.service.openClientFile(file2.path); + checkNumberOfProjects(et.service, { inferredProjects: 1 }); + + assert.equal(et.getEvents().length, 0); et.service.openClientFile(file.path); - checkNumberOfProjects(et.service, { configuredProjects: 1 }); + checkNumberOfProjects(et.service, { configuredProjects: 1, inferredProjects: 1 }); assert.equal(et.getEvents().length, 0); }); @@ -53,7 +59,7 @@ namespace ts.projectSystem { // TODO: Apparently compilerOptions is mutated, so have to repeat it here! et.assertProjectInfoTelemetryEvent({ - projectId: Harness.LanguageService.mockHash("/hunter2/foo.csproj"), + projectId: Harness.mockHash("/hunter2/foo.csproj"), compilerOptions: { strict: true }, compileOnSave: true, // These properties can't be present for an external project, so they are undefined instead of false. @@ -195,7 +201,7 @@ namespace ts.projectSystem { const et = new EventTracker([jsconfig, file]); et.service.openClientFile(file.path); et.assertProjectInfoTelemetryEvent({ - projectId: Harness.LanguageService.mockHash("/jsconfig.json"), + projectId: Harness.mockHash("/jsconfig.json"), fileStats: fileStats({ js: 1 }), compilerOptions: autoJsCompilerOptions, typeAcquisition: { @@ -215,7 +221,7 @@ namespace ts.projectSystem { et.service.openClientFile(file.path); et.getEvent(server.ProjectLanguageServiceStateEvent, /*mayBeMore*/ true); et.assertProjectInfoTelemetryEvent({ - projectId: Harness.LanguageService.mockHash("/jsconfig.json"), + projectId: Harness.mockHash("/jsconfig.json"), fileStats: fileStats({ js: 1 }), compilerOptions: autoJsCompilerOptions, configFileName: "jsconfig.json", @@ -249,9 +255,9 @@ namespace ts.projectSystem { return events; } - assertProjectInfoTelemetryEvent(partial: Partial): void { + assertProjectInfoTelemetryEvent(partial: Partial, configFile?: string): void { assert.deepEqual(this.getEvent(ts.server.ProjectInfoTelemetryEvent), { - projectId: Harness.LanguageService.mockHash("/tsconfig.json"), + projectId: Harness.mockHash(configFile || "/tsconfig.json"), fileStats: fileStats({ ts: 1 }), compilerOptions: {}, extends: false, @@ -282,7 +288,7 @@ namespace ts.projectSystem { } function makeFile(path: string, content: {} = ""): projectSystem.FileOrFolder { - return { path, content: typeof content === "string" ? "" : JSON.stringify(content) }; + return { path, content: isString(content) ? "" : JSON.stringify(content) }; } function fileStats(nonZeroStats: Partial): server.FileStats { diff --git a/src/harness/unittests/textStorage.ts b/src/harness/unittests/textStorage.ts index 545d090df9b33..aa8231aa31af4 100644 --- a/src/harness/unittests/textStorage.ts +++ b/src/harness/unittests/textStorage.ts @@ -21,7 +21,7 @@ namespace ts.textStorage { const ts1 = new server.TextStorage(host, server.asNormalizedPath(f.path)); const ts2 = new server.TextStorage(host, server.asNormalizedPath(f.path)); - ts1.useScriptVersionCache(); + ts1.useScriptVersionCache_TestOnly(); ts2.useText(); const lineMap = computeLineStarts(f.content); @@ -55,16 +55,16 @@ namespace ts.textStorage { const ts1 = new server.TextStorage(host, server.asNormalizedPath(f.path)); ts1.getSnapshot(); - assert.isTrue(!ts1.hasScriptVersionCache(), "should not have script version cache - 1"); + assert.isTrue(!ts1.hasScriptVersionCache_TestOnly(), "should not have script version cache - 1"); ts1.edit(0, 5, " "); - assert.isTrue(ts1.hasScriptVersionCache(), "have script version cache - 1"); + assert.isTrue(ts1.hasScriptVersionCache_TestOnly(), "have script version cache - 1"); ts1.useText(); - assert.isTrue(!ts1.hasScriptVersionCache(), "should not have script version cache - 2"); + assert.isTrue(!ts1.hasScriptVersionCache_TestOnly(), "should not have script version cache - 2"); ts1.getLineInfo(0); - assert.isTrue(ts1.hasScriptVersionCache(), "have script version cache - 2"); + assert.isTrue(ts1.hasScriptVersionCache_TestOnly(), "have script version cache - 2"); }); }); -} \ No newline at end of file +} diff --git a/src/harness/unittests/tscWatchMode.ts b/src/harness/unittests/tscWatchMode.ts new file mode 100644 index 0000000000000..5267bbcf0478c --- /dev/null +++ b/src/harness/unittests/tscWatchMode.ts @@ -0,0 +1,1659 @@ +/// +/// +/// + +namespace ts.tscWatch { + + import WatchedSystem = ts.TestFSWithWatch.TestServerHost; + type FileOrFolder = ts.TestFSWithWatch.FileOrFolder; + import createWatchedSystem = ts.TestFSWithWatch.createWatchedSystem; + import checkFileNames = ts.TestFSWithWatch.checkFileNames; + import libFile = ts.TestFSWithWatch.libFile; + import checkWatchedFiles = ts.TestFSWithWatch.checkWatchedFiles; + import checkWatchedDirectories = ts.TestFSWithWatch.checkWatchedDirectories; + import checkOutputContains = ts.TestFSWithWatch.checkOutputContains; + import checkOutputDoesNotContain = ts.TestFSWithWatch.checkOutputDoesNotContain; + + export function checkProgramActualFiles(program: Program, expectedFiles: string[]) { + checkFileNames(`Program actual files`, program.getSourceFiles().map(file => file.fileName), expectedFiles); + } + + export function checkProgramRootFiles(program: Program, expectedFiles: string[]) { + checkFileNames(`Program rootFileNames`, program.getRootFileNames(), expectedFiles); + } + + function createWatchingSystemHost(system: WatchedSystem) { + return ts.createWatchingSystemHost(/*pretty*/ undefined, system); + } + + function parseConfigFile(configFileName: string, watchingSystemHost: WatchingSystemHost) { + return ts.parseConfigFile(configFileName, {}, watchingSystemHost.system, watchingSystemHost.reportDiagnostic, watchingSystemHost.reportWatchDiagnostic); + } + + function createWatchModeWithConfigFile(configFilePath: string, host: WatchedSystem) { + const watchingSystemHost = createWatchingSystemHost(host); + const configFileResult = parseConfigFile(configFilePath, watchingSystemHost); + return ts.createWatchModeWithConfigFile(configFileResult, {}, watchingSystemHost); + } + + function createWatchModeWithoutConfigFile(fileNames: string[], host: WatchedSystem, options: CompilerOptions = {}) { + const watchingSystemHost = createWatchingSystemHost(host); + return ts.createWatchModeWithoutConfigFile(fileNames, options, watchingSystemHost); + } + + function getEmittedLineForMultiFileOutput(file: FileOrFolder, host: WatchedSystem) { + return `TSFILE: ${file.path.replace(".ts", ".js")}${host.newLine}`; + } + + function getEmittedLineForSingleFileOutput(filename: string, host: WatchedSystem) { + return `TSFILE: ${filename}${host.newLine}`; + } + + interface FileOrFolderEmit extends FileOrFolder { + output?: string; + } + + function getFileOrFolderEmit(file: FileOrFolder, getOutput?: (file: FileOrFolder) => string): FileOrFolderEmit { + const result = file as FileOrFolderEmit; + if (getOutput) { + result.output = getOutput(file); + } + return result; + } + + function getEmittedLines(files: FileOrFolderEmit[]) { + const seen = createMap(); + const result: string[] = []; + for (const { output } of files) { + if (output && !seen.has(output)) { + seen.set(output, true); + result.push(output); + } + } + return result; + } + + function checkAffectedLines(host: WatchedSystem, affectedFiles: FileOrFolderEmit[], allEmittedFiles: string[]) { + const expectedAffectedFiles = getEmittedLines(affectedFiles); + const expectedNonAffectedFiles = mapDefined(allEmittedFiles, line => contains(expectedAffectedFiles, line) ? undefined : line); + checkOutputContains(host, expectedAffectedFiles); + checkOutputDoesNotContain(host, expectedNonAffectedFiles); + } + + describe("tsc-watch program updates", () => { + const commonFile1: FileOrFolder = { + path: "/a/b/commonFile1.ts", + content: "let x = 1" + }; + const commonFile2: FileOrFolder = { + path: "/a/b/commonFile2.ts", + content: "let y = 1" + }; + + it("create watch without config file", () => { + const appFile: FileOrFolder = { + path: "/a/b/c/app.ts", + content: ` + import {f} from "./module" + console.log(f) + ` + }; + + const moduleFile: FileOrFolder = { + path: "/a/b/c/module.d.ts", + content: `export let x: number` + }; + const host = createWatchedSystem([appFile, moduleFile, libFile]); + const watch = createWatchModeWithoutConfigFile([appFile.path], host); + + checkProgramActualFiles(watch(), [appFile.path, libFile.path, moduleFile.path]); + + // TODO: Should we watch creation of config files in the root file's file hierarchy? + + // const configFileLocations = ["/a/b/c/", "/a/b/", "/a/", "/"]; + // const configFiles = flatMap(configFileLocations, location => [location + "tsconfig.json", location + "jsconfig.json"]); + // checkWatchedFiles(host, configFiles.concat(libFile.path, moduleFile.path)); + }); + + it("can handle tsconfig file name with difference casing", () => { + const f1 = { + path: "/a/b/app.ts", + content: "let x = 1" + }; + const config = { + path: "/a/b/tsconfig.json", + content: JSON.stringify({ + include: ["app.ts"] + }) + }; + + const host = createWatchedSystem([f1, config], { useCaseSensitiveFileNames: false }); + const upperCaseConfigFilePath = combinePaths(getDirectoryPath(config.path).toUpperCase(), getBaseFileName(config.path)); + const watch = createWatchModeWithConfigFile(upperCaseConfigFilePath, host); + checkProgramActualFiles(watch(), [combinePaths(getDirectoryPath(upperCaseConfigFilePath), getBaseFileName(f1.path))]); + }); + + it("create configured project without file list", () => { + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: ` + { + "compilerOptions": {}, + "exclude": [ + "e" + ] + }` + }; + const file1: FileOrFolder = { + path: "/a/b/c/f1.ts", + content: "let x = 1" + }; + const file2: FileOrFolder = { + path: "/a/b/d/f2.ts", + content: "let y = 1" + }; + const file3: FileOrFolder = { + path: "/a/b/e/f3.ts", + content: "let z = 1" + }; + + const host = createWatchedSystem([configFile, libFile, file1, file2, file3]); + const watchingSystemHost = createWatchingSystemHost(host); + const configFileResult = parseConfigFile(configFile.path, watchingSystemHost); + assert.equal(configFileResult.errors.length, 0, `expect no errors in config file, got ${JSON.stringify(configFileResult.errors)}`); + + const watch = ts.createWatchModeWithConfigFile(configFileResult, {}, watchingSystemHost); + + checkProgramActualFiles(watch(), [file1.path, libFile.path, file2.path]); + checkProgramRootFiles(watch(), [file1.path, file2.path]); + checkWatchedFiles(host, [configFile.path, file1.path, file2.path, libFile.path]); + const configDir = getDirectoryPath(configFile.path); + checkWatchedDirectories(host, projectSystem.getTypeRootsFromLocation(configDir).concat(configDir), /*recursive*/ true); + }); + + // TODO: if watching for config file creation + // it("add and then remove a config file in a folder with loose files", () => { + // }); + + it("add new files to a configured program without file list", () => { + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: `{}` + }; + const host = createWatchedSystem([commonFile1, libFile, configFile]); + const watch = createWatchModeWithConfigFile(configFile.path, host); + const configDir = getDirectoryPath(configFile.path); + checkWatchedDirectories(host, projectSystem.getTypeRootsFromLocation(configDir).concat(configDir), /*recursive*/ true); + + checkProgramRootFiles(watch(), [commonFile1.path]); + + // add a new ts file + host.reloadFS([commonFile1, commonFile2, libFile, configFile]); + host.checkTimeoutQueueLengthAndRun(1); + checkProgramRootFiles(watch(), [commonFile1.path, commonFile2.path]); + }); + + it("should ignore non-existing files specified in the config file", () => { + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: `{ + "compilerOptions": {}, + "files": [ + "commonFile1.ts", + "commonFile3.ts" + ] + }` + }; + const host = createWatchedSystem([commonFile1, commonFile2, configFile]); + const watch = createWatchModeWithConfigFile(configFile.path, host); + + const commonFile3 = "/a/b/commonFile3.ts"; + checkProgramRootFiles(watch(), [commonFile1.path, commonFile3]); + checkProgramActualFiles(watch(), [commonFile1.path]); + }); + + it("handle recreated files correctly", () => { + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: `{}` + }; + const host = createWatchedSystem([commonFile1, commonFile2, configFile]); + const watch = createWatchModeWithConfigFile(configFile.path, host); + checkProgramRootFiles(watch(), [commonFile1.path, commonFile2.path]); + + // delete commonFile2 + host.reloadFS([commonFile1, configFile]); + host.checkTimeoutQueueLengthAndRun(1); + checkProgramRootFiles(watch(), [commonFile1.path]); + + // re-add commonFile2 + host.reloadFS([commonFile1, commonFile2, configFile]); + host.checkTimeoutQueueLengthAndRun(1); + checkProgramRootFiles(watch(), [commonFile1.path, commonFile2.path]); + }); + + it("handles the missing files - that were added to program because they were added with /// { + const file1: FileOrFolder = { + path: "/a/b/commonFile1.ts", + content: `/// + let x = y` + }; + const host = createWatchedSystem([file1, libFile]); + const watch = createWatchModeWithoutConfigFile([file1.path], host); + + checkProgramRootFiles(watch(), [file1.path]); + checkProgramActualFiles(watch(), [file1.path, libFile.path]); + const errors = [ + `a/b/commonFile1.ts(1,22): error TS6053: File '${commonFile2.path}' not found.${host.newLine}`, + `a/b/commonFile1.ts(2,29): error TS2304: Cannot find name 'y'.${host.newLine}` + ]; + checkOutputContains(host, errors); + host.clearOutput(); + + host.reloadFS([file1, commonFile2, libFile]); + host.runQueuedTimeoutCallbacks(); + checkProgramRootFiles(watch(), [file1.path]); + checkProgramActualFiles(watch(), [file1.path, libFile.path, commonFile2.path]); + checkOutputDoesNotContain(host, errors); + }); + + it("should reflect change in config file", () => { + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: `{ + "compilerOptions": {}, + "files": ["${commonFile1.path}", "${commonFile2.path}"] + }` + }; + const files = [commonFile1, commonFile2, configFile]; + const host = createWatchedSystem(files); + const watch = createWatchModeWithConfigFile(configFile.path, host); + + checkProgramRootFiles(watch(), [commonFile1.path, commonFile2.path]); + configFile.content = `{ + "compilerOptions": {}, + "files": ["${commonFile1.path}"] + }`; + + host.reloadFS(files); + host.checkTimeoutQueueLengthAndRun(1); // reload the configured project + checkProgramRootFiles(watch(), [commonFile1.path]); + }); + + it("files explicitly excluded in config file", () => { + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: `{ + "compilerOptions": {}, + "exclude": ["/a/c"] + }` + }; + const excludedFile1: FileOrFolder = { + path: "/a/c/excluedFile1.ts", + content: `let t = 1;` + }; + + const host = createWatchedSystem([commonFile1, commonFile2, excludedFile1, configFile]); + const watch = createWatchModeWithConfigFile(configFile.path, host); + checkProgramRootFiles(watch(), [commonFile1.path, commonFile2.path]); + }); + + it("should properly handle module resolution changes in config file", () => { + const file1: FileOrFolder = { + path: "/a/b/file1.ts", + content: `import { T } from "module1";` + }; + const nodeModuleFile: FileOrFolder = { + path: "/a/b/node_modules/module1.ts", + content: `export interface T {}` + }; + const classicModuleFile: FileOrFolder = { + path: "/a/module1.ts", + content: `export interface T {}` + }; + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: `{ + "compilerOptions": { + "moduleResolution": "node" + }, + "files": ["${file1.path}"] + }` + }; + const files = [file1, nodeModuleFile, classicModuleFile, configFile]; + const host = createWatchedSystem(files); + const watch = createWatchModeWithConfigFile(configFile.path, host); + checkProgramRootFiles(watch(), [file1.path]); + checkProgramActualFiles(watch(), [file1.path, nodeModuleFile.path]); + + configFile.content = `{ + "compilerOptions": { + "moduleResolution": "classic" + }, + "files": ["${file1.path}"] + }`; + host.reloadFS(files); + host.checkTimeoutQueueLengthAndRun(1); + checkProgramRootFiles(watch(), [file1.path]); + checkProgramActualFiles(watch(), [file1.path, classicModuleFile.path]); + }); + + it("should tolerate config file errors and still try to build a project", () => { + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: `{ + "compilerOptions": { + "target": "es6", + "allowAnything": true + }, + "someOtherProperty": {} + }` + }; + const host = createWatchedSystem([commonFile1, commonFile2, libFile, configFile]); + const watch = createWatchModeWithConfigFile(configFile.path, host); + checkProgramRootFiles(watch(), [commonFile1.path, commonFile2.path]); + }); + + it("changes in files are reflected in project structure", () => { + const file1 = { + path: "/a/b/f1.ts", + content: `export * from "./f2"` + }; + const file2 = { + path: "/a/b/f2.ts", + content: `export let x = 1` + }; + const file3 = { + path: "/a/c/f3.ts", + content: `export let y = 1;` + }; + const host = createWatchedSystem([file1, file2, file3]); + const watch = createWatchModeWithoutConfigFile([file1.path], host); + checkProgramRootFiles(watch(), [file1.path]); + checkProgramActualFiles(watch(), [file1.path, file2.path]); + + const modifiedFile2 = { + path: file2.path, + content: `export * from "../c/f3"` // now inferred project should inclule file3 + }; + + host.reloadFS([file1, modifiedFile2, file3]); + host.checkTimeoutQueueLengthAndRun(1); + checkProgramRootFiles(watch(), [file1.path]); + checkProgramActualFiles(watch(), [file1.path, modifiedFile2.path, file3.path]); + }); + + it("deleted files affect project structure", () => { + const file1 = { + path: "/a/b/f1.ts", + content: `export * from "./f2"` + }; + const file2 = { + path: "/a/b/f2.ts", + content: `export * from "../c/f3"` + }; + const file3 = { + path: "/a/c/f3.ts", + content: `export let y = 1;` + }; + const host = createWatchedSystem([file1, file2, file3]); + const watch = createWatchModeWithoutConfigFile([file1.path], host); + checkProgramActualFiles(watch(), [file1.path, file2.path, file3.path]); + + host.reloadFS([file1, file3]); + host.checkTimeoutQueueLengthAndRun(1); + + checkProgramActualFiles(watch(), [file1.path]); + }); + + it("deleted files affect project structure - 2", () => { + const file1 = { + path: "/a/b/f1.ts", + content: `export * from "./f2"` + }; + const file2 = { + path: "/a/b/f2.ts", + content: `export * from "../c/f3"` + }; + const file3 = { + path: "/a/c/f3.ts", + content: `export let y = 1;` + }; + const host = createWatchedSystem([file1, file2, file3]); + const watch = createWatchModeWithoutConfigFile([file1.path, file3.path], host); + checkProgramActualFiles(watch(), [file1.path, file2.path, file3.path]); + + host.reloadFS([file1, file3]); + host.checkTimeoutQueueLengthAndRun(1); + + checkProgramActualFiles(watch(), [file1.path, file3.path]); + }); + + it("config file includes the file", () => { + const file1 = { + path: "/a/b/f1.ts", + content: "export let x = 5" + }; + const file2 = { + path: "/a/c/f2.ts", + content: `import {x} from "../b/f1"` + }; + const file3 = { + path: "/a/c/f3.ts", + content: "export let y = 1" + }; + const configFile = { + path: "/a/c/tsconfig.json", + content: JSON.stringify({ compilerOptions: {}, files: ["f2.ts", "f3.ts"] }) + }; + + const host = createWatchedSystem([file1, file2, file3, configFile]); + const watch = createWatchModeWithConfigFile(configFile.path, host); + + checkProgramRootFiles(watch(), [file2.path, file3.path]); + checkProgramActualFiles(watch(), [file1.path, file2.path, file3.path]); + }); + + it("correctly migrate files between projects", () => { + const file1 = { + path: "/a/b/f1.ts", + content: ` + export * from "../c/f2"; + export * from "../d/f3";` + }; + const file2 = { + path: "/a/c/f2.ts", + content: "export let x = 1;" + }; + const file3 = { + path: "/a/d/f3.ts", + content: "export let y = 1;" + }; + const host = createWatchedSystem([file1, file2, file3]); + const watch = createWatchModeWithoutConfigFile([file2.path, file3.path], host); + checkProgramActualFiles(watch(), [file2.path, file3.path]); + + const watch2 = createWatchModeWithoutConfigFile([file1.path], host); + checkProgramActualFiles(watch2(), [file1.path, file2.path, file3.path]); + + // Previous program shouldnt be updated + checkProgramActualFiles(watch(), [file2.path, file3.path]); + host.checkTimeoutQueueLength(0); + }); + + it("can correctly update configured project when set of root files has changed (new file on disk)", () => { + const file1 = { + path: "/a/b/f1.ts", + content: "let x = 1" + }; + const file2 = { + path: "/a/b/f2.ts", + content: "let y = 1" + }; + const configFile = { + path: "/a/b/tsconfig.json", + content: JSON.stringify({ compilerOptions: {} }) + }; + + const host = createWatchedSystem([file1, configFile]); + const watch = createWatchModeWithConfigFile(configFile.path, host); + checkProgramActualFiles(watch(), [file1.path]); + + host.reloadFS([file1, file2, configFile]); + host.checkTimeoutQueueLengthAndRun(1); + + checkProgramActualFiles(watch(), [file1.path, file2.path]); + checkProgramRootFiles(watch(), [file1.path, file2.path]); + }); + + it("can correctly update configured project when set of root files has changed (new file in list of files)", () => { + const file1 = { + path: "/a/b/f1.ts", + content: "let x = 1" + }; + const file2 = { + path: "/a/b/f2.ts", + content: "let y = 1" + }; + const configFile = { + path: "/a/b/tsconfig.json", + content: JSON.stringify({ compilerOptions: {}, files: ["f1.ts"] }) + }; + + const host = createWatchedSystem([file1, file2, configFile]); + const watch = createWatchModeWithConfigFile(configFile.path, host); + + checkProgramActualFiles(watch(), [file1.path]); + + const modifiedConfigFile = { + path: configFile.path, + content: JSON.stringify({ compilerOptions: {}, files: ["f1.ts", "f2.ts"] }) + }; + + host.reloadFS([file1, file2, modifiedConfigFile]); + host.checkTimeoutQueueLengthAndRun(1); + checkProgramRootFiles(watch(), [file1.path, file2.path]); + checkProgramActualFiles(watch(), [file1.path, file2.path]); + }); + + it("can update configured project when set of root files was not changed", () => { + const file1 = { + path: "/a/b/f1.ts", + content: "let x = 1" + }; + const file2 = { + path: "/a/b/f2.ts", + content: "let y = 1" + }; + const configFile = { + path: "/a/b/tsconfig.json", + content: JSON.stringify({ compilerOptions: {}, files: ["f1.ts", "f2.ts"] }) + }; + + const host = createWatchedSystem([file1, file2, configFile]); + const watch = createWatchModeWithConfigFile(configFile.path, host); + checkProgramActualFiles(watch(), [file1.path, file2.path]); + + const modifiedConfigFile = { + path: configFile.path, + content: JSON.stringify({ compilerOptions: { outFile: "out.js" }, files: ["f1.ts", "f2.ts"] }) + }; + + host.reloadFS([file1, file2, modifiedConfigFile]); + host.checkTimeoutQueueLengthAndRun(1); + checkProgramRootFiles(watch(), [file1.path, file2.path]); + checkProgramActualFiles(watch(), [file1.path, file2.path]); + }); + + it("config file is deleted", () => { + const file1 = { + path: "/a/b/f1.ts", + content: "let x = 1;" + }; + const file2 = { + path: "/a/b/f2.ts", + content: "let y = 2;" + }; + const config = { + path: "/a/b/tsconfig.json", + content: JSON.stringify({ compilerOptions: {} }) + }; + const host = createWatchedSystem([file1, file2, config]); + const watch = createWatchModeWithConfigFile(config.path, host); + + checkProgramActualFiles(watch(), [file1.path, file2.path]); + + host.clearOutput(); + host.reloadFS([file1, file2]); + host.checkTimeoutQueueLengthAndRun(1); + + assert.equal(host.exitCode, ExitStatus.DiagnosticsPresent_OutputsSkipped); + checkOutputContains(host, [`error TS6053: File '${config.path}' not found.${host.newLine}`]); + }); + + it("Proper errors: document is not contained in project", () => { + const file1 = { + path: "/a/b/app.ts", + content: "" + }; + const corruptedConfig = { + path: "/a/b/tsconfig.json", + content: "{" + }; + const host = createWatchedSystem([file1, corruptedConfig]); + const watch = createWatchModeWithConfigFile(corruptedConfig.path, host); + + checkProgramActualFiles(watch(), [file1.path]); + }); + + it("correctly handles changes in lib section of config file", () => { + const libES5 = { + path: "/compiler/lib.es5.d.ts", + content: "declare const eval: any" + }; + const libES2015Promise = { + path: "/compiler/lib.es2015.promise.d.ts", + content: "declare class Promise {}" + }; + const app = { + path: "/src/app.ts", + content: "var x: Promise;" + }; + const config1 = { + path: "/src/tsconfig.json", + content: JSON.stringify( + { + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "noImplicitAny": true, + "sourceMap": false, + "lib": [ + "es5" + ] + } + }) + }; + const config2 = { + path: config1.path, + content: JSON.stringify( + { + "compilerOptions": { + "module": "commonjs", + "target": "es5", + "noImplicitAny": true, + "sourceMap": false, + "lib": [ + "es5", + "es2015.promise" + ] + } + }) + }; + const host = createWatchedSystem([libES5, libES2015Promise, app, config1], { executingFilePath: "/compiler/tsc.js" }); + const watch = createWatchModeWithConfigFile(config1.path, host); + + checkProgramActualFiles(watch(), [libES5.path, app.path]); + + host.reloadFS([libES5, libES2015Promise, app, config2]); + host.checkTimeoutQueueLengthAndRun(1); + checkProgramActualFiles(watch(), [libES5.path, libES2015Promise.path, app.path]); + }); + + it("should handle non-existing directories in config file", () => { + const f = { + path: "/a/src/app.ts", + content: "let x = 1;" + }; + const config = { + path: "/a/tsconfig.json", + content: JSON.stringify({ + compilerOptions: {}, + include: [ + "src/**/*", + "notexistingfolder/*" + ] + }) + }; + const host = createWatchedSystem([f, config]); + const watch = createWatchModeWithConfigFile(config.path, host); + checkProgramActualFiles(watch(), [f.path]); + }); + + it("rename a module file and rename back should restore the states for inferred projects", () => { + const moduleFile = { + path: "/a/b/moduleFile.ts", + content: "export function bar() { };" + }; + const file1 = { + path: "/a/b/file1.ts", + content: "import * as T from './moduleFile'; T.bar();" + }; + const host = createWatchedSystem([moduleFile, file1, libFile]); + createWatchModeWithoutConfigFile([file1.path], host); + const error = "a/b/file1.ts(1,20): error TS2307: Cannot find module \'./moduleFile\'.\n"; + checkOutputDoesNotContain(host, [error]); + + const moduleFileOldPath = moduleFile.path; + const moduleFileNewPath = "/a/b/moduleFile1.ts"; + moduleFile.path = moduleFileNewPath; + host.reloadFS([moduleFile, file1, libFile]); + host.runQueuedTimeoutCallbacks(); + checkOutputContains(host, [error]); + + host.clearOutput(); + moduleFile.path = moduleFileOldPath; + host.reloadFS([moduleFile, file1, libFile]); + host.runQueuedTimeoutCallbacks(); + checkOutputDoesNotContain(host, [error]); + }); + + it("rename a module file and rename back should restore the states for configured projects", () => { + const moduleFile = { + path: "/a/b/moduleFile.ts", + content: "export function bar() { };" + }; + const file1 = { + path: "/a/b/file1.ts", + content: "import * as T from './moduleFile'; T.bar();" + }; + const configFile = { + path: "/a/b/tsconfig.json", + content: `{}` + }; + const host = createWatchedSystem([moduleFile, file1, configFile, libFile]); + createWatchModeWithConfigFile(configFile.path, host); + + const error = "a/b/file1.ts(1,20): error TS2307: Cannot find module \'./moduleFile\'.\n"; + checkOutputDoesNotContain(host, [error]); + + const moduleFileOldPath = moduleFile.path; + const moduleFileNewPath = "/a/b/moduleFile1.ts"; + moduleFile.path = moduleFileNewPath; + host.clearOutput(); + host.reloadFS([moduleFile, file1, configFile, libFile]); + host.runQueuedTimeoutCallbacks(); + checkOutputContains(host, [error]); + + host.clearOutput(); + moduleFile.path = moduleFileOldPath; + host.reloadFS([moduleFile, file1, configFile, libFile]); + host.runQueuedTimeoutCallbacks(); + checkOutputDoesNotContain(host, [error]); + }); + + it("types should load from config file path if config exists", () => { + const f1 = { + path: "/a/b/app.ts", + content: "let x = 1" + }; + const config = { + path: "/a/b/tsconfig.json", + content: JSON.stringify({ compilerOptions: { types: ["node"], typeRoots: [] } }) + }; + const node = { + path: "/a/b/node_modules/@types/node/index.d.ts", + content: "declare var process: any" + }; + const cwd = { + path: "/a/c" + }; + const host = createWatchedSystem([f1, config, node, cwd], { currentDirectory: cwd.path }); + const watch = createWatchModeWithConfigFile(config.path, host); + + checkProgramActualFiles(watch(), [f1.path, node.path]); + }); + + it("add the missing module file for inferred project: should remove the `module not found` error", () => { + const moduleFile = { + path: "/a/b/moduleFile.ts", + content: "export function bar() { };" + }; + const file1 = { + path: "/a/b/file1.ts", + content: "import * as T from './moduleFile'; T.bar();" + }; + const host = createWatchedSystem([file1, libFile]); + createWatchModeWithoutConfigFile([file1.path], host); + + const error = `a/b/file1.ts(1,20): error TS2307: Cannot find module \'./moduleFile\'.${host.newLine}`; + checkOutputContains(host, [error]); + host.clearOutput(); + + host.reloadFS([file1, moduleFile, libFile]); + host.runQueuedTimeoutCallbacks(); + checkOutputDoesNotContain(host, [error]); + }); + + it("Configure file diagnostics events are generated when the config file has errors", () => { + const file = { + path: "/a/b/app.ts", + content: "let x = 10" + }; + const configFile = { + path: "/a/b/tsconfig.json", + content: `{ + "compilerOptions": { + "foo": "bar", + "allowJS": true + } + }` + }; + + const host = createWatchedSystem([file, configFile, libFile]); + createWatchModeWithConfigFile(configFile.path, host); + checkOutputContains(host, [ + `a/b/tsconfig.json(3,29): error TS5023: Unknown compiler option \'foo\'.${host.newLine}`, + `a/b/tsconfig.json(4,29): error TS5023: Unknown compiler option \'allowJS\'.${host.newLine}` + ]); + }); + + it("Configure file diagnostics events are generated when the config file doesn't have errors", () => { + const file = { + path: "/a/b/app.ts", + content: "let x = 10" + }; + const configFile = { + path: "/a/b/tsconfig.json", + content: `{ + "compilerOptions": {} + }` + }; + + const host = createWatchedSystem([file, configFile, libFile]); + createWatchModeWithConfigFile(configFile.path, host); + checkOutputDoesNotContain(host, [ + `a/b/tsconfig.json(3,29): error TS5023: Unknown compiler option \'foo\'.${host.newLine}`, + `a/b/tsconfig.json(4,29): error TS5023: Unknown compiler option \'allowJS\'.${host.newLine}` + ]); + }); + + it("Configure file diagnostics events are generated when the config file changes", () => { + const file = { + path: "/a/b/app.ts", + content: "let x = 10" + }; + const configFile = { + path: "/a/b/tsconfig.json", + content: `{ + "compilerOptions": {} + }` + }; + + const host = createWatchedSystem([file, configFile, libFile]); + createWatchModeWithConfigFile(configFile.path, host); + const error = `a/b/tsconfig.json(3,25): error TS5023: Unknown compiler option 'haha'.${host.newLine}`; + checkOutputDoesNotContain(host, [error]); + + configFile.content = `{ + "compilerOptions": { + "haha": 123 + } + }`; + host.reloadFS([file, configFile, libFile]); + host.runQueuedTimeoutCallbacks(); + checkOutputContains(host, [error]); + + host.clearOutput(); + configFile.content = `{ + "compilerOptions": {} + }`; + host.reloadFS([file, configFile, libFile]); + host.runQueuedTimeoutCallbacks(); + checkOutputDoesNotContain(host, [error]); + }); + + it("non-existing directories listed in config file input array should be tolerated without crashing the server", () => { + const configFile = { + path: "/a/b/tsconfig.json", + content: `{ + "compilerOptions": {}, + "include": ["app/*", "test/**/*", "something"] + }` + }; + const file1 = { + path: "/a/b/file1.ts", + content: "let t = 10;" + }; + + const host = createWatchedSystem([file1, configFile, libFile]); + const watch = createWatchModeWithConfigFile(configFile.path, host); + + checkProgramActualFiles(watch(), [libFile.path]); + }); + + it("non-existing directories listed in config file input array should be able to handle @types if input file list is empty", () => { + const f = { + path: "/a/app.ts", + content: "let x = 1" + }; + const config = { + path: "/a/tsconfig.json", + content: JSON.stringify({ + compiler: {}, + files: [] + }) + }; + const t1 = { + path: "/a/node_modules/@types/typings/index.d.ts", + content: `export * from "./lib"` + }; + const t2 = { + path: "/a/node_modules/@types/typings/lib.d.ts", + content: `export const x: number` + }; + const host = createWatchedSystem([f, config, t1, t2], { currentDirectory: getDirectoryPath(f.path) }); + const watch = createWatchModeWithConfigFile(config.path, host); + + checkProgramActualFiles(watch(), [t1.path, t2.path]); + }); + + it("should support files without extensions", () => { + const f = { + path: "/a/compile", + content: "let x = 1" + }; + const host = createWatchedSystem([f, libFile]); + const watch = createWatchModeWithoutConfigFile([f.path], host, { allowNonTsExtensions: true }); + checkProgramActualFiles(watch(), [f.path, libFile.path]); + }); + + it("Options Diagnostic locations reported correctly with changes in configFile contents when options change", () => { + const file = { + path: "/a/b/app.ts", + content: "let x = 10" + }; + const configFileContentBeforeComment = `{`; + const configFileContentComment = ` + // comment + // More comment`; + const configFileContentAfterComment = ` + "compilerOptions": { + "allowJs": true, + "declaration": true + } + }`; + const configFileContentWithComment = configFileContentBeforeComment + configFileContentComment + configFileContentAfterComment; + const configFileContentWithoutCommentLine = configFileContentBeforeComment + configFileContentAfterComment; + + const line = 5; + const errors = (line: number) => [ + `a/b/tsconfig.json(${line},25): error TS5053: Option \'allowJs\' cannot be specified with option \'declaration\'.\n`, + `a/b/tsconfig.json(${line + 1},25): error TS5053: Option \'allowJs\' cannot be specified with option \'declaration\'.\n` + ]; + + const configFile = { + path: "/a/b/tsconfig.json", + content: configFileContentWithComment + }; + + const host = createWatchedSystem([file, libFile, configFile]); + createWatchModeWithConfigFile(configFile.path, host); + checkOutputContains(host, errors(line)); + checkOutputDoesNotContain(host, errors(line - 2)); + host.clearOutput(); + + configFile.content = configFileContentWithoutCommentLine; + host.reloadFS([file, configFile]); + host.runQueuedTimeoutCallbacks(); + checkOutputContains(host, errors(line - 2)); + checkOutputDoesNotContain(host, errors(line)); + }); + }); + + describe("tsc-watch emit with outFile or out setting", () => { + function createWatchForOut(out?: string, outFile?: string) { + const host = createWatchedSystem([]); + const config: FileOrFolderEmit = { + path: "/a/tsconfig.json", + content: JSON.stringify({ + compilerOptions: { listEmittedFiles: true } + }) + }; + + let getOutput: (file: FileOrFolder) => string; + if (out) { + config.content = JSON.stringify({ + compilerOptions: { listEmittedFiles: true, out } + }); + getOutput = __ => getEmittedLineForSingleFileOutput(out, host); + } + else if (outFile) { + config.content = JSON.stringify({ + compilerOptions: { listEmittedFiles: true, outFile } + }); + getOutput = __ => getEmittedLineForSingleFileOutput(outFile, host); + } + else { + getOutput = file => getEmittedLineForMultiFileOutput(file, host); + } + + const f1 = getFileOrFolderEmit({ + path: "/a/a.ts", + content: "let x = 1" + }, getOutput); + const f2 = getFileOrFolderEmit({ + path: "/a/b.ts", + content: "let y = 1" + }, getOutput); + + const files = [f1, f2, config, libFile]; + host.reloadFS(files); + createWatchModeWithConfigFile(config.path, host); + + const allEmittedLines = getEmittedLines(files); + checkOutputContains(host, allEmittedLines); + host.clearOutput(); + + f1.content = "let x = 11"; + host.reloadFS(files); + host.runQueuedTimeoutCallbacks(); + checkAffectedLines(host, [f1], allEmittedLines); + } + + it("projectUsesOutFile should not be returned if not set", () => { + createWatchForOut(); + }); + + it("projectUsesOutFile should be true if out is set", () => { + const outJs = "/a/out.js"; + createWatchForOut(outJs); + }); + + it("projectUsesOutFile should be true if outFile is set", () => { + const outJs = "/a/out.js"; + createWatchForOut(/*out*/ undefined, outJs); + }); + }); + + describe("tsc-watch emit for configured projects", () => { + const file1Consumer1Path = "/a/b/file1Consumer1.ts"; + const moduleFile1Path = "/a/b/moduleFile1.ts"; + const configFilePath = "/a/b/tsconfig.json"; + interface InitialStateParams { + /** custom config file options */ + configObj?: any; + /** list of the files that will be emitted for first compilation */ + firstCompilationEmitFiles?: string[]; + /** get the emit file for file - default is multi file emit line */ + getEmitLine?(file: FileOrFolder, host: WatchedSystem): string; + /** Additional files and folders to add */ + getAdditionalFileOrFolder?(): FileOrFolder[]; + /** initial list of files to emit if not the default list */ + firstReloadFileList?: string[]; + } + function getInitialState({ configObj = {}, firstCompilationEmitFiles, getEmitLine, getAdditionalFileOrFolder, firstReloadFileList }: InitialStateParams = {}) { + const host = createWatchedSystem([]); + const getOutputName = getEmitLine ? (file: FileOrFolder) => getEmitLine(file, host) : + (file: FileOrFolder) => getEmittedLineForMultiFileOutput(file, host); + + const moduleFile1 = getFileOrFolderEmit({ + path: moduleFile1Path, + content: "export function Foo() { };", + }, getOutputName); + + const file1Consumer1 = getFileOrFolderEmit({ + path: file1Consumer1Path, + content: `import {Foo} from "./moduleFile1"; export var y = 10;`, + }, getOutputName); + + const file1Consumer2 = getFileOrFolderEmit({ + path: "/a/b/file1Consumer2.ts", + content: `import {Foo} from "./moduleFile1"; let z = 10;`, + }, getOutputName); + + const moduleFile2 = getFileOrFolderEmit({ + path: "/a/b/moduleFile2.ts", + content: `export var Foo4 = 10;`, + }, getOutputName); + + const globalFile3 = getFileOrFolderEmit({ + path: "/a/b/globalFile3.ts", + content: `interface GlobalFoo { age: number }` + }); + + const additionalFiles = getAdditionalFileOrFolder ? + map(getAdditionalFileOrFolder(), file => getFileOrFolderEmit(file, getOutputName)) : + []; + + (configObj.compilerOptions || (configObj.compilerOptions = {})).listEmittedFiles = true; + const configFile = getFileOrFolderEmit({ + path: configFilePath, + content: JSON.stringify(configObj) + }); + + const files = [moduleFile1, file1Consumer1, file1Consumer2, globalFile3, moduleFile2, configFile, libFile, ...additionalFiles]; + let allEmittedFiles = getEmittedLines(files); + host.reloadFS(firstReloadFileList ? getFiles(firstReloadFileList) : files); + + // Initial compile + createWatchModeWithConfigFile(configFile.path, host); + if (firstCompilationEmitFiles) { + checkAffectedLines(host, getFiles(firstCompilationEmitFiles), allEmittedFiles); + } + else { + checkOutputContains(host, allEmittedFiles); + } + host.clearOutput(); + + return { + moduleFile1, file1Consumer1, file1Consumer2, moduleFile2, globalFile3, configFile, + files, + getFile, + verifyAffectedFiles, + verifyAffectedAllFiles, + getOutputName + }; + + function getFiles(filelist: string[]) { + return map(filelist, getFile); + } + + function getFile(fileName: string) { + return find(files, file => file.path === fileName); + } + + function verifyAffectedAllFiles() { + host.reloadFS(files); + host.checkTimeoutQueueLengthAndRun(1); + checkOutputContains(host, allEmittedFiles); + host.clearOutput(); + } + + function verifyAffectedFiles(expected: FileOrFolderEmit[], filesToReload?: FileOrFolderEmit[]) { + if (!filesToReload) { + filesToReload = files; + } + else if (filesToReload.length > files.length) { + allEmittedFiles = getEmittedLines(filesToReload); + } + host.reloadFS(filesToReload); + host.checkTimeoutQueueLengthAndRun(1); + checkAffectedLines(host, expected, allEmittedFiles); + host.clearOutput(); + } + } + + it("should contains only itself if a module file's shape didn't change, and all files referencing it if its shape changed", () => { + const { + moduleFile1, file1Consumer1, file1Consumer2, + verifyAffectedFiles + } = getInitialState(); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyAffectedFiles([moduleFile1, file1Consumer1, file1Consumer2]); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { console.log('hi'); };` + moduleFile1.content = `export var T: number;export function Foo() { console.log('hi'); };`; + verifyAffectedFiles([moduleFile1]); + }); + + it("should be up-to-date with the reference map changes", () => { + const { + moduleFile1, file1Consumer1, file1Consumer2, + verifyAffectedFiles + } = getInitialState(); + + // Change file1Consumer1 content to `export let y = Foo();` + file1Consumer1.content = `export let y = Foo();`; + verifyAffectedFiles([file1Consumer1]); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyAffectedFiles([moduleFile1, file1Consumer2]); + + // Add the import statements back to file1Consumer1 + file1Consumer1.content = `import {Foo} from "./moduleFile1";let y = Foo();`; + verifyAffectedFiles([file1Consumer1]); + + // Change the content of moduleFile1 to `export var T: number;export var T2: string;export function Foo() { };` + moduleFile1.content = `export var T: number;export var T2: string;export function Foo() { };`; + verifyAffectedFiles([moduleFile1, file1Consumer2, file1Consumer1]); + + // Multiple file edits in one go: + + // Change file1Consumer1 content to `export let y = Foo();` + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + file1Consumer1.content = `export let y = Foo();`; + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyAffectedFiles([moduleFile1, file1Consumer1, file1Consumer2]); + }); + + it("should be up-to-date with deleted files", () => { + const { + moduleFile1, file1Consumer1, file1Consumer2, + files, + verifyAffectedFiles + } = getInitialState(); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + moduleFile1.content = `export var T: number;export function Foo() { };`; + + // Delete file1Consumer2 + const filesToLoad = mapDefined(files, file => file === file1Consumer2 ? undefined : file); + verifyAffectedFiles([moduleFile1, file1Consumer1], filesToLoad); + }); + + it("should be up-to-date with newly created files", () => { + const { + moduleFile1, file1Consumer1, file1Consumer2, + files, + verifyAffectedFiles, + getOutputName + } = getInitialState(); + + const file1Consumer3 = getFileOrFolderEmit({ + path: "/a/b/file1Consumer3.ts", + content: `import {Foo} from "./moduleFile1"; let y = Foo();` + }, getOutputName); + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyAffectedFiles([moduleFile1, file1Consumer1, file1Consumer3, file1Consumer2], files.concat(file1Consumer3)); + }); + + it("should detect changes in non-root files", () => { + const { + moduleFile1, file1Consumer1, + verifyAffectedFiles + } = getInitialState({ configObj: { files: [file1Consumer1Path] }, firstCompilationEmitFiles: [file1Consumer1Path, moduleFile1Path] }); + + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyAffectedFiles([moduleFile1, file1Consumer1]); + + // change file1 internal, and verify only file1 is affected + moduleFile1.content += "var T1: number;"; + verifyAffectedFiles([moduleFile1]); + }); + + it("should return all files if a global file changed shape", () => { + const { + globalFile3, verifyAffectedAllFiles + } = getInitialState(); + + globalFile3.content += "var T2: string;"; + verifyAffectedAllFiles(); + }); + + it("should always return the file itself if '--isolatedModules' is specified", () => { + const { + moduleFile1, verifyAffectedFiles + } = getInitialState({ configObj: { compilerOptions: { isolatedModules: true } } }); + + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyAffectedFiles([moduleFile1]); + }); + + it("should always return the file itself if '--out' or '--outFile' is specified", () => { + const outFilePath = "/a/b/out.js"; + const { + moduleFile1, verifyAffectedFiles + } = getInitialState({ + configObj: { compilerOptions: { module: "system", outFile: outFilePath } }, + getEmitLine: (_, host) => getEmittedLineForSingleFileOutput(outFilePath, host) + }); + + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyAffectedFiles([moduleFile1]); + }); + + it("should return cascaded affected file list", () => { + const file1Consumer1Consumer1: FileOrFolder = { + path: "/a/b/file1Consumer1Consumer1.ts", + content: `import {y} from "./file1Consumer1";` + }; + const { + moduleFile1, file1Consumer1, file1Consumer2, verifyAffectedFiles, getFile + } = getInitialState({ + getAdditionalFileOrFolder: () => [file1Consumer1Consumer1] + }); + + const file1Consumer1Consumer1Emit = getFile(file1Consumer1Consumer1.path); + file1Consumer1.content += "export var T: number;"; + verifyAffectedFiles([file1Consumer1, file1Consumer1Consumer1Emit]); + + // Doesnt change the shape of file1Consumer1 + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyAffectedFiles([moduleFile1, file1Consumer1, file1Consumer2]); + + // Change both files before the timeout + file1Consumer1.content += "export var T2: number;"; + moduleFile1.content = `export var T2: number;export function Foo() { };`; + verifyAffectedFiles([moduleFile1, file1Consumer1, file1Consumer2, file1Consumer1Consumer1Emit]); + }); + + it("should work fine for files with circular references", () => { + // TODO: do not exit on such errors? Just continue to watch the files for update in watch mode + + const file1: FileOrFolder = { + path: "/a/b/file1.ts", + content: ` + /// + export var t1 = 10;` + }; + const file2: FileOrFolder = { + path: "/a/b/file2.ts", + content: ` + /// + export var t2 = 10;` + }; + const { + configFile, + getFile, + verifyAffectedFiles + } = getInitialState({ + firstCompilationEmitFiles: [file1.path, file2.path], + getAdditionalFileOrFolder: () => [file1, file2], + firstReloadFileList: [libFile.path, file1.path, file2.path, configFilePath] + }); + const file1Emit = getFile(file1.path), file2Emit = getFile(file2.path); + + file1Emit.content += "export var t3 = 10;"; + verifyAffectedFiles([file1Emit, file2Emit], [file1, file2, libFile, configFile]); + + }); + + it("should detect removed code file", () => { + const referenceFile1: FileOrFolder = { + path: "/a/b/referenceFile1.ts", + content: ` + /// + export var x = Foo();` + }; + const { + configFile, + getFile, + verifyAffectedFiles + } = getInitialState({ + firstCompilationEmitFiles: [referenceFile1.path, moduleFile1Path], + getAdditionalFileOrFolder: () => [referenceFile1], + firstReloadFileList: [libFile.path, referenceFile1.path, moduleFile1Path, configFilePath] + }); + + const referenceFile1Emit = getFile(referenceFile1.path); + verifyAffectedFiles([referenceFile1Emit], [libFile, referenceFile1Emit, configFile]); + }); + + it("should detect non-existing code file", () => { + const referenceFile1: FileOrFolder = { + path: "/a/b/referenceFile1.ts", + content: ` + /// + export var x = Foo();` + }; + const { + configFile, + moduleFile2, + getFile, + verifyAffectedFiles + } = getInitialState({ + firstCompilationEmitFiles: [referenceFile1.path], + getAdditionalFileOrFolder: () => [referenceFile1], + firstReloadFileList: [libFile.path, referenceFile1.path, configFilePath] + }); + + const referenceFile1Emit = getFile(referenceFile1.path); + referenceFile1Emit.content += "export var yy = Foo();"; + verifyAffectedFiles([referenceFile1Emit], [libFile, referenceFile1Emit, configFile]); + + // Create module File2 and see both files are saved + verifyAffectedFiles([referenceFile1Emit, moduleFile2], [libFile, moduleFile2, referenceFile1Emit, configFile]); + }); + }); + + describe("tsc-watch emit file content", () => { + interface EmittedFile extends FileOrFolder { + shouldBeWritten: boolean; + } + function getEmittedFiles(files: FileOrFolderEmit[], contents: string[]): EmittedFile[] { + return map(contents, (content, index) => { + return { + content, + path: changeExtension(files[index].path, Extension.Js), + shouldBeWritten: true + }; + } + ); + } + function verifyEmittedFiles(host: WatchedSystem, emittedFiles: EmittedFile[]) { + for (const { path, content, shouldBeWritten } of emittedFiles) { + if (shouldBeWritten) { + assert.isTrue(host.fileExists(path), `Expected file ${path} to be present`); + assert.equal(host.readFile(path), content, `Contents of file ${path} do not match`); + } + else { + assert.isNotTrue(host.fileExists(path), `Expected file ${path} to be absent`); + } + } + } + + function verifyEmittedFileContents(newLine: string, inputFiles: FileOrFolder[], initialEmittedFileContents: string[], + modifyFiles: (files: FileOrFolderEmit[], emitedFiles: EmittedFile[]) => FileOrFolderEmit[], configFile?: FileOrFolder) { + const host = createWatchedSystem([], { newLine }); + const files = concatenate( + map(inputFiles, file => getFileOrFolderEmit(file, fileToConvert => getEmittedLineForMultiFileOutput(fileToConvert, host))), + configFile ? [libFile, configFile] : [libFile] + ); + const allEmittedFiles = getEmittedLines(files); + host.reloadFS(files); + + // Initial compile + if (configFile) { + createWatchModeWithConfigFile(configFile.path, host); + } + else { + // First file as the root + createWatchModeWithoutConfigFile([files[0].path], host, { listEmittedFiles: true }); + } + checkOutputContains(host, allEmittedFiles); + + const emittedFiles = getEmittedFiles(files, initialEmittedFileContents); + verifyEmittedFiles(host, emittedFiles); + host.clearOutput(); + + const affectedFiles = modifyFiles(files, emittedFiles); + host.reloadFS(files); + host.checkTimeoutQueueLengthAndRun(1); + checkAffectedLines(host, affectedFiles, allEmittedFiles); + + verifyEmittedFiles(host, emittedFiles); + } + + function verifyNewLine(newLine: string) { + const lines = ["var x = 1;", "var y = 2;"]; + const fileContent = lines.join(newLine); + const f = { + path: "/a/app.ts", + content: fileContent + }; + + verifyEmittedFileContents(newLine, [f], [fileContent + newLine], modifyFiles); + + function modifyFiles(files: FileOrFolderEmit[], emittedFiles: EmittedFile[]) { + files[0].content = fileContent + newLine + "var z = 3;"; + emittedFiles[0].content = files[0].content + newLine; + return [files[0]]; + } + } + + it("handles new lines: \\n", () => { + verifyNewLine("\n"); + }); + + it("handles new lines: \\r\\n", () => { + verifyNewLine("\r\n"); + }); + + it("should emit specified file", () => { + const file1 = { + path: "/a/b/f1.ts", + content: `export function Foo() { return 10; }` + }; + + const file2 = { + path: "/a/b/f2.ts", + content: `import {Foo} from "./f1"; export let y = Foo();` + }; + + const file3 = { + path: "/a/b/f3.ts", + content: `import {y} from "./f2"; let x = y;` + }; + + const configFile = { + path: "/a/b/tsconfig.json", + content: JSON.stringify({ compilerOptions: { listEmittedFiles: true } }) + }; + + verifyEmittedFileContents("\r\n", [file1, file2, file3], [ + `"use strict";\r\nexports.__esModule = true;\r\nfunction Foo() { return 10; }\r\nexports.Foo = Foo;\r\n`, + `"use strict";\r\nexports.__esModule = true;\r\nvar f1_1 = require("./f1");\r\nexports.y = f1_1.Foo();\r\n`, + `"use strict";\r\nexports.__esModule = true;\r\nvar f2_1 = require("./f2");\r\nvar x = f2_1.y;\r\n` + ], modifyFiles, configFile); + + function modifyFiles(files: FileOrFolderEmit[], emittedFiles: EmittedFile[]) { + files[0].content += `export function foo2() { return 2; }`; + emittedFiles[0].content += `function foo2() { return 2; }\r\nexports.foo2 = foo2;\r\n`; + emittedFiles[2].shouldBeWritten = false; + return files.slice(0, 2); + } + }); + }); + + describe("tsc-watch module resolution caching", () => { + it("works", () => { + const root = { + path: "/a/d/f0.ts", + content: `import {x} from "f1"` + }; + + const imported = { + path: "/a/f1.ts", + content: `foo()` + }; + + const f1IsNotModule = `a/d/f0.ts(1,17): error TS2306: File '${imported.path}' is not a module.\n`; + const cannotFindFoo = `a/f1.ts(1,1): error TS2304: Cannot find name 'foo'.\n`; + const cannotAssignValue = "a/d/f0.ts(2,21): error TS2322: Type '1' is not assignable to type 'string'.\n"; + + const files = [root, imported, libFile]; + const host = createWatchedSystem(files); + createWatchModeWithoutConfigFile([root.path], host, { module: ModuleKind.AMD }); + + // ensure that imported file was found + checkOutputContains(host, [f1IsNotModule, cannotFindFoo]); + host.clearOutput(); + + const originalFileExists = host.fileExists; + { + const newContent = `import {x} from "f1" + var x: string = 1;`; + root.content = newContent; + host.reloadFS(files); + + // patch fileExists to make sure that disk is not touched + host.fileExists = notImplemented; + + // trigger synchronization to make sure that import will be fetched from the cache + host.runQueuedTimeoutCallbacks(); + + // ensure file has correct number of errors after edit + checkOutputContains(host, [f1IsNotModule, cannotAssignValue]); + host.clearOutput(); + } + { + let fileExistsIsCalled = false; + host.fileExists = (fileName): boolean => { + if (fileName === "lib.d.ts") { + return false; + } + fileExistsIsCalled = true; + assert.isTrue(fileName.indexOf("/f2.") !== -1); + return originalFileExists.call(host, fileName); + }; + + root.content = `import {x} from "f2"`; + host.reloadFS(files); + + // trigger synchronization to make sure that LSHost will try to find 'f2' module on disk + host.runQueuedTimeoutCallbacks(); + + // ensure file has correct number of errors after edit + const cannotFindModuleF2 = `a/d/f0.ts(1,17): error TS2307: Cannot find module 'f2'.\n`; + checkOutputContains(host, [cannotFindModuleF2]); + host.clearOutput(); + + assert.isTrue(fileExistsIsCalled); + } + { + let fileExistsCalled = false; + host.fileExists = (fileName): boolean => { + if (fileName === "lib.d.ts") { + return false; + } + fileExistsCalled = true; + assert.isTrue(fileName.indexOf("/f1.") !== -1); + return originalFileExists.call(host, fileName); + }; + + const newContent = `import {x} from "f1"`; + root.content = newContent; + + host.reloadFS(files); + host.runQueuedTimeoutCallbacks(); + + checkOutputContains(host, [f1IsNotModule, cannotFindFoo]); + assert.isTrue(fileExistsCalled); + } + }); + + it("loads missing files from disk", () => { + const root = { + path: `/a/foo.ts`, + content: `import {x} from "bar"` + }; + + const imported = { + path: `/a/bar.d.ts`, + content: `export const y = 1;` + }; + + const files = [root, libFile]; + const host = createWatchedSystem(files); + const originalFileExists = host.fileExists; + + let fileExistsCalledForBar = false; + host.fileExists = fileName => { + if (fileName === "lib.d.ts") { + return false; + } + if (!fileExistsCalledForBar) { + fileExistsCalledForBar = fileName.indexOf("/bar.") !== -1; + } + + return originalFileExists.call(host, fileName); + }; + + createWatchModeWithoutConfigFile([root.path], host, { module: ModuleKind.AMD }); + + const barNotFound = `a/foo.ts(1,17): error TS2307: Cannot find module 'bar'.\n`; + assert.isTrue(fileExistsCalledForBar, "'fileExists' should be called"); + checkOutputContains(host, [barNotFound]); + host.clearOutput(); + + fileExistsCalledForBar = false; + root.content = `import {y} from "bar"`; + host.reloadFS(files.concat(imported)); + + host.runQueuedTimeoutCallbacks(); + assert.isTrue(fileExistsCalledForBar, "'fileExists' should be called."); + checkOutputDoesNotContain(host, [barNotFound]); + }); + + it("should compile correctly when resolved module goes missing and then comes back (module is not part of the root)", () => { + const root = { + path: `/a/foo.ts`, + content: `import {x} from "bar"` + }; + + const imported = { + path: `/a/bar.d.ts`, + content: `export const y = 1;` + }; + + const files = [root, libFile]; + const filesWithImported = files.concat(imported); + const host = createWatchedSystem(filesWithImported); + const originalFileExists = host.fileExists; + let fileExistsCalledForBar = false; + host.fileExists = fileName => { + if (fileName === "lib.d.ts") { + return false; + } + if (!fileExistsCalledForBar) { + fileExistsCalledForBar = fileName.indexOf("/bar.") !== -1; + } + return originalFileExists.call(host, fileName); + }; + + createWatchModeWithoutConfigFile([root.path], host, { module: ModuleKind.AMD }); + + const barNotFound = `a/foo.ts(1,17): error TS2307: Cannot find module 'bar'.\n`; + assert.isTrue(fileExistsCalledForBar, "'fileExists' should be called"); + checkOutputDoesNotContain(host, [barNotFound]); + host.clearOutput(); + + fileExistsCalledForBar = false; + host.reloadFS(files); + host.runQueuedTimeoutCallbacks(); + assert.isTrue(fileExistsCalledForBar, "'fileExists' should be called."); + checkOutputContains(host, [barNotFound]); + host.clearOutput(); + + fileExistsCalledForBar = false; + host.reloadFS(filesWithImported); + host.checkTimeoutQueueLengthAndRun(1); + assert.isTrue(fileExistsCalledForBar, "'fileExists' should be called."); + checkOutputDoesNotContain(host, [barNotFound]); + }); + }); +} diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index dcefa14bcb83e..06e2beeaaa6cf 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -1,4 +1,5 @@ /// +/// /// namespace ts.projectSystem { @@ -6,17 +7,14 @@ namespace ts.projectSystem { import protocol = server.protocol; import CommandNames = server.CommandNames; - const safeList = { - path: "/safeList.json", - content: JSON.stringify({ - commander: "commander", - express: "express", - jquery: "jquery", - lodash: "lodash", - moment: "moment", - chroma: "chroma-js" - }) - }; + export import TestServerHost = ts.TestFSWithWatch.TestServerHost; + export type FileOrFolder = ts.TestFSWithWatch.FileOrFolder; + export import createServerHost = ts.TestFSWithWatch.createServerHost; + export import checkFileNames = ts.TestFSWithWatch.checkFileNames; + export import libFile = ts.TestFSWithWatch.libFile; + export import checkWatchedFiles = ts.TestFSWithWatch.checkWatchedFiles; + import checkWatchedDirectories = ts.TestFSWithWatch.checkWatchedDirectories; + import safeList = ts.TestFSWithWatch.safeList; export const customTypesMap = { path: "/typesMap.json", @@ -59,12 +57,6 @@ namespace ts.projectSystem { getLogFileName: (): string => undefined }; - const { content: libFileContent } = Harness.getDefaultLibraryFile(Harness.IO); - export const libFile: FileOrFolder = { - path: "/a/lib/lib.d.ts", - content: libFileContent - }; - export class TestTypingsInstaller extends TI.TypingsInstaller implements server.ITypingsInstaller { protected projectService: server.ProjectService; constructor( @@ -115,7 +107,7 @@ namespace ts.projectSystem { } addPostExecAction(stdout: string | string[], cb: TI.RequestCompletedAction) { - const out = typeof stdout === "string" ? stdout : createNpmPackageJsonString(stdout); + const out = isString(stdout) ? stdout : createNpmPackageJsonString(stdout); const action: PostExecAction = { success: !!out, callback: cb @@ -132,10 +124,6 @@ namespace ts.projectSystem { return JSON.stringify({ dependencies }); } - function getExecutingFilePathFromLibFile(): string { - return combinePaths(getDirectoryPath(libFile.path), "tsc.js"); - } - export function toExternalFile(fileName: string): protocol.ExternalFile { return { fileName }; } @@ -151,32 +139,12 @@ namespace ts.projectSystem { this.events.push(event); } - checkEventCountOfType(eventType: "context" | "configFileDiag", expectedCount: number) { + checkEventCountOfType(eventType: "configFileDiag", expectedCount: number) { const eventsOfType = filter(this.events, e => e.eventName === eventType); assert.equal(eventsOfType.length, expectedCount, `The actual event counts of type ${eventType} is ${eventsOfType.length}, while expected ${expectedCount}`); } } - interface TestServerHostCreationParameters { - useCaseSensitiveFileNames?: boolean; - executingFilePath?: string; - currentDirectory?: string; - newLine?: string; - } - - export function createServerHost(fileOrFolderList: FileOrFolder[], params?: TestServerHostCreationParameters): TestServerHost { - if (!params) { - params = {}; - } - const host = new TestServerHost( - params.useCaseSensitiveFileNames !== undefined ? params.useCaseSensitiveFileNames : false, - params.executingFilePath || getExecutingFilePathFromLibFile(), - params.currentDirectory || "/", - fileOrFolderList, - params.newLine); - return host; - } - class TestSession extends server.Session { private seq = 0; @@ -217,7 +185,7 @@ namespace ts.projectSystem { typingsInstaller: undefined, byteLength: Utils.byteLength, hrtime: process.hrtime, - logger: nullLogger, + logger: opts.logger || nullLogger, canUseEvents: false }; @@ -232,7 +200,6 @@ namespace ts.projectSystem { eventHandler?: server.ProjectServiceEventHandler; } - export class TestProjectService extends server.ProjectService { constructor(host: server.ServerHost, logger: server.Logger, cancellationToken: HostCancellationToken, useSingleInferredProject: boolean, typingsInstaller: server.ITypingsInstaller, eventHandler: server.ProjectServiceEventHandler, opts: Partial = {}) { @@ -260,68 +227,8 @@ namespace ts.projectSystem { return new TestProjectService(host, logger, cancellationToken, useSingleInferredProject, parameters.typingsInstaller, parameters.eventHandler); } - export interface FileOrFolder { - path: string; - content?: string; - fileSize?: number; - } - - export interface FSEntry { - path: Path; - fullPath: string; - } - - export interface File extends FSEntry { - content: string; - fileSize?: number; - } - - export interface Folder extends FSEntry { - entries: FSEntry[]; - } - - function isFolder(s: FSEntry): s is Folder { - return isArray((s).entries); - } - - function isFile(s: FSEntry): s is File { - return typeof (s).content === "string"; - } - - function addFolder(fullPath: string, toPath: (s: string) => Path, fs: Map): Folder { - const path = toPath(fullPath); - if (fs.has(path)) { - Debug.assert(isFolder(fs.get(path))); - return (fs.get(path)); - } - - const entry: Folder = { path, entries: [], fullPath }; - fs.set(path, entry); - - const baseFullPath = getDirectoryPath(fullPath); - if (fullPath !== baseFullPath) { - addFolder(baseFullPath, toPath, fs).entries.push(entry); - } - - return entry; - } - - function checkMapKeys(caption: string, map: Map, expectedKeys: string[]) { - assert.equal(map.size, expectedKeys.length, `${caption}: incorrect size of map`); - for (const name of expectedKeys) { - assert.isTrue(map.has(name), `${caption} is expected to contain ${name}, actual keys: ${arrayFrom(map.keys())}`); - } - } - - function checkFileNames(caption: string, actualFileNames: string[], expectedFileNames: string[]) { - assert.equal(actualFileNames.length, expectedFileNames.length, `${caption}: incorrect actual number of files, expected ${JSON.stringify(expectedFileNames)}, got ${actualFileNames}`); - for (const f of expectedFileNames) { - assert.isTrue(contains(actualFileNames, f), `${caption}: expected to find ${f} in ${JSON.stringify(actualFileNames)}`); - } - } - - function checkNumberOfConfiguredProjects(projectService: server.ProjectService, expected: number) { - assert.equal(projectService.configuredProjects.length, expected, `expected ${expected} configured project(s)`); + export function checkNumberOfConfiguredProjects(projectService: server.ProjectService, expected: number) { + assert.equal(projectService.configuredProjects.size, expected, `expected ${expected} configured project(s)`); } function checkNumberOfExternalProjects(projectService: server.ProjectService, expected: number) { @@ -338,12 +245,13 @@ namespace ts.projectSystem { checkNumberOfInferredProjects(projectService, count.inferredProjects || 0); } - export function checkWatchedFiles(host: TestServerHost, expectedFiles: string[]) { - checkMapKeys("watchedFiles", host.watchedFiles, expectedFiles); - } - - function checkWatchedDirectories(host: TestServerHost, expectedDirectories: string[]) { - checkMapKeys("watchedDirectories", host.watchedDirectories, expectedDirectories); + export function configuredProjectAt(projectService: server.ProjectService, index: number) { + const values = projectService.configuredProjects.values(); + while (index > 0) { + values.next(); + index--; + } + return values.next().value; } export function checkProjectActualFiles(project: server.Project, expectedFiles: string[]) { @@ -354,249 +262,16 @@ namespace ts.projectSystem { checkFileNames(`${server.ProjectKind[project.projectKind]} project, rootFileNames`, project.getRootFiles(), expectedFiles); } - class Callbacks { - private map: TimeOutCallback[] = []; - private nextId = 1; - - register(cb: (...args: any[]) => void, args: any[]) { - const timeoutId = this.nextId; - this.nextId++; - this.map[timeoutId] = cb.bind(/*this*/ undefined, ...args); - return timeoutId; - } - - unregister(id: any) { - if (typeof id === "number") { - delete this.map[id]; - } - } - - count() { - let n = 0; - for (const _ in this.map) { - n++; - } - return n; - } - - invoke() { - // Note: invoking a callback may result in new callbacks been queued, - // so do not clear the entire callback list regardless. Only remove the - // ones we have invoked. - for (const key in this.map) { - this.map[key](); - delete this.map[key]; - } - } + function getNodeModuleDirectories(dir: string) { + const result: string[] = []; + forEachAncestorDirectory(dir, ancestor => { + result.push(combinePaths(ancestor, "node_modules")); + }); + return result; } - type TimeOutCallback = () => any; - - export class TestServerHost implements server.ServerHost { - args: string[] = []; - - private readonly output: string[] = []; - - private fs: Map; - private getCanonicalFileName: (s: string) => string; - private toPath: (f: string) => Path; - private timeoutCallbacks = new Callbacks(); - private immediateCallbacks = new Callbacks(); - - readonly watchedDirectories = createMultiMap<{ cb: DirectoryWatcherCallback, recursive: boolean }>(); - readonly watchedFiles = createMultiMap(); - - private filesOrFolders: FileOrFolder[]; - - constructor(public useCaseSensitiveFileNames: boolean, private executingFilePath: string, private currentDirectory: string, fileOrFolderList: FileOrFolder[], public readonly newLine = "\n") { - this.getCanonicalFileName = createGetCanonicalFileName(useCaseSensitiveFileNames); - this.toPath = s => toPath(s, currentDirectory, this.getCanonicalFileName); - - this.reloadFS(fileOrFolderList); - } - - reloadFS(filesOrFolders: FileOrFolder[]) { - this.filesOrFolders = filesOrFolders; - this.fs = createMap(); - // always inject safelist file in the list of files - for (const fileOrFolder of filesOrFolders.concat(safeList)) { - const path = this.toPath(fileOrFolder.path); - const fullPath = getNormalizedAbsolutePath(fileOrFolder.path, this.currentDirectory); - if (typeof fileOrFolder.content === "string") { - const entry = { path, content: fileOrFolder.content, fullPath, fileSize: fileOrFolder.fileSize }; - this.fs.set(path, entry); - addFolder(getDirectoryPath(fullPath), this.toPath, this.fs).entries.push(entry); - } - else { - addFolder(fullPath, this.toPath, this.fs); - } - } - } - - fileExists(s: string) { - const path = this.toPath(s); - return this.fs.has(path) && isFile(this.fs.get(path)); - } - - getFileSize(s: string) { - const path = this.toPath(s); - if (this.fs.has(path)) { - const entry = this.fs.get(path); - if (isFile(entry)) { - return entry.fileSize ? entry.fileSize : entry.content.length; - } - } - return undefined; - } - - directoryExists(s: string) { - const path = this.toPath(s); - return this.fs.has(path) && isFolder(this.fs.get(path)); - } - - getDirectories(s: string) { - const path = this.toPath(s); - if (!this.fs.has(path)) { - return []; - } - else { - const entry = this.fs.get(path); - return isFolder(entry) ? map(entry.entries, x => getBaseFileName(x.fullPath)) : []; - } - } - - readDirectory(path: string, extensions?: ReadonlyArray, exclude?: ReadonlyArray, include?: ReadonlyArray, depth?: number): string[] { - return ts.matchFiles(path, extensions, exclude, include, this.useCaseSensitiveFileNames, this.getCurrentDirectory(), depth, (dir) => { - const directories: string[] = []; - const files: string[] = []; - const dirEntry = this.fs.get(this.toPath(dir)); - if (isFolder(dirEntry)) { - dirEntry.entries.forEach((entry) => { - if (isFolder(entry)) { - directories.push(entry.fullPath); - } - else if (isFile(entry)) { - files.push(entry.fullPath); - } - }); - } - return { directories, files }; - }); - } - - watchDirectory(directoryName: string, callback: DirectoryWatcherCallback, recursive: boolean): DirectoryWatcher { - const path = this.toPath(directoryName); - const cbWithRecursive = { cb: callback, recursive }; - this.watchedDirectories.add(path, cbWithRecursive); - return { - referenceCount: 0, - directoryName, - close: () => this.watchedDirectories.remove(path, cbWithRecursive) - }; - } - - createHash(s: string): string { - return Harness.LanguageService.mockHash(s); - } - - triggerDirectoryWatcherCallback(directoryName: string, fileName: string): void { - const path = this.toPath(directoryName); - const callbacks = this.watchedDirectories.get(path); - if (callbacks) { - for (const callback of callbacks) { - callback.cb(fileName); - } - } - } - - triggerFileWatcherCallback(fileName: string, eventKind: FileWatcherEventKind): void { - const path = this.toPath(fileName); - const callbacks = this.watchedFiles.get(path); - if (callbacks) { - for (const callback of callbacks) { - callback(path, eventKind); - } - } - } - - watchFile(fileName: string, callback: FileWatcherCallback) { - const path = this.toPath(fileName); - this.watchedFiles.add(path, callback); - return { close: () => this.watchedFiles.remove(path, callback) }; - } - - // TOOD: record and invoke callbacks to simulate timer events - setTimeout(callback: TimeOutCallback, _time: number, ...args: any[]) { - return this.timeoutCallbacks.register(callback, args); - } - - clearTimeout(timeoutId: any): void { - this.timeoutCallbacks.unregister(timeoutId); - } - - checkTimeoutQueueLength(expected: number) { - const callbacksCount = this.timeoutCallbacks.count(); - assert.equal(callbacksCount, expected, `expected ${expected} timeout callbacks queued but found ${callbacksCount}.`); - } - - runQueuedTimeoutCallbacks() { - this.timeoutCallbacks.invoke(); - } - - runQueuedImmediateCallbacks() { - this.immediateCallbacks.invoke(); - } - - setImmediate(callback: TimeOutCallback, _time: number, ...args: any[]) { - return this.immediateCallbacks.register(callback, args); - } - - clearImmediate(timeoutId: any): void { - this.immediateCallbacks.unregister(timeoutId); - } - - createDirectory(directoryName: string): void { - this.createFileOrFolder({ path: directoryName }); - } - - writeFile(path: string, content: string): void { - this.createFileOrFolder({ path, content, fileSize: content.length }); - } - - createFileOrFolder(f: FileOrFolder, createParentDirectory = false): void { - const base = getDirectoryPath(f.path); - if (base !== f.path && !this.directoryExists(base)) { - if (createParentDirectory) { - // TODO: avoid reloading FS on every creation - this.createFileOrFolder({ path: base }, createParentDirectory); - } - else { - throw new Error(`directory ${base} does not exist`); - } - } - const filesOrFolders = this.filesOrFolders.slice(0); - filesOrFolders.push(f); - this.reloadFS(filesOrFolders); - } - - write(message: string) { - this.output.push(message); - } - - getOutput(): ReadonlyArray { - return this.output; - } - - clearOutput() { - clear(this.output); - } - - readonly readFile = (s: string) => (this.fs.get(this.toPath(s))).content; - readonly resolvePath = (s: string) => s; - readonly getExecutingFilePath = () => this.executingFilePath; - readonly getCurrentDirectory = () => this.currentDirectory; - readonly exit = notImplemented; - readonly getEnvironmentVariable = notImplemented; + function getNumberOfWatchesInvokedForRecursiveWatches(recursiveWatchedDirs: string[], file: string) { + return countWhere(recursiveWatchedDirs, dir => file.length > dir.length && startsWith(file, dir) && file[dir.length] === directorySeparator); } /** @@ -659,6 +334,35 @@ namespace ts.projectSystem { } } + interface ErrorInformation { + diagnosticMessage: DiagnosticMessage; + errorTextArguments?: string[]; + } + + function getProtocolDiagnosticMessage({ diagnosticMessage, errorTextArguments = [] }: ErrorInformation) { + return formatStringFromArgs(diagnosticMessage.message, errorTextArguments); + } + + function verifyDiagnostics(actual: server.protocol.Diagnostic[], expected: ErrorInformation[]) { + const expectedErrors = expected.map(getProtocolDiagnosticMessage); + assert.deepEqual(actual.map(diag => flattenDiagnosticMessageText(diag.text, "\n")), expectedErrors); + } + + function verifyNoDiagnostics(actual: server.protocol.Diagnostic[]) { + verifyDiagnostics(actual, []); + } + + const typeRootFromTsserverLocation = "/node_modules/@types"; + + export function getTypeRootsFromLocation(currentDirectory: string) { + currentDirectory = normalizePath(currentDirectory); + const result: string[] = []; + forEachAncestorDirectory(currentDirectory, ancestor => { + result.push(combinePaths(ancestor, "node_modules/@types")); + }); + return result; + } + describe("tsserverProjectSystem", () => { const commonFile1: FileOrFolder = { path: "/a/b/commonFile1.ts", @@ -693,7 +397,11 @@ namespace ts.projectSystem { const project = projectService.inferredProjects[0]; checkFileNames("inferred project", project.getFileNames(), [appFile.path, libFile.path, moduleFile.path]); - checkWatchedDirectories(host, ["/a/b/c", "/a/b", "/a"]); + const configFileLocations = ["/a/b/c/", "/a/b/", "/a/", "/"]; + const configFiles = flatMap(configFileLocations, location => [location + "tsconfig.json", location + "jsconfig.json"]); + checkWatchedFiles(host, configFiles.concat(libFile.path, moduleFile.path)); + checkWatchedDirectories(host, [], /*recursive*/ false); + checkWatchedDirectories(host, ["/a/b/c", typeRootFromTsserverLocation], /*recursive*/ true); }); it("can handle tsconfig file name with difference casing", () => { @@ -710,18 +418,19 @@ namespace ts.projectSystem { const host = createServerHost([f1, config], { useCaseSensitiveFileNames: false }); const service = createProjectService(host); + const upperCaseConfigFilePath = combinePaths(getDirectoryPath(config.path).toUpperCase(), getBaseFileName(config.path)); service.openExternalProject({ projectFileName: "/a/b/project.csproj", - rootFiles: toExternalFiles([f1.path, combinePaths(getDirectoryPath(config.path).toUpperCase(), getBaseFileName(config.path))]), + rootFiles: toExternalFiles([f1.path, upperCaseConfigFilePath]), options: {} }); service.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(service.configuredProjects[0], []); + checkProjectActualFiles(configuredProjectAt(service, 0), [upperCaseConfigFilePath]); service.openClientFile(f1.path); service.checkNumberOfProjects({ configuredProjects: 1, inferredProjects: 1 }); - checkProjectActualFiles(service.configuredProjects[0], []); + checkProjectActualFiles(configuredProjectAt(service, 0), [upperCaseConfigFilePath]); checkProjectActualFiles(service.inferredProjects[0], [f1.path]); }); @@ -758,12 +467,52 @@ namespace ts.projectSystem { checkNumberOfInferredProjects(projectService, 0); checkNumberOfConfiguredProjects(projectService, 1); - const project = projectService.configuredProjects[0]; + const project = configuredProjectAt(projectService, 0); + checkProjectActualFiles(project, [file1.path, libFile.path, file2.path, configFile.path]); + checkProjectRootFiles(project, [file1.path, file2.path]); + // watching all files except one that was open + checkWatchedFiles(host, [configFile.path, file2.path, libFile.path]); + const configFileDirectory = getDirectoryPath(configFile.path); + checkWatchedDirectories(host, getTypeRootsFromLocation(configFileDirectory).concat(configFileDirectory), /*recursive*/ true); + }); + + it("create configured project with the file list", () => { + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: ` + { + "compilerOptions": {}, + "include": ["*.ts"] + }` + }; + const file1: FileOrFolder = { + path: "/a/b/f1.ts", + content: "let x = 1" + }; + const file2: FileOrFolder = { + path: "/a/b/f2.ts", + content: "let y = 1" + }; + const file3: FileOrFolder = { + path: "/a/b/c/f3.ts", + content: "let z = 1" + }; + + const host = createServerHost([configFile, libFile, file1, file2, file3]); + const projectService = createProjectService(host); + const { configFileName, configFileErrors } = projectService.openClientFile(file1.path); + + assert(configFileName, "should find config file"); + assert.isTrue(!configFileErrors, `expect no errors in config file, got ${JSON.stringify(configFileErrors)}`); + checkNumberOfInferredProjects(projectService, 0); + checkNumberOfConfiguredProjects(projectService, 1); + + const project = configuredProjectAt(projectService, 0); checkProjectActualFiles(project, [file1.path, libFile.path, file2.path, configFile.path]); checkProjectRootFiles(project, [file1.path, file2.path]); // watching all files except one that was open checkWatchedFiles(host, [configFile.path, file2.path, libFile.path]); - checkWatchedDirectories(host, [getDirectoryPath(configFile.path)]); + checkWatchedDirectories(host, [getDirectoryPath(configFile.path)], /*recursive*/ false); }); it("add and then remove a config file in a folder with loose files", () => { @@ -782,24 +531,26 @@ namespace ts.projectSystem { projectService.openClientFile(commonFile2.path); checkNumberOfInferredProjects(projectService, 2); - checkWatchedDirectories(host, ["/a/b", "/a"]); + const configFileLocations = ["/", "/a/", "/a/b/"]; + const watchedFiles = flatMap(configFileLocations, location => [location + "tsconfig.json", location + "jsconfig.json"]).concat(libFile.path); + checkWatchedFiles(host, watchedFiles); // Add a tsconfig file host.reloadFS(filesWithConfig); - host.triggerDirectoryWatcherCallback("/a/b", configFile.path); - + host.checkTimeoutQueueLengthAndRun(1); checkNumberOfInferredProjects(projectService, 1); checkNumberOfConfiguredProjects(projectService, 1); - // watching all files except one that was open - checkWatchedFiles(host, [libFile.path, configFile.path]); + checkWatchedFiles(host, watchedFiles); // remove the tsconfig file host.reloadFS(filesWithoutConfig); - host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); + + checkNumberOfInferredProjects(projectService, 1); + host.checkTimeoutQueueLengthAndRun(1); // Refresh inferred projects checkNumberOfInferredProjects(projectService, 2); checkNumberOfConfiguredProjects(projectService, 0); - checkWatchedDirectories(host, ["/a/b", "/a"]); + checkWatchedFiles(host, watchedFiles); }); it("add new files to a configured project without file list", () => { @@ -810,16 +561,16 @@ namespace ts.projectSystem { const host = createServerHost([commonFile1, libFile, configFile]); const projectService = createProjectService(host); projectService.openClientFile(commonFile1.path); - checkWatchedDirectories(host, ["/a/b"]); + const configFileDir = getDirectoryPath(configFile.path); + checkWatchedDirectories(host, getTypeRootsFromLocation(configFileDir).concat(configFileDir), /*recursive*/ true); checkNumberOfConfiguredProjects(projectService, 1); - const project = projectService.configuredProjects[0]; + const project = configuredProjectAt(projectService, 0); checkProjectRootFiles(project, [commonFile1.path]); // add a new ts file host.reloadFS([commonFile1, commonFile2, libFile, configFile]); - host.triggerDirectoryWatcherCallback("/a/b", commonFile2.path); - host.runQueuedTimeoutCallbacks(); + host.checkTimeoutQueueLengthAndRun(2); // project service waits for 250ms to update the project structure, therefore the assertion needs to wait longer. checkProjectRootFiles(project, [commonFile1.path, commonFile2.path]); }); @@ -841,7 +592,7 @@ namespace ts.projectSystem { projectService.openClientFile(commonFile2.path); checkNumberOfConfiguredProjects(projectService, 1); - const project = projectService.configuredProjects[0]; + const project = configuredProjectAt(projectService, 0); checkProjectRootFiles(project, [commonFile1.path]); checkNumberOfInferredProjects(projectService, 1); }); @@ -916,22 +667,57 @@ namespace ts.projectSystem { projectService.openClientFile(commonFile1.path); checkNumberOfConfiguredProjects(projectService, 1); - const project = projectService.configuredProjects[0]; + const project = configuredProjectAt(projectService, 0); checkProjectRootFiles(project, [commonFile1.path, commonFile2.path]); // delete commonFile2 host.reloadFS([commonFile1, configFile]); - host.triggerDirectoryWatcherCallback("/a/b", commonFile2.path); - host.runQueuedTimeoutCallbacks(); + host.checkTimeoutQueueLengthAndRun(2); checkProjectRootFiles(project, [commonFile1.path]); // re-add commonFile2 host.reloadFS([commonFile1, commonFile2, configFile]); - host.triggerDirectoryWatcherCallback("/a/b", commonFile2.path); - host.runQueuedTimeoutCallbacks(); + host.checkTimeoutQueueLengthAndRun(2); checkProjectRootFiles(project, [commonFile1.path, commonFile2.path]); }); + it("handles the missing files - that were added to program because they were added with /// { + const file1: FileOrFolder = { + path: "/a/b/commonFile1.ts", + content: `/// + let x = y` + }; + const host = createServerHost([file1, libFile]); + const session = createSession(host); + openFilesForSession([file1], session); + const projectService = session.getProjectService(); + + checkNumberOfInferredProjects(projectService, 1); + const project = projectService.inferredProjects[0]; + checkProjectRootFiles(project, [file1.path]); + checkProjectActualFiles(project, [file1.path, libFile.path]); + const getErrRequest = makeSessionRequest( + server.CommandNames.SemanticDiagnosticsSync, + { file: file1.path } + ); + + // Two errors: CommonFile2 not found and cannot find name y + let diags: server.protocol.Diagnostic[] = session.executeCommand(getErrRequest).response; + verifyDiagnostics(diags, [ + { diagnosticMessage: Diagnostics.Cannot_find_name_0, errorTextArguments: ["y"] }, + { diagnosticMessage: Diagnostics.File_0_not_found, errorTextArguments: [commonFile2.path] } + ]); + + host.reloadFS([file1, commonFile2, libFile]); + host.runQueuedTimeoutCallbacks(); + checkNumberOfInferredProjects(projectService, 1); + assert.strictEqual(projectService.inferredProjects[0], project, "Inferred project should be same"); + checkProjectRootFiles(project, [file1.path]); + checkProjectActualFiles(project, [file1.path, libFile.path, commonFile2.path]); + diags = session.executeCommand(getErrRequest).response; + verifyNoDiagnostics(diags); + }); + it("should create new inferred projects for files excluded from a configured project", () => { const configFile: FileOrFolder = { path: "/a/b/tsconfig.json", @@ -945,15 +731,17 @@ namespace ts.projectSystem { const projectService = createProjectService(host); projectService.openClientFile(commonFile1.path); - const project = projectService.configuredProjects[0]; + const project = configuredProjectAt(projectService, 0); checkProjectRootFiles(project, [commonFile1.path, commonFile2.path]); configFile.content = `{ "compilerOptions": {}, "files": ["${commonFile1.path}"] }`; host.reloadFS(files); - host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); + checkNumberOfConfiguredProjects(projectService, 1); + checkProjectRootFiles(project, [commonFile1.path, commonFile2.path]); + host.checkTimeoutQueueLengthAndRun(2); // Update the configured project + refresh inferred projects checkNumberOfConfiguredProjects(projectService, 1); checkProjectRootFiles(project, [commonFile1.path]); @@ -979,7 +767,7 @@ namespace ts.projectSystem { projectService.openClientFile(commonFile1.path); checkNumberOfConfiguredProjects(projectService, 1); - const project = projectService.configuredProjects[0]; + const project = configuredProjectAt(projectService, 0); checkProjectRootFiles(project, [commonFile1.path, commonFile2.path]); projectService.openClientFile(excludedFile1.path); checkNumberOfInferredProjects(projectService, 1); @@ -1015,7 +803,7 @@ namespace ts.projectSystem { projectService.openClientFile(classicModuleFile.path); checkNumberOfConfiguredProjects(projectService, 1); - const project = projectService.configuredProjects[0]; + const project = configuredProjectAt(projectService, 0); checkProjectActualFiles(project, [file1.path, nodeModuleFile.path, configFile.path]); checkNumberOfInferredProjects(projectService, 1); @@ -1026,7 +814,7 @@ namespace ts.projectSystem { "files": ["${file1.path}"] }`; host.reloadFS(files); - host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); + host.checkTimeoutQueueLengthAndRun(2); checkProjectActualFiles(project, [file1.path, classicModuleFile.path, configFile.path]); checkNumberOfInferredProjects(projectService, 1); }); @@ -1099,7 +887,7 @@ namespace ts.projectSystem { const projectService = createProjectService(host); projectService.openClientFile(commonFile1.path); checkNumberOfConfiguredProjects(projectService, 1); - checkProjectRootFiles(projectService.configuredProjects[0], [commonFile1.path, commonFile2.path]); + checkProjectRootFiles(configuredProjectAt(projectService, 0), [commonFile1.path, commonFile2.path]); }); it("should disable features when the files are too large", () => { @@ -1173,14 +961,48 @@ namespace ts.projectSystem { host.reloadFS([file1, configFile, file2, file3, libFile]); - host.triggerDirectoryWatcherCallback(getDirectoryPath(configFile.path), configFile.path); - + host.checkTimeoutQueueLengthAndRun(1); checkNumberOfConfiguredProjects(projectService, 1); checkNumberOfInferredProjects(projectService, 1); checkProjectActualFiles(projectService.inferredProjects[0], [file2.path, file3.path, libFile.path]); }); - it("should close configured project after closing last open file", () => { + it("should reuse same project if file is opened from the configured project that has no open files", () => { + const file1 = { + path: "/a/b/main.ts", + content: "let x =1;" + }; + const file2 = { + path: "/a/b/main2.ts", + content: "let y =1;" + }; + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: `{ + "compilerOptions": { + "target": "es6" + }, + "files": [ "main.ts", "main2.ts" ] + }` + }; + const host = createServerHost([file1, file2, configFile, libFile]); + const projectService = createProjectService(host, { useSingleInferredProject: true }); + projectService.openClientFile(file1.path); + checkNumberOfConfiguredProjects(projectService, 1); + const project = projectService.configuredProjects.get(configFile.path); + + projectService.closeClientFile(file1.path); + checkNumberOfConfiguredProjects(projectService, 1); + assert.strictEqual(projectService.configuredProjects.get(configFile.path), project); + assert.equal(project.openRefCount, 0); + + projectService.openClientFile(file2.path); + checkNumberOfConfiguredProjects(projectService, 1); + assert.strictEqual(projectService.configuredProjects.get(configFile.path), project); + assert.equal(project.openRefCount, 1); + }); + + it("should not close configured project after closing last open file, but should be closed on next file open if its not the file from same project", () => { const file1 = { path: "/a/b/main.ts", content: "let x =1;" @@ -1198,8 +1020,14 @@ namespace ts.projectSystem { const projectService = createProjectService(host, { useSingleInferredProject: true }); projectService.openClientFile(file1.path); checkNumberOfConfiguredProjects(projectService, 1); + const project = projectService.configuredProjects.get(configFile.path); projectService.closeClientFile(file1.path); + checkNumberOfConfiguredProjects(projectService, 1); + assert.strictEqual(projectService.configuredProjects.get(configFile.path), project); + assert.equal(project.openRefCount, 0); + + projectService.openClientFile(libFile.path); checkNumberOfConfiguredProjects(projectService, 0); }); @@ -1280,23 +1108,43 @@ namespace ts.projectSystem { }); checkNumberOfProjects(projectService, { configuredProjects: 2 }); + const proj1 = projectService.configuredProjects.get(config1.path); + const proj2 = projectService.configuredProjects.get(config2.path); + assert.isDefined(proj1); + assert.isDefined(proj2); // open client file - should not lead to creation of inferred project projectService.openClientFile(file1.path, file1.content); checkNumberOfProjects(projectService, { configuredProjects: 2 }); + assert.strictEqual(projectService.configuredProjects.get(config1.path), proj1); + assert.strictEqual(projectService.configuredProjects.get(config2.path), proj2); projectService.openClientFile(file3.path, file3.content); checkNumberOfProjects(projectService, { configuredProjects: 2, inferredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(config1.path), proj1); + assert.strictEqual(projectService.configuredProjects.get(config2.path), proj2); projectService.closeExternalProject(externalProjectName); // open file 'file1' from configured project keeps project alive checkNumberOfProjects(projectService, { configuredProjects: 1, inferredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(config1.path), proj1); + assert.isUndefined(projectService.configuredProjects.get(config2.path)); projectService.closeClientFile(file3.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(config1.path), proj1); + assert.isUndefined(projectService.configuredProjects.get(config2.path)); projectService.closeClientFile(file1.path); - checkNumberOfProjects(projectService, {}); + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(config1.path), proj1); + assert.isUndefined(projectService.configuredProjects.get(config2.path)); + + projectService.openClientFile(file2.path, file2.content); + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + assert.isUndefined(projectService.configuredProjects.get(config1.path)); + assert.isDefined(projectService.configuredProjects.get(config2.path)); + }); it("reload regular file after closing", () => { @@ -1399,16 +1247,21 @@ namespace ts.projectSystem { path: "/a/b/f1.ts", content: "let x = 1" }; + const file2 = { + path: "/a/f2.ts", + content: "let x = 1" + }; const configFile = { path: "/a/b/tsconfig.json", content: JSON.stringify({ compilerOptions: {} }) }; const externalProjectName = "externalproject"; - const host = createServerHost([file1, configFile]); + const host = createServerHost([file1, file2, libFile, configFile]); const projectService = createProjectService(host); projectService.openClientFile(file1.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); + const project = projectService.configuredProjects.get(configFile.path); projectService.openExternalProject({ rootFiles: toExternalFiles([configFile.path]), @@ -1417,13 +1270,20 @@ namespace ts.projectSystem { }); checkNumberOfProjects(projectService, { configuredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(configFile.path), project); projectService.closeExternalProject(externalProjectName); // configured project is alive since file is still open checkNumberOfProjects(projectService, { configuredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(configFile.path), project); projectService.closeClientFile(file1.path); - checkNumberOfProjects(projectService, {}); + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(configFile.path), project); + + projectService.openClientFile(file2.path); + checkNumberOfProjects(projectService, { inferredProjects: 1 }); + assert.isUndefined(projectService.configuredProjects.get(configFile.path)); }); it("changes in closed files are reflected in project structure", () => { @@ -1457,8 +1317,7 @@ namespace ts.projectSystem { }; host.reloadFS([file1, modifiedFile2, file3]); - host.triggerFileWatcherCallback(modifiedFile2.path, FileWatcherEventKind.Changed); - + host.checkTimeoutQueueLengthAndRun(2); checkNumberOfInferredProjects(projectService, 1); checkProjectActualFiles(projectService.inferredProjects[0], [file1.path, modifiedFile2.path, file3.path]); }); @@ -1489,7 +1348,7 @@ namespace ts.projectSystem { checkNumberOfProjects(projectService, { inferredProjects: 1 }); host.reloadFS([file1, file3]); - host.triggerFileWatcherCallback(file2.path, FileWatcherEventKind.Deleted); + host.checkTimeoutQueueLengthAndRun(2); checkNumberOfProjects(projectService, { inferredProjects: 2 }); @@ -1587,9 +1446,9 @@ namespace ts.projectSystem { checkProjectActualFiles(projectService.inferredProjects[1], [file3.path]); host.reloadFS([file1, file2, file3, configFile]); - host.triggerDirectoryWatcherCallback(getDirectoryPath(configFile.path), configFile.path); + host.checkTimeoutQueueLengthAndRun(1); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, file2.path, file3.path, configFile.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path, file3.path, configFile.path]); }); it("correctly migrate files between projects", () => { @@ -1647,16 +1506,14 @@ namespace ts.projectSystem { projectService.openClientFile(file1.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, configFile.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [file1.path, configFile.path]); host.reloadFS([file1, file2, configFile]); - host.triggerDirectoryWatcherCallback(getDirectoryPath(file2.path), file2.path); - host.checkTimeoutQueueLength(1); - host.runQueuedTimeoutCallbacks(); // to execute throttled requests + host.checkTimeoutQueueLengthAndRun(2); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectRootFiles(projectService.configuredProjects[0], [file1.path, file2.path]); + checkProjectRootFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path]); }); it("can correctly update configured project when set of root files has changed (new file in list of files)", () => { @@ -1678,7 +1535,7 @@ namespace ts.projectSystem { projectService.openClientFile(file1.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, configFile.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [file1.path, configFile.path]); const modifiedConfigFile = { path: configFile.path, @@ -1686,10 +1543,10 @@ namespace ts.projectSystem { }; host.reloadFS([file1, file2, modifiedConfigFile]); - host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectRootFiles(projectService.configuredProjects[0], [file1.path, file2.path]); + host.checkTimeoutQueueLengthAndRun(2); + checkProjectRootFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path]); }); it("can update configured project when set of root files was not changed", () => { @@ -1711,7 +1568,7 @@ namespace ts.projectSystem { projectService.openClientFile(file1.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, file2.path, configFile.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path, configFile.path]); const modifiedConfigFile = { path: configFile.path, @@ -1719,10 +1576,9 @@ namespace ts.projectSystem { }; host.reloadFS([file1, file2, modifiedConfigFile]); - host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectRootFiles(projectService.configuredProjects[0], [file1.path, file2.path]); + checkProjectRootFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path]); }); it("can correctly update external project when set of root files has changed", () => { @@ -1814,15 +1670,14 @@ namespace ts.projectSystem { projectService.openClientFile(file1.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, file2.path, config.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path, config.path]); projectService.openClientFile(file2.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, file2.path, config.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path, config.path]); host.reloadFS([file1, file2]); - host.triggerFileWatcherCallback(config.path, FileWatcherEventKind.Deleted); - + host.checkTimeoutQueueLengthAndRun(1); checkNumberOfProjects(projectService, { inferredProjects: 2 }); checkProjectActualFiles(projectService.inferredProjects[0], [file1.path]); checkProjectActualFiles(projectService.inferredProjects[1], [file2.path]); @@ -1853,13 +1708,14 @@ namespace ts.projectSystem { }); projectService.openClientFile(f1.path); projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [f1.path, config.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [f1.path, config.path]); + // Should close configured project with next file open projectService.closeClientFile(f1.path); projectService.openClientFile(f2.path); - projectService.checkNumberOfProjects({ configuredProjects: 1, inferredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [f1.path, config.path]); + projectService.checkNumberOfProjects({ inferredProjects: 1 }); + assert.isUndefined(projectService.configuredProjects.get(config.path)); checkProjectActualFiles(projectService.inferredProjects[0], [f2.path]); }); @@ -1883,16 +1739,18 @@ namespace ts.projectSystem { // HTML file will not be included in any projects yet checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, config.path]); + const configuredProj = configuredProjectAt(projectService, 0); + checkProjectActualFiles(configuredProj, [file1.path, config.path]); // Specify .html extension as mixed content const extraFileExtensions = [{ extension: ".html", scriptKind: ScriptKind.JS, isMixedContent: true }]; const configureHostRequest = makeSessionRequest(CommandNames.Configure, { extraFileExtensions }); session.executeCommand(configureHostRequest).response; - // HTML file still not included in the project as it is closed + // The configured project should now be updated to include html file checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, config.path]); + assert.strictEqual(configuredProjectAt(projectService, 0), configuredProj, "Same configured project should be updated"); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path, config.path]); // Open HTML file projectService.applyChangesInOpenFiles( @@ -1902,10 +1760,10 @@ namespace ts.projectSystem { // Now HTML file is included in the project checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, file2.path, config.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path, config.path]); // Check identifiers defined in HTML content are available in .ts file - const project = projectService.configuredProjects[0]; + const project = configuredProjectAt(projectService, 0); let completions = project.getLanguageService().getCompletionsAtPosition(file1.path, 1); assert(completions && completions.entries[0].name === "hello", `expected entry hello to be in completion list`); @@ -1917,7 +1775,7 @@ namespace ts.projectSystem { // HTML file is still included in project checkNumberOfProjects(projectService, { configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, file2.path, config.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [file1.path, file2.path, config.path]); // Check identifiers defined in HTML content are not available in .ts file completions = project.getLanguageService().getCompletionsAtPosition(file1.path, 5); @@ -1953,7 +1811,7 @@ namespace ts.projectSystem { checkNumberOfProjects(projectService, { configuredProjects: 1 }); - let diagnostics = projectService.configuredProjects[0].getLanguageService().getCompilerOptionsDiagnostics(); + let diagnostics = configuredProjectAt(projectService, 0).getLanguageService().getCompilerOptionsDiagnostics(); assert.deepEqual(diagnostics, []); // #2. Ensure no errors when allowJs is false @@ -1972,7 +1830,7 @@ namespace ts.projectSystem { checkNumberOfProjects(projectService, { configuredProjects: 1 }); - diagnostics = projectService.configuredProjects[0].getLanguageService().getCompilerOptionsDiagnostics(); + diagnostics = configuredProjectAt(projectService, 0).getLanguageService().getCompilerOptionsDiagnostics(); assert.deepEqual(diagnostics, []); // #3. Ensure no errors when compiler options aren't specified @@ -1991,7 +1849,7 @@ namespace ts.projectSystem { checkNumberOfProjects(projectService, { configuredProjects: 1 }); - diagnostics = projectService.configuredProjects[0].getLanguageService().getCompilerOptionsDiagnostics(); + diagnostics = configuredProjectAt(projectService, 0).getLanguageService().getCompilerOptionsDiagnostics(); assert.deepEqual(diagnostics, []); // #4. Ensure no errors when files are explicitly specified in tsconfig @@ -2010,7 +1868,7 @@ namespace ts.projectSystem { checkNumberOfProjects(projectService, { configuredProjects: 1 }); - diagnostics = projectService.configuredProjects[0].getLanguageService().getCompilerOptionsDiagnostics(); + diagnostics = configuredProjectAt(projectService, 0).getLanguageService().getCompilerOptionsDiagnostics(); assert.deepEqual(diagnostics, []); // #4. Ensure no errors when files are explicitly excluded in tsconfig @@ -2029,7 +1887,7 @@ namespace ts.projectSystem { checkNumberOfProjects(projectService, { configuredProjects: 1 }); - diagnostics = projectService.configuredProjects[0].getLanguageService().getCompilerOptionsDiagnostics(); + diagnostics = configuredProjectAt(projectService, 0).getLanguageService().getCompilerOptionsDiagnostics(); assert.deepEqual(diagnostics, []); }); @@ -2114,6 +1972,7 @@ namespace ts.projectSystem { checkNumberOfProjects(projectService, { inferredProjects: 2 }); projectService.setCompilerOptionsForInferredProjects({ moduleResolution: ModuleResolutionKind.Classic }); + host.checkTimeoutQueueLengthAndRun(3); checkNumberOfProjects(projectService, { inferredProjects: 1 }); }); @@ -2155,15 +2014,16 @@ namespace ts.projectSystem { projectService.openClientFile(file2.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - const project1 = projectService.configuredProjects[0]; + const project1 = projectService.configuredProjects.get(tsconfig1.path); assert.equal(project1.openRefCount, 1, "Open ref count in project1 - 1"); assert.equal(project1.getScriptInfo(file2.path).containingProjects.length, 1, "containing projects count"); projectService.openClientFile(file1.path); checkNumberOfProjects(projectService, { configuredProjects: 2 }); assert.equal(project1.openRefCount, 2, "Open ref count in project1 - 2"); + assert.strictEqual(projectService.configuredProjects.get(tsconfig1.path), project1); - const project2 = projectService.configuredProjects[1]; + const project2 = projectService.configuredProjects.get(tsconfig2.path); assert.equal(project2.openRefCount, 1, "Open ref count in project2 - 2"); assert.equal(project1.getScriptInfo(file1.path).containingProjects.length, 2, `${file1.path} containing projects count`); @@ -2173,9 +2033,21 @@ namespace ts.projectSystem { checkNumberOfProjects(projectService, { configuredProjects: 2 }); assert.equal(project1.openRefCount, 1, "Open ref count in project1 - 3"); assert.equal(project2.openRefCount, 1, "Open ref count in project2 - 3"); + assert.strictEqual(projectService.configuredProjects.get(tsconfig1.path), project1); + assert.strictEqual(projectService.configuredProjects.get(tsconfig2.path), project2); projectService.closeClientFile(file1.path); - checkNumberOfProjects(projectService, { configuredProjects: 0 }); + checkNumberOfProjects(projectService, { configuredProjects: 2 }); + assert.equal(project1.openRefCount, 0, "Open ref count in project1 - 4"); + assert.equal(project2.openRefCount, 0, "Open ref count in project2 - 4"); + assert.strictEqual(projectService.configuredProjects.get(tsconfig1.path), project1); + assert.strictEqual(projectService.configuredProjects.get(tsconfig2.path), project2); + + projectService.openClientFile(file2.path); + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(tsconfig1.path), project1); + assert.isUndefined(projectService.configuredProjects.get(tsconfig2.path)); + assert.equal(project1.openRefCount, 1, "Open ref count in project1 - 5"); }); it("language service disabled state is updated in external projects", () => { @@ -2245,14 +2117,18 @@ namespace ts.projectSystem { const projectService = createProjectService(host); projectService.openClientFile(f1.path); projectService.checkNumberOfProjects({ configuredProjects: 1 }); + const project = projectService.configuredProjects.get(config.path); projectService.closeClientFile(f1.path); - projectService.checkNumberOfProjects({}); + projectService.checkNumberOfProjects({ configuredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(config.path), project); + assert.equal(project.openRefCount, 0); for (const f of [f1, f2, f3]) { // There shouldnt be any script info as we closed the file that resulted in creation of it const scriptInfo = projectService.getScriptInfoForNormalizedPath(server.toNormalizedPath(f.path)); - assert.equal(scriptInfo.containingProjects.length, 0, `expect 0 containing projects for '${f.path}'`); + assert.equal(scriptInfo.containingProjects.length, 1, `expect 1 containing projects for '${f.path}'`); + assert.equal(scriptInfo.containingProjects[0], project, `expect configured project to be the only containing project for '${f.path}'`); } }); @@ -2282,7 +2158,7 @@ namespace ts.projectSystem { const session = createSession(host, { canUseEvents: true, eventHandler: e => { - if (e.eventName === server.ConfigFileDiagEvent || e.eventName === server.ContextEvent || e.eventName === server.ProjectInfoTelemetryEvent) { + if (e.eventName === server.ConfigFileDiagEvent || e.eventName === server.ProjectsUpdatedInBackgroundEvent || e.eventName === server.ProjectInfoTelemetryEvent) { return; } assert.equal(e.eventName, server.ProjectLanguageServiceStateEvent); @@ -2298,7 +2174,7 @@ namespace ts.projectSystem { }); const projectService = session.getProjectService(); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - const project = projectService.configuredProjects[0]; + const project = configuredProjectAt(projectService, 0); assert.isFalse(project.languageServiceEnabled, "Language service enabled"); assert.isTrue(!!lastEvent, "should receive event"); assert.equal(lastEvent.data.project, project, "project name"); @@ -2306,8 +2182,7 @@ namespace ts.projectSystem { assert.isFalse(lastEvent.data.languageServiceEnabled, "Language service state"); host.reloadFS([f1, f2, configWithExclude]); - host.triggerFileWatcherCallback(config.path, FileWatcherEventKind.Changed); - + host.checkTimeoutQueueLengthAndRun(2); checkNumberOfProjects(projectService, { configuredProjects: 1 }); assert.isTrue(project.languageServiceEnabled, "Language service enabled"); assert.equal(lastEvent.data.project, project, "project"); @@ -2351,7 +2226,7 @@ namespace ts.projectSystem { const projectService = session.getProjectService(); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - const project = projectService.configuredProjects[0]; + const project = configuredProjectAt(projectService, 0); assert.isFalse(project.languageServiceEnabled, "Language service enabled"); assert.isTrue(!!lastEvent, "should receive event"); assert.equal(lastEvent.data.project, project, "project name"); @@ -2412,14 +2287,14 @@ namespace ts.projectSystem { let knownProjects = projectService.synchronizeProjectList([]); checkNumberOfProjects(projectService, { configuredProjects: 1, externalProjects: 0, inferredProjects: 0 }); - const configProject = projectService.configuredProjects[0]; - checkProjectActualFiles(configProject, [libFile.path]); + const configProject = configuredProjectAt(projectService, 0); + checkProjectActualFiles(configProject, [libFile.path, configFile.path]); const diagnostics = configProject.getAllProjectErrors(); assert.equal(diagnostics[0].code, Diagnostics.No_inputs_were_found_in_config_file_0_Specified_include_paths_were_1_and_exclude_paths_were_2.code); host.reloadFS([libFile, site]); - host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Deleted); + host.checkTimeoutQueueLengthAndRun(1); knownProjects = projectService.synchronizeProjectList(map(knownProjects, proj => proj.info)); checkNumberOfProjects(projectService, { configuredProjects: 0, externalProjects: 0, inferredProjects: 0 }); @@ -2430,6 +2305,100 @@ namespace ts.projectSystem { checkNumberOfProjects(projectService, { configuredProjects: 0, externalProjects: 1, inferredProjects: 0 }); checkProjectActualFiles(projectService.externalProjects[0], [site.path, libFile.path]); }); + + it("Getting errors from closed script info does not throw exception (because of getting project from orphan script info)", () => { + let hasErrorMsg = false; + const { close, hasLevel, loggingEnabled, startGroup, endGroup, info, getLogFileName, perftrc } = nullLogger; + const logger: server.Logger = { + close, hasLevel, loggingEnabled, startGroup, endGroup, info, getLogFileName, perftrc, + msg: () => { + hasErrorMsg = true; + } + }; + const f1 = { + path: "/a/b/app.ts", + content: "let x = 1;" + }; + const config = { + path: "/a/b/tsconfig.json", + content: JSON.stringify({ compilerOptions: {} }) + }; + const host = createServerHost([f1, libFile, config]); + const session = createSession(host, { logger }); + session.executeCommandSeq({ + command: server.CommandNames.Open, + arguments: { + file: f1.path + } + }); + session.executeCommandSeq({ + command: server.CommandNames.Close, + arguments: { + file: f1.path + } + }); + session.executeCommandSeq({ + command: server.CommandNames.Geterr, + arguments: { + delay: 0, + files: [f1.path] + } + }); + assert.isFalse(hasErrorMsg); + }); + + it("Changed module resolution reflected when specifying files list", () => { + const file1: FileOrFolder = { + path: "/a/b/file1.ts", + content: 'import classc from "file2"' + }; + const file2a: FileOrFolder = { + path: "/a/file2.ts", + content: "export classc { method2a() { return 10; } }" + }; + const file2: FileOrFolder = { + path: "/a/b/file2.ts", + content: "export classc { method2() { return 10; } }" + }; + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: JSON.stringify({ files: [file1.path], compilerOptions: { module: "amd" } }) + }; + const files = [file1, file2a, configFile, libFile]; + const host = createServerHost(files); + const projectService = createProjectService(host); + projectService.openClientFile(file1.path); + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + const project = projectService.configuredProjects.get(configFile.path); + assert.isDefined(project); + checkProjectActualFiles(project, map(files, file => file.path)); + checkWatchedFiles(host, mapDefined(files, file => file === file1 ? undefined : file.path)); + checkWatchedDirectories(host, [], /*recursive*/ false); + const watchedRecursiveDirectories = getTypeRootsFromLocation("/a/b"); + watchedRecursiveDirectories.push("/a/b"); + checkWatchedDirectories(host, watchedRecursiveDirectories, /*recursive*/ true); + + files.push(file2); + host.reloadFS(files); + host.runQueuedTimeoutCallbacks(); + watchedRecursiveDirectories.pop(); + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(configFile.path), project); + checkProjectActualFiles(project, mapDefined(files, file => file === file2a ? undefined : file.path)); + checkWatchedFiles(host, mapDefined(files, file => file === file1 ? undefined : file.path)); + checkWatchedDirectories(host, [], /*recursive*/ false); + checkWatchedDirectories(host, watchedRecursiveDirectories, /*recursive*/ true); + + // On next file open the files file2a should be closed and not watched any more + projectService.openClientFile(file2.path); + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(configFile.path), project); + checkProjectActualFiles(project, mapDefined(files, file => file === file2a ? undefined : file.path)); + checkWatchedFiles(host, [libFile.path, configFile.path]); + checkWatchedDirectories(host, [], /*recursive*/ false); + checkWatchedDirectories(host, watchedRecursiveDirectories, /*recursive*/ true); + + }); }); describe("Proper errors", () => { @@ -2583,11 +2552,10 @@ namespace ts.projectSystem { options: {} }); projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [f1.path, tsconfig.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [f1.path, tsconfig.path]); // rename tsconfig.json back to lib.ts host.reloadFS([f1, f2]); - host.triggerFileWatcherCallback(tsconfig.path, FileWatcherEventKind.Deleted); projectService.openExternalProject({ projectFileName: projectName, rootFiles: toExternalFiles([f1.path, f2.path]), @@ -2641,8 +2609,8 @@ namespace ts.projectSystem { options: {} }); projectService.checkNumberOfProjects({ configuredProjects: 2 }); - checkProjectActualFiles(projectService.configuredProjects[0], [cLib.path, cTsconfig.path]); - checkProjectActualFiles(projectService.configuredProjects[1], [dLib.path, dTsconfig.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [cLib.path, cTsconfig.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 1), [dLib.path, dTsconfig.path]); // remove one config file projectService.openExternalProject({ @@ -2652,7 +2620,7 @@ namespace ts.projectSystem { }); projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [dLib.path, dTsconfig.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [dLib.path, dTsconfig.path]); // remove second config file projectService.openExternalProject({ @@ -2672,8 +2640,8 @@ namespace ts.projectSystem { options: {} }); projectService.checkNumberOfProjects({ configuredProjects: 2 }); - checkProjectActualFiles(projectService.configuredProjects[0], [cLib.path, cTsconfig.path]); - checkProjectActualFiles(projectService.configuredProjects[1], [dLib.path, dTsconfig.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [cLib.path, cTsconfig.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 1), [dLib.path, dTsconfig.path]); // close all projects - no projects should be opened projectService.closeExternalProject(projectName); @@ -2729,13 +2697,13 @@ namespace ts.projectSystem { projectService.openClientFile(app.path); projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [libES5.path, app.path, config1.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [libES5.path, app.path, config1.path]); host.reloadFS([libES5, libES2015Promise, app, config2]); - host.triggerFileWatcherCallback(config1.path, FileWatcherEventKind.Changed); + host.checkTimeoutQueueLengthAndRun(2); projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [libES5.path, libES2015Promise.path, app.path, config2.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [libES5.path, libES2015Promise.path, app.path, config2.path]); }); it("should handle non-existing directories in config file", () => { @@ -2757,12 +2725,18 @@ namespace ts.projectSystem { const projectService = createProjectService(host); projectService.openClientFile(f.path); projectService.checkNumberOfProjects({ configuredProjects: 1 }); + const project = projectService.configuredProjects.get(config.path); + assert.equal(project.openRefCount, 1); projectService.closeClientFile(f.path); - projectService.checkNumberOfProjects({ configuredProjects: 0 }); + projectService.checkNumberOfProjects({ configuredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(config.path), project); + assert.equal(project.openRefCount, 0); projectService.openClientFile(f.path); projectService.checkNumberOfProjects({ configuredProjects: 1 }); + assert.strictEqual(projectService.configuredProjects.get(config.path), project); + assert.equal(project.openRefCount, 1); }); }); @@ -2790,7 +2764,7 @@ namespace ts.projectSystem { projectService.openClientFile(f1.path); projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [f1.path, barTypings.path, config.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [f1.path, barTypings.path, config.path]); }); }); @@ -2861,25 +2835,23 @@ namespace ts.projectSystem { projectService.openClientFile(f1.path); projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [f1.path, t1.path, tsconfig.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [f1.path, t1.path, tsconfig.path]); // delete t1 host.reloadFS([f1, tsconfig]); - host.triggerDirectoryWatcherCallback("/a/b/node_modules/@types", "lib1"); // run throttled operation host.runQueuedTimeoutCallbacks(); projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [f1.path, tsconfig.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [f1.path, tsconfig.path]); // create t2 host.reloadFS([f1, tsconfig, t2]); - host.triggerDirectoryWatcherCallback("/a/b/node_modules/@types", "lib2"); // run throttled operation host.runQueuedTimeoutCallbacks(); projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [f1.path, t2.path, tsconfig.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [f1.path, t2.path, tsconfig.path]); }); }); @@ -2948,23 +2920,22 @@ namespace ts.projectSystem { server.CommandNames.SemanticDiagnosticsSync, { file: file1.path } ); - let diags = session.executeCommand(getErrRequest).response; - assert.equal(diags.length, 0); + let diags: server.protocol.Diagnostic[] = session.executeCommand(getErrRequest).response; + verifyNoDiagnostics(diags); const moduleFileOldPath = moduleFile.path; const moduleFileNewPath = "/a/b/moduleFile1.ts"; moduleFile.path = moduleFileNewPath; host.reloadFS([moduleFile, file1]); - host.triggerFileWatcherCallback(moduleFileOldPath, FileWatcherEventKind.Changed); - host.triggerDirectoryWatcherCallback("/a/b", moduleFile.path); host.runQueuedTimeoutCallbacks(); - diags = session.executeCommand(getErrRequest).response; + diags = session.executeCommand(getErrRequest).response; + verifyDiagnostics(diags, [ + { diagnosticMessage: Diagnostics.Cannot_find_module_0, errorTextArguments: ["./moduleFile"] } + ]); assert.equal(diags.length, 1); moduleFile.path = moduleFileOldPath; host.reloadFS([moduleFile, file1]); - host.triggerFileWatcherCallback(moduleFileNewPath, FileWatcherEventKind.Changed); - host.triggerDirectoryWatcherCallback("/a/b", moduleFile.path); host.runQueuedTimeoutCallbacks(); // Make a change to trigger the program rebuild @@ -2975,8 +2946,8 @@ namespace ts.projectSystem { session.executeCommand(changeRequest); host.runQueuedTimeoutCallbacks(); - diags = session.executeCommand(getErrRequest).response; - assert.equal(diags.length, 0); + diags = session.executeCommand(getErrRequest).response; + verifyNoDiagnostics(diags); }); it("should restore the states for configured projects", () => { @@ -3000,26 +2971,24 @@ namespace ts.projectSystem { server.CommandNames.SemanticDiagnosticsSync, { file: file1.path } ); - let diags = session.executeCommand(getErrRequest).response; - assert.equal(diags.length, 0); + let diags: server.protocol.Diagnostic[] = session.executeCommand(getErrRequest).response; + verifyNoDiagnostics(diags); const moduleFileOldPath = moduleFile.path; const moduleFileNewPath = "/a/b/moduleFile1.ts"; moduleFile.path = moduleFileNewPath; host.reloadFS([moduleFile, file1, configFile]); - host.triggerFileWatcherCallback(moduleFileOldPath, FileWatcherEventKind.Changed); - host.triggerDirectoryWatcherCallback("/a/b", moduleFile.path); host.runQueuedTimeoutCallbacks(); - diags = session.executeCommand(getErrRequest).response; - assert.equal(diags.length, 1); + diags = session.executeCommand(getErrRequest).response; + verifyDiagnostics(diags, [ + { diagnosticMessage: Diagnostics.Cannot_find_module_0, errorTextArguments: ["./moduleFile"] } + ]); moduleFile.path = moduleFileOldPath; host.reloadFS([moduleFile, file1, configFile]); - host.triggerFileWatcherCallback(moduleFileNewPath, FileWatcherEventKind.Changed); - host.triggerDirectoryWatcherCallback("/a/b", moduleFile.path); host.runQueuedTimeoutCallbacks(); - diags = session.executeCommand(getErrRequest).response; - assert.equal(diags.length, 0); + diags = session.executeCommand(getErrRequest).response; + verifyNoDiagnostics(diags); }); it("should property handle missing config files", () => { @@ -3064,7 +3033,7 @@ namespace ts.projectSystem { const projectService = createProjectService(host); projectService.openClientFile(f1.path); projectService.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(projectService.configuredProjects[0], [f1.path, node.path, config.path]); + checkProjectActualFiles(configuredProjectAt(projectService, 0), [f1.path, node.path, config.path]); }); }); @@ -3085,11 +3054,12 @@ namespace ts.projectSystem { server.CommandNames.SemanticDiagnosticsSync, { file: file1.path } ); - let diags = session.executeCommand(getErrRequest).response; - assert.equal(diags.length, 1); + let diags: server.protocol.Diagnostic[] = session.executeCommand(getErrRequest).response; + verifyDiagnostics(diags, [ + { diagnosticMessage: Diagnostics.Cannot_find_module_0, errorTextArguments: ["./moduleFile"] } + ]); host.reloadFS([file1, moduleFile]); - host.triggerDirectoryWatcherCallback(getDirectoryPath(file1.path), moduleFile.path); host.runQueuedTimeoutCallbacks(); // Make a change to trigger the program rebuild @@ -3100,8 +3070,8 @@ namespace ts.projectSystem { session.executeCommand(changeRequest); // Recheck - diags = session.executeCommand(getErrRequest).response; - assert.equal(diags.length, 0); + diags = session.executeCommand(getErrRequest).response; + verifyNoDiagnostics(diags); }); }); @@ -3189,7 +3159,6 @@ namespace ts.projectSystem { } }`; host.reloadFS([file, configFile]); - host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); host.runQueuedTimeoutCallbacks(); serverEventManager.checkEventCountOfType("configFileDiag", 2); @@ -3197,7 +3166,6 @@ namespace ts.projectSystem { "compilerOptions": {} }`; host.reloadFS([file, configFile]); - host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); host.runQueuedTimeoutCallbacks(); serverEventManager.checkEventCountOfType("configFileDiag", 3); }); @@ -3449,12 +3417,10 @@ namespace ts.projectSystem { const projectService = createProjectService(host); projectService.openClientFile(file1.path); host.runQueuedTimeoutCallbacks(); - checkNumberOfConfiguredProjects(projectService, 1); + // Since there is no file open from configFile it would be closed + checkNumberOfConfiguredProjects(projectService, 0); checkNumberOfInferredProjects(projectService, 1); - const configuredProject = projectService.configuredProjects[0]; - assert.isTrue(configuredProject.getFileNames().length === 0); - const inferredProject = projectService.inferredProjects[0]; assert.isTrue(inferredProject.containsFile(file1.path)); }); @@ -3483,7 +3449,8 @@ namespace ts.projectSystem { const projectService = createProjectService(host); projectService.openClientFile(f.path); - projectService.checkNumberOfProjects({ configuredProjects: 1, inferredProjects: 1 }); + // Since no file from the configured project is open, it would be closed immediately + projectService.checkNumberOfProjects({ configuredProjects: 0, inferredProjects: 1 }); }); }); @@ -3651,9 +3618,9 @@ namespace ts.projectSystem { checkProjectActualFiles(projectService.inferredProjects[0], [file4.path]); checkProjectActualFiles(projectService.inferredProjects[1], [file1.path, file2.path]); checkProjectActualFiles(projectService.inferredProjects[2], [file3.path]); - assert.equal(projectService.inferredProjects[0].getCompilerOptions().target, ScriptTarget.ESNext); - assert.equal(projectService.inferredProjects[1].getCompilerOptions().target, ScriptTarget.ESNext); - assert.equal(projectService.inferredProjects[2].getCompilerOptions().target, ScriptTarget.ES2015); + assert.equal(projectService.inferredProjects[0].getCompilationSettings().target, ScriptTarget.ESNext); + assert.equal(projectService.inferredProjects[1].getCompilationSettings().target, ScriptTarget.ESNext); + assert.equal(projectService.inferredProjects[2].getCompilationSettings().target, ScriptTarget.ES2015); }); }); @@ -4143,13 +4110,13 @@ namespace ts.projectSystem { projectService.openClientFile(file1.path); let project = projectService.inferredProjects[0]; - let options = project.getCompilerOptions(); + let options = project.getCompilationSettings(); assert.isTrue(options.maxNodeModuleJsDepth === 2); // Assert the option sticks projectService.setCompilerOptionsForInferredProjects({ target: ScriptTarget.ES2016 }); project = projectService.inferredProjects[0]; - options = project.getCompilerOptions(); + options = project.getCompilationSettings(); assert.isTrue(options.maxNodeModuleJsDepth === 2); }); @@ -4169,15 +4136,15 @@ namespace ts.projectSystem { projectService.openClientFile(file1.path); checkNumberOfInferredProjects(projectService, 1); let project = projectService.inferredProjects[0]; - assert.isUndefined(project.getCompilerOptions().maxNodeModuleJsDepth); + assert.isUndefined(project.getCompilationSettings().maxNodeModuleJsDepth); projectService.openClientFile(file2.path); project = projectService.inferredProjects[0]; - assert.isTrue(project.getCompilerOptions().maxNodeModuleJsDepth === 2); + assert.isTrue(project.getCompilationSettings().maxNodeModuleJsDepth === 2); projectService.closeClientFile(file2.path); project = projectService.inferredProjects[0]; - assert.isUndefined(project.getCompilerOptions().maxNodeModuleJsDepth); + assert.isUndefined(project.getCompilationSettings().maxNodeModuleJsDepth); }); }); @@ -4209,7 +4176,7 @@ namespace ts.projectSystem { const projectService = session.getProjectService(); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - const projectName = projectService.configuredProjects[0].getProjectName(); + const projectName = configuredProjectAt(projectService, 0).getProjectName(); const diags = session.executeCommand({ type: "request", @@ -4221,7 +4188,6 @@ namespace ts.projectSystem { configFile.content = configFileContentWithoutCommentLine; host.reloadFS([file, configFile]); - host.triggerFileWatcherCallback(configFile.path, FileWatcherEventKind.Changed); const diagsAfterEdit = session.executeCommand({ type: "request", @@ -4301,4 +4267,1193 @@ namespace ts.projectSystem { }); }); }); + + describe("CachingFileSystemInformation", () => { + enum CalledMapsWithSingleArg { + fileExists = "fileExists", + directoryExists = "directoryExists", + getDirectories = "getDirectories", + readFile = "readFile" + } + enum CalledMapsWithFiveArgs { + readDirectory = "readDirectory" + } + type CalledMaps = CalledMapsWithSingleArg | CalledMapsWithFiveArgs; + function createCallsTrackingHost(host: TestServerHost) { + const calledMaps: Record> & Record, ReadonlyArray, ReadonlyArray, number]>> = { + fileExists: setCallsTrackingWithSingleArgFn(CalledMapsWithSingleArg.fileExists), + directoryExists: setCallsTrackingWithSingleArgFn(CalledMapsWithSingleArg.directoryExists), + getDirectories: setCallsTrackingWithSingleArgFn(CalledMapsWithSingleArg.getDirectories), + readFile: setCallsTrackingWithSingleArgFn(CalledMapsWithSingleArg.readFile), + readDirectory: setCallsTrackingWithFiveArgFn(CalledMapsWithFiveArgs.readDirectory) + }; + + return { + verifyNoCall, + verifyCalledOnEachEntryNTimes, + verifyCalledOnEachEntry, + verifyNoHostCalls, + verifyNoHostCallsExceptFileExistsOnce, + verifyCalledOn, + clear + }; + + function setCallsTrackingWithSingleArgFn(prop: CalledMapsWithSingleArg) { + const calledMap = createMultiMap(); + const cb = (host)[prop].bind(host); + (host)[prop] = (f: string) => { + calledMap.add(f, /*value*/ true); + return cb(f); + }; + return calledMap; + } + + function setCallsTrackingWithFiveArgFn(prop: CalledMapsWithFiveArgs) { + const calledMap = createMultiMap<[U, V, W, X]>(); + const cb = (host)[prop].bind(host); + (host)[prop] = (f: string, arg1?: U, arg2?: V, arg3?: W, arg4?: X) => { + calledMap.add(f, [arg1, arg2, arg3, arg4]); + return cb(f, arg1, arg2, arg3, arg4); + }; + return calledMap; + } + + function verifyCalledOn(callback: CalledMaps, name: string) { + const calledMap = calledMaps[callback]; + const result = calledMap.get(name); + assert.isTrue(result && !!result.length, `${callback} should be called with name: ${name}: ${arrayFrom(calledMap.keys())}`); + } + + function verifyNoCall(callback: CalledMaps) { + const calledMap = calledMaps[callback]; + assert.equal(calledMap.size, 0, `${callback} shouldnt be called: ${arrayFrom(calledMap.keys())}`); + } + + function verifyCalledOnEachEntry(callback: CalledMaps, expectedKeys: Map) { + const calledMap = calledMaps[callback]; + assert.equal(calledMap.size, expectedKeys.size, `${callback}: incorrect size of map: Actual keys: ${arrayFrom(calledMap.keys())} Expected: ${arrayFrom(expectedKeys.keys())}`); + expectedKeys.forEach((called, name) => { + assert.isTrue(calledMap.has(name), `${callback} is expected to contain ${name}, actual keys: ${arrayFrom(calledMap.keys())}`); + assert.equal(calledMap.get(name).length, called, `${callback} is expected to be called ${called} times with ${name}. Actual entry: ${calledMap.get(name)}`); + }); + } + + function verifyCalledOnEachEntryNTimes(callback: CalledMaps, expectedKeys: string[], nTimes: number) { + return verifyCalledOnEachEntry(callback, zipToMap(expectedKeys, expectedKeys.map(() => nTimes))); + } + + function verifyNoHostCalls() { + iterateOnCalledMaps(key => verifyNoCall(key)); + } + + function verifyNoHostCallsExceptFileExistsOnce(expectedKeys: string[]) { + verifyCalledOnEachEntryNTimes(CalledMapsWithSingleArg.fileExists, expectedKeys, 1); + verifyNoCall(CalledMapsWithSingleArg.directoryExists); + verifyNoCall(CalledMapsWithSingleArg.getDirectories); + verifyNoCall(CalledMapsWithSingleArg.readFile); + verifyNoCall(CalledMapsWithFiveArgs.readDirectory); + } + + function clear() { + iterateOnCalledMaps(key => calledMaps[key].clear()); + } + + function iterateOnCalledMaps(cb: (key: CalledMaps) => void) { + for (const key in CalledMapsWithSingleArg) { + cb(key as CalledMapsWithSingleArg); + } + for (const key in CalledMapsWithFiveArgs) { + cb(key as CalledMapsWithFiveArgs); + } + } + } + + it("works using legacy resolution logic", () => { + let rootContent = `import {x} from "f1"`; + const root: FileOrFolder = { + path: "/c/d/f0.ts", + content: rootContent + }; + + const imported: FileOrFolder = { + path: "/c/f1.ts", + content: `foo()` + }; + + const host = createServerHost([root, imported]); + const projectService = createProjectService(host); + projectService.setCompilerOptionsForInferredProjects({ module: ts.ModuleKind.AMD, noLib: true }); + projectService.openClientFile(root.path); + checkNumberOfProjects(projectService, { inferredProjects: 1 }); + const project = projectService.inferredProjects[0]; + const rootScriptInfo = project.getRootScriptInfos()[0]; + assert.equal(rootScriptInfo.fileName, root.path); + + // ensure that imported file was found + verifyImportedDiagnostics(); + + const callsTrackingHost = createCallsTrackingHost(host); + + // trigger synchronization to make sure that import will be fetched from the cache + // ensure file has correct number of errors after edit + editContent(`import {x} from "f1"; + var x: string = 1;`); + verifyImportedDiagnostics(); + callsTrackingHost.verifyNoHostCalls(); + + // trigger synchronization to make sure that LSHost will try to find 'f2' module on disk + editContent(`import {x} from "f2"`); + try { + // trigger synchronization to make sure that LSHost will try to find 'f2' module on disk + verifyImportedDiagnostics(); + assert.isTrue(false, `should not find file '${imported.path}'`); + } + catch (e) { + assert.isTrue(e.message.indexOf(`Could not find file: '${imported.path}'.`) === 0); + } + const f2Lookups = getLocationsForModuleLookup("f2"); + callsTrackingHost.verifyCalledOnEachEntryNTimes(CalledMapsWithSingleArg.fileExists, f2Lookups, 1); + const f2DirLookups = getLocationsForDirectoryLookup(); + callsTrackingHost.verifyCalledOnEachEntry(CalledMapsWithSingleArg.directoryExists, f2DirLookups); + callsTrackingHost.verifyNoCall(CalledMapsWithSingleArg.getDirectories); + callsTrackingHost.verifyNoCall(CalledMapsWithSingleArg.readFile); + callsTrackingHost.verifyNoCall(CalledMapsWithFiveArgs.readDirectory); + + editContent(`import {x} from "f1"`); + verifyImportedDiagnostics(); + const f1Lookups = f2Lookups.map(s => s.replace("f2", "f1")); + f1Lookups.length = f1Lookups.indexOf(imported.path) + 1; + const f1DirLookups = ["/c/d", "/c", typeRootFromTsserverLocation]; + vertifyF1Lookups(); + + // setting compiler options discards module resolution cache + callsTrackingHost.clear(); + projectService.setCompilerOptionsForInferredProjects({ module: ts.ModuleKind.AMD, noLib: true, target: ts.ScriptTarget.ES5 }); + verifyImportedDiagnostics(); + vertifyF1Lookups(); + + function vertifyF1Lookups() { + callsTrackingHost.verifyCalledOnEachEntryNTimes(CalledMapsWithSingleArg.fileExists, f1Lookups, 1); + callsTrackingHost.verifyCalledOnEachEntryNTimes(CalledMapsWithSingleArg.directoryExists, f1DirLookups, 1); + callsTrackingHost.verifyNoCall(CalledMapsWithSingleArg.getDirectories); + callsTrackingHost.verifyNoCall(CalledMapsWithSingleArg.readFile); + callsTrackingHost.verifyNoCall(CalledMapsWithFiveArgs.readDirectory); + } + + function editContent(newContent: string) { + callsTrackingHost.clear(); + rootScriptInfo.editContent(0, rootContent.length, newContent); + rootContent = newContent; + } + + function verifyImportedDiagnostics() { + const diags = project.getLanguageService().getSemanticDiagnostics(imported.path); + assert.equal(diags.length, 1); + const diag = diags[0]; + assert.equal(diag.code, Diagnostics.Cannot_find_name_0.code); + assert.equal(flattenDiagnosticMessageText(diag.messageText, "\n"), "Cannot find name 'foo'."); + } + + function getLocationsForModuleLookup(module: string) { + const locations: string[] = []; + forEachAncestorDirectory(getDirectoryPath(root.path), ancestor => { + locations.push( + combinePaths(ancestor, `${module}.ts`), + combinePaths(ancestor, `${module}.tsx`), + combinePaths(ancestor, `${module}.d.ts`) + ); + }); + forEachAncestorDirectory(getDirectoryPath(root.path), ancestor => { + locations.push( + combinePaths(ancestor, `${module}.js`), + combinePaths(ancestor, `${module}.jsx`) + ); + }); + return locations; + } + + function getLocationsForDirectoryLookup() { + const result = createMap(); + // Type root + result.set(typeRootFromTsserverLocation, 1); + forEachAncestorDirectory(getDirectoryPath(root.path), ancestor => { + // To resolve modules + result.set(ancestor, 2); + // for type roots + result.set(combinePaths(ancestor, `node_modules`), 1); + }); + return result; + } + }); + + it("loads missing files from disk", () => { + const root: FileOrFolder = { + path: "/c/foo.ts", + content: `import {y} from "bar"` + }; + + const imported: FileOrFolder = { + path: "/c/bar.d.ts", + content: `export var y = 1` + }; + + const host = createServerHost([root]); + const projectService = createProjectService(host); + projectService.setCompilerOptionsForInferredProjects({ module: ts.ModuleKind.AMD, noLib: true }); + const callsTrackingHost = createCallsTrackingHost(host); + projectService.openClientFile(root.path); + checkNumberOfProjects(projectService, { inferredProjects: 1 }); + const project = projectService.inferredProjects[0]; + const rootScriptInfo = project.getRootScriptInfos()[0]; + assert.equal(rootScriptInfo.fileName, root.path); + + let diags = project.getLanguageService().getSemanticDiagnostics(root.path); + assert.equal(diags.length, 1); + const diag = diags[0]; + assert.equal(diag.code, Diagnostics.Cannot_find_module_0.code); + assert.equal(flattenDiagnosticMessageText(diag.messageText, "\n"), "Cannot find module 'bar'."); + callsTrackingHost.verifyCalledOn(CalledMapsWithSingleArg.fileExists, imported.path); + + + callsTrackingHost.clear(); + host.reloadFS([root, imported]); + host.runQueuedTimeoutCallbacks(); + diags = project.getLanguageService().getSemanticDiagnostics(root.path); + assert.equal(diags.length, 0); + callsTrackingHost.verifyCalledOn(CalledMapsWithSingleArg.fileExists, imported.path); + }); + + it("when calling goto definition of module", () => { + const clientFile: FileOrFolder = { + path: "/a/b/controllers/vessels/client.ts", + content: ` + import { Vessel } from '~/models/vessel'; + const v = new Vessel(); + ` + }; + const anotherModuleFile: FileOrFolder = { + path: "/a/b/utils/db.ts", + content: "export class Bookshelf { }" + }; + const moduleFile: FileOrFolder = { + path: "/a/b/models/vessel.ts", + content: ` + import { Bookshelf } from '~/utils/db'; + export class Vessel extends Bookshelf {} + ` + }; + const tsconfigFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: JSON.stringify({ + compilerOptions: { + target: "es6", + module: "es6", + baseUrl: "./", // all paths are relative to the baseUrl + paths: { + "~/*": ["*"] // resolve any `~/foo/bar` to `/foo/bar` + } + }, + exclude: [ + "api", + "build", + "node_modules", + "public", + "seeds", + "sql_updates", + "tests.build" + ] + }) + }; + const projectFiles = [clientFile, anotherModuleFile, moduleFile, tsconfigFile]; + const host = createServerHost(projectFiles); + const session = createSession(host); + const projectService = session.getProjectService(); + const { configFileName } = projectService.openClientFile(clientFile.path); + + assert.isDefined(configFileName, `should find config`); + checkNumberOfConfiguredProjects(projectService, 1); + + const project = projectService.configuredProjects.get(tsconfigFile.path); + checkProjectActualFiles(project, map(projectFiles, f => f.path)); + + const callsTrackingHost = createCallsTrackingHost(host); + + // Get definitions shouldnt make host requests + const getDefinitionRequest = makeSessionRequest(protocol.CommandTypes.Definition, { + file: clientFile.path, + position: clientFile.content.indexOf("/vessel") + 1, + line: undefined, + offset: undefined + }); + const { response } = session.executeCommand(getDefinitionRequest); + assert.equal(response[0].file, moduleFile.path, "Should go to definition of vessel: response: " + JSON.stringify(response)); + callsTrackingHost.verifyNoHostCalls(); + + // Open the file should call only file exists on module directory and use cached value for parental directory + const { configFileName: config2 } = projectService.openClientFile(moduleFile.path); + assert.equal(config2, configFileName); + callsTrackingHost.verifyNoHostCallsExceptFileExistsOnce(["/a/b/models/tsconfig.json", "/a/b/models/jsconfig.json"]); + + checkNumberOfConfiguredProjects(projectService, 1); + assert.strictEqual(projectService.configuredProjects.get(tsconfigFile.path), project); + }); + + describe("WatchDirectories for config file with", () => { + function verifyWatchDirectoriesCaseSensitivity(useCaseSensitiveFileNames: boolean) { + const frontendDir = "/Users/someuser/work/applications/frontend"; + const toCanonical: (s: string) => Path = useCaseSensitiveFileNames ? s => s as Path : s => s.toLowerCase() as Path; + const canonicalFrontendDir = toCanonical(frontendDir); + const file1: FileOrFolder = { + path: `${frontendDir}/src/app/utils/Analytic.ts`, + content: "export class SomeClass { };" + }; + const file2: FileOrFolder = { + path: `${frontendDir}/src/app/redux/configureStore.ts`, + content: "export class configureStore { }" + }; + const file3: FileOrFolder = { + path: `${frontendDir}/src/app/utils/Cookie.ts`, + content: "export class Cookie { }" + }; + const es2016LibFile: FileOrFolder = { + path: "/a/lib/lib.es2016.full.d.ts", + content: libFile.content + }; + const typeRoots = ["types", "node_modules/@types"]; + const types = ["node", "jest"]; + const tsconfigFile: FileOrFolder = { + path: `${frontendDir}/tsconfig.json`, + content: JSON.stringify({ + "compilerOptions": { + "strict": true, + "strictNullChecks": true, + "target": "es2016", + "module": "commonjs", + "moduleResolution": "node", + "sourceMap": true, + "noEmitOnError": true, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + types, + "noUnusedLocals": true, + "outDir": "./compiled", + typeRoots, + "baseUrl": ".", + "paths": { + "*": [ + "types/*" + ] + } + }, + "include": [ + "src/**/*" + ], + "exclude": [ + "node_modules", + "compiled" + ] + }) + }; + const projectFiles = [file1, file2, es2016LibFile, tsconfigFile]; + const host = createServerHost(projectFiles, { useCaseSensitiveFileNames }); + const projectService = createProjectService(host); + const canonicalConfigPath = toCanonical(tsconfigFile.path); + const { configFileName } = projectService.openClientFile(file1.path); + assert.equal(configFileName, tsconfigFile.path, `should find config`); + checkNumberOfConfiguredProjects(projectService, 1); + const watchingRecursiveDirectories = [`${canonicalFrontendDir}/src`, canonicalFrontendDir].concat(getNodeModuleDirectories(getDirectoryPath(canonicalFrontendDir))); + + const project = projectService.configuredProjects.get(canonicalConfigPath); + verifyProjectAndWatchedDirectories(); + + const callsTrackingHost = createCallsTrackingHost(host); + + // Create file cookie.ts + projectFiles.push(file3); + host.reloadFS(projectFiles); + host.runQueuedTimeoutCallbacks(); + + const canonicalFile3Path = useCaseSensitiveFileNames ? file3.path : file3.path.toLocaleLowerCase(); + const numberOfTimesWatchInvoked = getNumberOfWatchesInvokedForRecursiveWatches(watchingRecursiveDirectories, canonicalFile3Path); + callsTrackingHost.verifyCalledOnEachEntryNTimes(CalledMapsWithSingleArg.fileExists, [canonicalFile3Path], numberOfTimesWatchInvoked); + callsTrackingHost.verifyCalledOnEachEntryNTimes(CalledMapsWithSingleArg.directoryExists, [canonicalFile3Path], numberOfTimesWatchInvoked); + callsTrackingHost.verifyNoCall(CalledMapsWithSingleArg.getDirectories); + callsTrackingHost.verifyCalledOnEachEntryNTimes(CalledMapsWithSingleArg.readFile, [file3.path], 1); + callsTrackingHost.verifyNoCall(CalledMapsWithFiveArgs.readDirectory); + + checkNumberOfConfiguredProjects(projectService, 1); + assert.strictEqual(projectService.configuredProjects.get(canonicalConfigPath), project); + verifyProjectAndWatchedDirectories(); + + callsTrackingHost.clear(); + + const { configFileName: configFile2 } = projectService.openClientFile(file3.path); + assert.equal(configFile2, configFileName); + + checkNumberOfConfiguredProjects(projectService, 1); + assert.strictEqual(projectService.configuredProjects.get(canonicalConfigPath), project); + verifyProjectAndWatchedDirectories(); + callsTrackingHost.verifyNoHostCalls(); + + function getFilePathIfNotOpen(f: FileOrFolder) { + const path = toCanonical(f.path); + const info = projectService.getScriptInfoForPath(toCanonical(f.path)); + return info && info.isScriptOpen() ? undefined : path; + } + + function verifyProjectAndWatchedDirectories() { + checkProjectActualFiles(project, map(projectFiles, f => f.path)); + checkWatchedFiles(host, mapDefined(projectFiles, getFilePathIfNotOpen)); + checkWatchedDirectories(host, watchingRecursiveDirectories, /*recursive*/ true); + checkWatchedDirectories(host, [], /*recursive*/ false); + } + } + + it("case insensitive file system", () => { + verifyWatchDirectoriesCaseSensitivity(/*useCaseSensitiveFileNames*/ false); + }); + + it("case sensitive file system", () => { + verifyWatchDirectoriesCaseSensitivity(/*useCaseSensitiveFileNames*/ true); + }); + }); + + describe("Verify npm install in directory with tsconfig file works when", () => { + function verifyNpmInstall(timeoutDuringPartialInstallation: boolean) { + const app: FileOrFolder = { + path: "/a/b/app.ts", + content: "import _ from 'lodash';" + }; + const tsconfigJson: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: '{ "compilerOptions": { } }' + }; + const packageJson: FileOrFolder = { + path: "/a/b/package.json", + content: ` +{ + "name": "test", + "version": "1.0.0", + "description": "", + "main": "index.js", + "dependencies": { + "lodash", + "rxjs" + }, + "devDependencies": { + "@types/lodash", + "typescript" + }, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC" +} +` + }; + const appFolder = getDirectoryPath(app.path); + const projectFiles = [app, libFile, tsconfigJson]; + const typeRootDirectories = getTypeRootsFromLocation(getDirectoryPath(tsconfigJson.path)); + const otherFiles = [packageJson]; + const host = createServerHost(projectFiles.concat(otherFiles)); + const projectService = createProjectService(host); + const { configFileName } = projectService.openClientFile(app.path); + assert.equal(configFileName, tsconfigJson.path, `should find config`); + const recursiveWatchedDirectories: string[] = [appFolder].concat(getNodeModuleDirectories(getDirectoryPath(appFolder))); + verifyProject(); + + let timeoutAfterReloadFs = timeoutDuringPartialInstallation; + + // Simulate npm install + const filesAndFoldersToAdd: FileOrFolder[] = [ + { "path": "/a/b/node_modules" }, + { "path": "/a/b/node_modules/.staging/@types" }, + { "path": "/a/b/node_modules/.staging/lodash-b0733faa" }, + { "path": "/a/b/node_modules/.staging/@types/lodash-e56c4fe7" }, + { "path": "/a/b/node_modules/.staging/symbol-observable-24bcbbff" }, + { "path": "/a/b/node_modules/.staging/rxjs-22375c61" }, + { "path": "/a/b/node_modules/.staging/typescript-8493ea5d" }, + { "path": "/a/b/node_modules/.staging/symbol-observable-24bcbbff/package.json", "content": "{\n \"name\": \"symbol-observable\",\n \"version\": \"1.0.4\",\n \"description\": \"Symbol.observable ponyfill\",\n \"license\": \"MIT\",\n \"repository\": \"blesh/symbol-observable\",\n \"author\": {\n \"name\": \"Ben Lesh\",\n \"email\": \"ben@benlesh.com\"\n },\n \"engines\": {\n \"node\": \">=0.10.0\"\n },\n \"scripts\": {\n \"test\": \"npm run build && mocha && tsc ./ts-test/test.ts && node ./ts-test/test.js && check-es3-syntax -p lib/ --kill\",\n \"build\": \"babel es --out-dir lib\",\n \"prepublish\": \"npm test\"\n },\n \"files\": [\n \"" }, + { "path": "/a/b/node_modules/.staging/lodash-b0733faa/package.json", "content": "{\n \"name\": \"lodash\",\n \"version\": \"4.17.4\",\n \"description\": \"Lodash modular utilities.\",\n \"keywords\": \"modules, stdlib, util\",\n \"homepage\": \"https://lodash.com/\",\n \"repository\": \"lodash/lodash\",\n \"icon\": \"https://lodash.com/icon.svg\",\n \"license\": \"MIT\",\n \"main\": \"lodash.js\",\n \"author\": \"John-David Dalton (http://allyoucanleet.com/)\",\n \"contributors\": [\n \"John-David Dalton (http://allyoucanleet.com/)\",\n \"Mathias Bynens \",\n \"contributors\": [\n {\n \"name\": \"Ben Lesh\",\n \"email\": \"ben@benlesh.com\"\n },\n {\n \"name\": \"Paul Taylor\",\n \"email\": \"paul.e.taylor@me.com\"\n },\n {\n \"name\": \"Jeff Cross\",\n \"email\": \"crossj@google.com\"\n },\n {\n \"name\": \"Matthew Podwysocki\",\n \"email\": \"matthewp@microsoft.com\"\n },\n {\n \"name\": \"OJ Kwon\",\n \"email\": \"kwon.ohjoong@gmail.com\"\n },\n {\n \"name\": \"Andre Staltz\",\n \"email\": \"andre@staltz.com\"\n }\n ],\n \"license\": \"Apache-2.0\",\n \"bugs\": {\n \"url\": \"https://github.com/ReactiveX/RxJS/issues\"\n },\n \"homepage\": \"https://github.com/ReactiveX/RxJS\",\n \"devDependencies\": {\n \"babel-polyfill\": \"^6.23.0\",\n \"benchmark\": \"^2.1.0\",\n \"benchpress\": \"2.0.0-beta.1\",\n \"chai\": \"^3.5.0\",\n \"color\": \"^0.11.1\",\n \"colors\": \"1.1.2\",\n \"commitizen\": \"^2.8.6\",\n \"coveralls\": \"^2.11.13\",\n \"cz-conventional-changelog\": \"^1.2.0\",\n \"danger\": \"^1.1.0\",\n \"doctoc\": \"^1.0.0\",\n \"escape-string-regexp\": \"^1.0.5 \",\n \"esdoc\": \"^0.4.7\",\n \"eslint\": \"^3.8.0\",\n \"fs-extra\": \"^2.1.2\",\n \"get-folder-size\": \"^1.0.0\",\n \"glob\": \"^7.0.3\",\n \"gm\": \"^1.22.0\",\n \"google-closure-compiler-js\": \"^20170218.0.0\",\n \"gzip-size\": \"^3.0.0\",\n \"http-server\": \"^0.9.0\",\n \"husky\": \"^0.13.3\",\n \"lint-staged\": \"3.2.5\",\n \"lodash\": \"^4.15.0\",\n \"madge\": \"^1.4.3\",\n \"markdown-doctest\": \"^0.9.1\",\n \"minimist\": \"^1.2.0\",\n \"mkdirp\": \"^0.5.1\",\n \"mocha\": \"^3.0.2\",\n \"mocha-in-sauce\": \"0.0.1\",\n \"npm-run-all\": \"^4.0.2\",\n \"npm-scripts-info\": \"^0.3.4\",\n \"nyc\": \"^10.2.0\",\n \"opn-cli\": \"^3.1.0\",\n \"platform\": \"^1.3.1\",\n \"promise\": \"^7.1.1\",\n \"protractor\": \"^3.1.1\",\n \"rollup\": \"0.36.3\",\n \"rollup-plugin-inject\": \"^2.0.0\",\n \"rollup-plugin-node-resolve\": \"^2.0.0\",\n \"rx\": \"latest\",\n \"rxjs\": \"latest\",\n \"shx\": \"^0.2.2\",\n \"sinon\": \"^2.1.0\",\n \"sinon-chai\": \"^2.9.0\",\n \"source-map-support\": \"^0.4.0\",\n \"tslib\": \"^1.5.0\",\n \"tslint\": \"^4.4.2\",\n \"typescript\": \"~2.0.6\",\n \"typings\": \"^2.0.0\",\n \"validate-commit-msg\": \"^2.14.0\",\n \"watch\": \"^1.0.1\",\n \"webpack\": \"^1.13.1\",\n \"xmlhttprequest\": \"1.8.0\"\n },\n \"engines\": {\n \"npm\": \">=2.0.0\"\n },\n \"typings\": \"Rx.d.ts\",\n \"dependencies\": {\n \"symbol-observable\": \"^1.0.1\"\n }\n}" }, + { "path": "/a/b/node_modules/.staging/typescript-8493ea5d/package.json", "content": "{\n \"name\": \"typescript\",\n \"author\": \"Microsoft Corp.\",\n \"homepage\": \"http://typescriptlang.org/\",\n \"version\": \"2.4.2\",\n \"license\": \"Apache-2.0\",\n \"description\": \"TypeScript is a language for application scale JavaScript development\",\n \"keywords\": [\n \"TypeScript\",\n \"Microsoft\",\n \"compiler\",\n \"language\",\n \"javascript\"\n ],\n \"bugs\": {\n \"url\": \"https://github.com/Microsoft/TypeScript/issues\"\n },\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"https://github.com/Microsoft/TypeScript.git\"\n },\n \"main\": \"./lib/typescript.js\",\n \"typings\": \"./lib/typescript.d.ts\",\n \"bin\": {\n \"tsc\": \"./bin/tsc\",\n \"tsserver\": \"./bin/tsserver\"\n },\n \"engines\": {\n \"node\": \">=4.2.0\"\n },\n \"devDependencies\": {\n \"@types/browserify\": \"latest\",\n \"@types/chai\": \"latest\",\n \"@types/convert-source-map\": \"latest\",\n \"@types/del\": \"latest\",\n \"@types/glob\": \"latest\",\n \"@types/gulp\": \"latest\",\n \"@types/gulp-concat\": \"latest\",\n \"@types/gulp-help\": \"latest\",\n \"@types/gulp-newer\": \"latest\",\n \"@types/gulp-sourcemaps\": \"latest\",\n \"@types/merge2\": \"latest\",\n \"@types/minimatch\": \"latest\",\n \"@types/minimist\": \"latest\",\n \"@types/mkdirp\": \"latest\",\n \"@types/mocha\": \"latest\",\n \"@types/node\": \"latest\",\n \"@types/q\": \"latest\",\n \"@types/run-sequence\": \"latest\",\n \"@types/through2\": \"latest\",\n \"browserify\": \"latest\",\n \"chai\": \"latest\",\n \"convert-source-map\": \"latest\",\n \"del\": \"latest\",\n \"gulp\": \"latest\",\n \"gulp-clone\": \"latest\",\n \"gulp-concat\": \"latest\",\n \"gulp-help\": \"latest\",\n \"gulp-insert\": \"latest\",\n \"gulp-newer\": \"latest\",\n \"gulp-sourcemaps\": \"latest\",\n \"gulp-typescript\": \"latest\",\n \"into-stream\": \"latest\",\n \"istanbul\": \"latest\",\n \"jake\": \"latest\",\n \"merge2\": \"latest\",\n \"minimist\": \"latest\",\n \"mkdirp\": \"latest\",\n \"mocha\": \"latest\",\n \"mocha-fivemat-progress-reporter\": \"latest\",\n \"q\": \"latest\",\n \"run-sequence\": \"latest\",\n \"sorcery\": \"latest\",\n \"through2\": \"latest\",\n \"travis-fold\": \"latest\",\n \"ts-node\": \"latest\",\n \"tslint\": \"latest\",\n \"typescript\": \"^2.4\"\n },\n \"scripts\": {\n \"pretest\": \"jake tests\",\n \"test\": \"jake runtests-parallel\",\n \"build\": \"npm run build:compiler && npm run build:tests\",\n \"build:compiler\": \"jake local\",\n \"build:tests\": \"jake tests\",\n \"start\": \"node lib/tsc\",\n \"clean\": \"jake clean\",\n \"gulp\": \"gulp\",\n \"jake\": \"jake\",\n \"lint\": \"jake lint\",\n \"setup-hooks\": \"node scripts/link-hooks.js\"\n },\n \"browser\": {\n \"buffer\": false,\n \"fs\": false,\n \"os\": false,\n \"path\": false\n }\n}" }, + { "path": "/a/b/node_modules/.staging/symbol-observable-24bcbbff/index.js", "content": "module.exports = require('./lib/index');\n" }, + { "path": "/a/b/node_modules/.staging/symbol-observable-24bcbbff/index.d.ts", "content": "declare const observableSymbol: symbol;\nexport default observableSymbol;\n" }, + { "path": "/a/b/node_modules/.staging/symbol-observable-24bcbbff/lib" }, + { "path": "/a/b/node_modules/.staging/symbol-observable-24bcbbff/lib/index.js", "content": "'use strict';\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\n\nvar _ponyfill = require('./ponyfill');\n\nvar _ponyfill2 = _interopRequireDefault(_ponyfill);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { 'default': obj }; }\n\nvar root; /* global window */\n\n\nif (typeof self !== 'undefined') {\n root = self;\n} else if (typeof window !== 'undefined') {\n root = window;\n} else if (typeof global !== 'undefined') {\n root = global;\n} else if (typeof module !== 'undefined') {\n root = module;\n} else {\n root = Function('return this')();\n}\n\nvar result = (0, _ponyfill2['default'])(root);\nexports['default'] = result;" }, + ]; + verifyAfterPartialOrCompleteNpmInstall(2); + + filesAndFoldersToAdd.push( + { "path": "/a/b/node_modules/.staging/typescript-8493ea5d/lib" }, + { "path": "/a/b/node_modules/.staging/rxjs-22375c61/add/operator" }, + { "path": "/a/b/node_modules/.staging/@types/lodash-e56c4fe7/package.json", "content": "{\n \"name\": \"@types/lodash\",\n \"version\": \"4.14.74\",\n \"description\": \"TypeScript definitions for Lo-Dash\",\n \"license\": \"MIT\",\n \"contributors\": [\n {\n \"name\": \"Brian Zengel\",\n \"url\": \"https://github.com/bczengel\"\n },\n {\n \"name\": \"Ilya Mochalov\",\n \"url\": \"https://github.com/chrootsu\"\n },\n {\n \"name\": \"Stepan Mikhaylyuk\",\n \"url\": \"https://github.com/stepancar\"\n },\n {\n \"name\": \"Eric L Anderson\",\n \"url\": \"https://github.com/ericanderson\"\n },\n {\n \"name\": \"AJ Richardson\",\n \"url\": \"https://github.com/aj-r\"\n },\n {\n \"name\": \"Junyoung Clare Jang\",\n \"url\": \"https://github.com/ailrun\"\n }\n ],\n \"main\": \"\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"https://www.github.com/DefinitelyTyped/DefinitelyTyped.git\"\n },\n \"scripts\": {},\n \"dependencies\": {},\n \"typesPublisherContentHash\": \"12af578ffaf8d86d2df37e591857906a86b983fa9258414326544a0fe6af0de8\",\n \"typeScriptVersion\": \"2.2\"\n}" }, + { "path": "/a/b/node_modules/.staging/lodash-b0733faa/index.js", "content": "module.exports = require('./lodash');" }, + { "path": "/a/b/node_modules/.staging/typescript-8493ea5d/package.json.3017591594" } + ); + // Since we didnt add any supported extension file, there wont be any timeout scheduled + verifyAfterPartialOrCompleteNpmInstall(0); + + // Remove file "/a/b/node_modules/.staging/typescript-8493ea5d/package.json.3017591594" + filesAndFoldersToAdd.length--; + verifyAfterPartialOrCompleteNpmInstall(0); + + filesAndFoldersToAdd.push( + { "path": "/a/b/node_modules/.staging/rxjs-22375c61/bundles" }, + { "path": "/a/b/node_modules/.staging/rxjs-22375c61/operator" }, + { "path": "/a/b/node_modules/.staging/rxjs-22375c61/src/add/observable/dom" }, + { "path": "/a/b/node_modules/.staging/@types/lodash-e56c4fe7/index.d.ts", "content": "\n// Stub for lodash\nexport = _;\nexport as namespace _;\ndeclare var _: _.LoDashStatic;\ndeclare namespace _ {\n interface LoDashStatic {\n someProp: string;\n }\n class SomeClass {\n someMethod(): void;\n }\n}" } + ); + verifyAfterPartialOrCompleteNpmInstall(2); + + filesAndFoldersToAdd.push( + { "path": "/a/b/node_modules/.staging/rxjs-22375c61/src/scheduler" }, + { "path": "/a/b/node_modules/.staging/rxjs-22375c61/src/util" }, + { "path": "/a/b/node_modules/.staging/rxjs-22375c61/symbol" }, + { "path": "/a/b/node_modules/.staging/rxjs-22375c61/testing" }, + { "path": "/a/b/node_modules/.staging/rxjs-22375c61/package.json.2252192041", "content": "{\n \"_args\": [\n [\n {\n \"raw\": \"rxjs@^5.4.2\",\n \"scope\": null,\n \"escapedName\": \"rxjs\",\n \"name\": \"rxjs\",\n \"rawSpec\": \"^5.4.2\",\n \"spec\": \">=5.4.2 <6.0.0\",\n \"type\": \"range\"\n },\n \"C:\\\\Users\\\\shkamat\\\\Desktop\\\\app\"\n ]\n ],\n \"_from\": \"rxjs@>=5.4.2 <6.0.0\",\n \"_id\": \"rxjs@5.4.3\",\n \"_inCache\": true,\n \"_location\": \"/rxjs\",\n \"_nodeVersion\": \"7.7.2\",\n \"_npmOperationalInternal\": {\n \"host\": \"s3://npm-registry-packages\",\n \"tmp\": \"tmp/rxjs-5.4.3.tgz_1502407898166_0.6800217325799167\"\n },\n \"_npmUser\": {\n \"name\": \"blesh\",\n \"email\": \"ben@benlesh.com\"\n },\n \"_npmVersion\": \"5.3.0\",\n \"_phantomChildren\": {},\n \"_requested\": {\n \"raw\": \"rxjs@^5.4.2\",\n \"scope\": null,\n \"escapedName\": \"rxjs\",\n \"name\": \"rxjs\",\n \"rawSpec\": \"^5.4.2\",\n \"spec\": \">=5.4.2 <6.0.0\",\n \"type\": \"range\"\n },\n \"_requiredBy\": [\n \"/\"\n ],\n \"_resolved\": \"https://registry.npmjs.org/rxjs/-/rxjs-5.4.3.tgz\",\n \"_shasum\": \"0758cddee6033d68e0fd53676f0f3596ce3d483f\",\n \"_shrinkwrap\": null,\n \"_spec\": \"rxjs@^5.4.2\",\n \"_where\": \"C:\\\\Users\\\\shkamat\\\\Desktop\\\\app\",\n \"author\": {\n \"name\": \"Ben Lesh\",\n \"email\": \"ben@benlesh.com\"\n },\n \"bugs\": {\n \"url\": \"https://github.com/ReactiveX/RxJS/issues\"\n },\n \"config\": {\n \"commitizen\": {\n \"path\": \"cz-conventional-changelog\"\n }\n },\n \"contributors\": [\n {\n \"name\": \"Ben Lesh\",\n \"email\": \"ben@benlesh.com\"\n },\n {\n \"name\": \"Paul Taylor\",\n \"email\": \"paul.e.taylor@me.com\"\n },\n {\n \"name\": \"Jeff Cross\",\n \"email\": \"crossj@google.com\"\n },\n {\n \"name\": \"Matthew Podwysocki\",\n \"email\": \"matthewp@microsoft.com\"\n },\n {\n \"name\": \"OJ Kwon\",\n \"email\": \"kwon.ohjoong@gmail.com\"\n },\n {\n \"name\": \"Andre Staltz\",\n \"email\": \"andre@staltz.com\"\n }\n ],\n \"dependencies\": {\n \"symbol-observable\": \"^1.0.1\"\n },\n \"description\": \"Reactive Extensions for modern JavaScript\",\n \"devDependencies\": {\n \"babel-polyfill\": \"^6.23.0\",\n \"benchmark\": \"^2.1.0\",\n \"benchpress\": \"2.0.0-beta.1\",\n \"chai\": \"^3.5.0\",\n \"color\": \"^0.11.1\",\n \"colors\": \"1.1.2\",\n \"commitizen\": \"^2.8.6\",\n \"coveralls\": \"^2.11.13\",\n \"cz-conventional-changelog\": \"^1.2.0\",\n \"danger\": \"^1.1.0\",\n \"doctoc\": \"^1.0.0\",\n \"escape-string-regexp\": \"^1.0.5 \",\n \"esdoc\": \"^0.4.7\",\n \"eslint\": \"^3.8.0\",\n \"fs-extra\": \"^2.1.2\",\n \"get-folder-size\": \"^1.0.0\",\n \"glob\": \"^7.0.3\",\n \"gm\": \"^1.22.0\",\n \"google-closure-compiler-js\": \"^20170218.0.0\",\n \"gzip-size\": \"^3.0.0\",\n \"http-server\": \"^0.9.0\",\n \"husky\": \"^0.13.3\",\n \"lint-staged\": \"3.2.5\",\n \"lodash\": \"^4.15.0\",\n \"madge\": \"^1.4.3\",\n \"markdown-doctest\": \"^0.9.1\",\n \"minimist\": \"^1.2.0\",\n \"mkdirp\": \"^0.5.1\",\n \"mocha\": \"^3.0.2\",\n \"mocha-in-sauce\": \"0.0.1\",\n \"npm-run-all\": \"^4.0.2\",\n \"npm-scripts-info\": \"^0.3.4\",\n \"nyc\": \"^10.2.0\",\n \"opn-cli\": \"^3.1.0\",\n \"platform\": \"^1.3.1\",\n \"promise\": \"^7.1.1\",\n \"protractor\": \"^3.1.1\",\n \"rollup\": \"0.36.3\",\n \"rollup-plugin-inject\": \"^2.0.0\",\n \"rollup-plugin-node-resolve\": \"^2.0.0\",\n \"rx\": \"latest\",\n \"rxjs\": \"latest\",\n \"shx\": \"^0.2.2\",\n \"sinon\": \"^2.1.0\",\n \"sinon-chai\": \"^2.9.0\",\n \"source-map-support\": \"^0.4.0\",\n \"tslib\": \"^1.5.0\",\n \"tslint\": \"^4.4.2\",\n \"typescript\": \"~2.0.6\",\n \"typings\": \"^2.0.0\",\n \"validate-commit-msg\": \"^2.14.0\",\n \"watch\": \"^1.0.1\",\n \"webpack\": \"^1.13.1\",\n \"xmlhttprequest\": \"1.8.0\"\n },\n \"directories\": {},\n \"dist\": {\n \"integrity\": \"sha512-fSNi+y+P9ss+EZuV0GcIIqPUK07DEaMRUtLJvdcvMyFjc9dizuDjere+A4V7JrLGnm9iCc+nagV/4QdMTkqC4A==\",\n \"shasum\": \"0758cddee6033d68e0fd53676f0f3596ce3d483f\",\n \"tarball\": \"https://registry.npmjs.org/rxjs/-/rxjs-5.4.3.tgz\"\n },\n \"engines\": {\n \"npm\": \">=2.0.0\"\n },\n \"homepage\": \"https://github.com/ReactiveX/RxJS\",\n \"keywords\": [\n \"Rx\",\n \"RxJS\",\n \"ReactiveX\",\n \"ReactiveExtensions\",\n \"Streams\",\n \"Observables\",\n \"Observable\",\n \"Stream\",\n \"ES6\",\n \"ES2015\"\n ],\n \"license\": \"Apache-2.0\",\n \"lint-staged\": {\n \"*.@(js)\": [\n \"eslint --fix\",\n \"git add\"\n ],\n \"*.@(ts)\": [\n \"tslint --fix\",\n \"git add\"\n ]\n },\n \"main\": \"Rx.js\",\n \"maintainers\": [\n {\n \"name\": \"blesh\",\n \"email\": \"ben@benlesh.com\"\n }\n ],\n \"name\": \"rxjs\",\n \"optionalDependencies\": {},\n \"readme\": \"ERROR: No README data found!\",\n \"repository\": {\n \"type\": \"git\",\n \"url\": \"git+ssh://git@github.com/ReactiveX/RxJS.git\"\n },\n \"scripts-info\": {\n \"info\": \"List available script\",\n \"build_all\": \"Build all packages (ES6, CJS, UMD) and generate packages\",\n \"build_cjs\": \"Build CJS package with clean up existing build, copy source into dist\",\n \"build_es6\": \"Build ES6 package with clean up existing build, copy source into dist\",\n \"build_closure_core\": \"Minify Global core build using closure compiler\",\n \"build_global\": \"Build Global package, then minify build\",\n \"build_perf\": \"Build CJS & Global build, run macro performance test\",\n \"build_test\": \"Build CJS package & test spec, execute mocha test runner\",\n \"build_cover\": \"Run lint to current code, build CJS & test spec, execute test coverage\",\n \"build_docs\": \"Build ES6 & global package, create documentation using it\",\n \"build_spec\": \"Build test specs\",\n \"check_circular_dependencies\": \"Check codebase has circular dependencies\",\n \"clean_spec\": \"Clean up existing test spec build output\",\n \"clean_dist_cjs\": \"Clean up existing CJS package output\",\n \"clean_dist_es6\": \"Clean up existing ES6 package output\",\n \"clean_dist_global\": \"Clean up existing Global package output\",\n \"commit\": \"Run git commit wizard\",\n \"compile_dist_cjs\": \"Compile codebase into CJS module\",\n \"compile_module_es6\": \"Compile codebase into ES6\",\n \"cover\": \"Execute test coverage\",\n \"lint_perf\": \"Run lint against performance test suite\",\n \"lint_spec\": \"Run lint against test spec\",\n \"lint_src\": \"Run lint against source\",\n \"lint\": \"Run lint against everything\",\n \"perf\": \"Run macro performance benchmark\",\n \"perf_micro\": \"Run micro performance benchmark\",\n \"test_mocha\": \"Execute mocha test runner against existing test spec build\",\n \"test_browser\": \"Execute mocha test runner on browser against existing test spec build\",\n \"test\": \"Clean up existing test spec build, build test spec and execute mocha test runner\",\n \"tests2png\": \"Generate marble diagram image from test spec\",\n \"watch\": \"Watch codebase, trigger compile when source code changes\"\n },\n \"typings\": \"Rx.d.ts\",\n \"version\": \"5.4.3\"\n}\n" } + ); + verifyAfterPartialOrCompleteNpmInstall(0); + + // remove /a/b/node_modules/.staging/rxjs-22375c61/package.json.2252192041 + filesAndFoldersToAdd.length--; + // and add few more folders/files + filesAndFoldersToAdd.push( + { "path": "/a/b/node_modules/symbol-observable" }, + { "path": "/a/b/node_modules/@types" }, + { "path": "/a/b/node_modules/@types/lodash" }, + { "path": "/a/b/node_modules/lodash" }, + { "path": "/a/b/node_modules/rxjs" }, + { "path": "/a/b/node_modules/typescript" }, + { "path": "/a/b/node_modules/.bin" } + ); + // From the type root update + verifyAfterPartialOrCompleteNpmInstall(2); + + forEach(filesAndFoldersToAdd, f => { + f.path = f.path + .replace("/a/b/node_modules/.staging", "/a/b/node_modules") + .replace(/[\-\.][\d\w][\d\w][\d\w][\d\w][\d\w][\d\w][\d\w][\d\w]/g, ""); + }); + + const lodashIndexPath = "/a/b/node_modules/@types/lodash/index.d.ts"; + projectFiles.push(find(filesAndFoldersToAdd, f => f.path === lodashIndexPath)); + // we would now not have failed lookup in the parent of appFolder since lodash is available + recursiveWatchedDirectories.length = 1; + // npm installation complete, timeout after reload fs + timeoutAfterReloadFs = true; + verifyAfterPartialOrCompleteNpmInstall(2); + + function verifyAfterPartialOrCompleteNpmInstall(timeoutQueueLengthWhenRunningTimeouts: number) { + host.reloadFS(projectFiles.concat(otherFiles, filesAndFoldersToAdd)); + if (timeoutAfterReloadFs) { + host.checkTimeoutQueueLengthAndRun(timeoutQueueLengthWhenRunningTimeouts); + } + else { + host.checkTimeoutQueueLength(2); + } + verifyProject(); + } + + function verifyProject() { + checkNumberOfConfiguredProjects(projectService, 1); + + const project = projectService.configuredProjects.get(tsconfigJson.path); + const projectFilePaths = map(projectFiles, f => f.path); + checkProjectActualFiles(project, projectFilePaths); + + const filesWatched = filter(projectFilePaths, p => p !== app.path); + checkWatchedFiles(host, filesWatched); + checkWatchedDirectories(host, typeRootDirectories.concat(recursiveWatchedDirectories), /*recursive*/ true); + checkWatchedDirectories(host, [], /*recursive*/ false); + } + } + + it("timeouts occur inbetween installation", () => { + verifyNpmInstall(/*timeoutDuringPartialInstallation*/ true); + }); + + it("timeout occurs after installation", () => { + verifyNpmInstall(/*timeoutDuringPartialInstallation*/ false); + }); + }); + }); + + describe("ProjectsChangedInBackground", () => { + function verifyFiles(caption: string, actual: ReadonlyArray, expected: ReadonlyArray) { + assert.equal(actual.length, expected.length, `Incorrect number of ${caption}. Actual: ${actual} Expected: ${expected}`); + const seen = createMap(); + forEach(actual, f => { + assert.isFalse(seen.has(f), `${caption}: Found duplicate ${f}. Actual: ${actual} Expected: ${expected}`); + seen.set(f, true); + assert.isTrue(contains(expected, f), `${caption}: Expected not to contain ${f}. Actual: ${actual} Expected: ${expected}`); + }); + } + + function createVerifyInitialOpen(session: TestSession, verifyProjectsUpdatedInBackgroundEventHandler: (events: server.ProjectsUpdatedInBackgroundEvent[]) => void) { + return (file: FileOrFolder) => { + session.executeCommandSeq({ + command: server.CommandNames.Open, + arguments: { + file: file.path + } + }); + verifyProjectsUpdatedInBackgroundEventHandler([]); + }; + } + + interface ProjectsUpdatedInBackgroundEventVerifier { + session: TestSession; + verifyProjectsUpdatedInBackgroundEventHandler(events: server.ProjectsUpdatedInBackgroundEvent[]): void; + verifyInitialOpen(file: FileOrFolder): void; + } + + function verifyProjectsUpdatedInBackgroundEvent(createSession: (host: TestServerHost) => ProjectsUpdatedInBackgroundEventVerifier) { + it("when adding new file", () => { + const commonFile1: FileOrFolder = { + path: "/a/b/file1.ts", + content: "export var x = 10;" + }; + const commonFile2: FileOrFolder = { + path: "/a/b/file2.ts", + content: "export var y = 10;" + }; + const commonFile3: FileOrFolder = { + path: "/a/b/file3.ts", + content: "export var z = 10;" + }; + const configFile: FileOrFolder = { + path: "/a/b/tsconfig.json", + content: `{}` + }; + const openFiles = [commonFile1.path]; + const host = createServerHost([commonFile1, libFile, configFile]); + const { verifyProjectsUpdatedInBackgroundEventHandler, verifyInitialOpen } = createSession(host); + verifyInitialOpen(commonFile1); + + host.reloadFS([commonFile1, libFile, configFile, commonFile2]); + host.runQueuedTimeoutCallbacks(); + verifyProjectsUpdatedInBackgroundEventHandler([{ + eventName: server.ProjectsUpdatedInBackgroundEvent, + data: { + openFiles + } + }]); + + host.reloadFS([commonFile1, commonFile2, libFile, configFile, commonFile3]); + host.runQueuedTimeoutCallbacks(); + verifyProjectsUpdatedInBackgroundEventHandler([{ + eventName: server.ProjectsUpdatedInBackgroundEvent, + data: { + openFiles + } + }]); + }); + + describe("with --out or --outFile setting", () => { + function verifyEventWithOutSettings(compilerOptions: CompilerOptions = {}) { + const config: FileOrFolder = { + path: "/a/tsconfig.json", + content: JSON.stringify({ + compilerOptions + }) + }; + + const f1: FileOrFolder = { + path: "/a/a.ts", + content: "export let x = 1" + }; + const f2: FileOrFolder = { + path: "/a/b.ts", + content: "export let y = 1" + }; + + const openFiles = [f1.path]; + const files = [f1, config, libFile]; + const host = createServerHost(files); + const { verifyInitialOpen, verifyProjectsUpdatedInBackgroundEventHandler } = createSession(host); + verifyInitialOpen(f1); + + files.push(f2); + host.reloadFS(files); + host.runQueuedTimeoutCallbacks(); + + verifyProjectsUpdatedInBackgroundEventHandler([{ + eventName: server.ProjectsUpdatedInBackgroundEvent, + data: { + openFiles + } + }]); + + f2.content = "export let x = 11"; + host.reloadFS(files); + host.runQueuedTimeoutCallbacks(); + verifyProjectsUpdatedInBackgroundEventHandler([{ + eventName: server.ProjectsUpdatedInBackgroundEvent, + data: { + openFiles + } + }]); + } + + it("when both options are not set", () => { + verifyEventWithOutSettings(); + }); + + it("when --out is set", () => { + const outJs = "/a/out.js"; + verifyEventWithOutSettings({ out: outJs }); + }); + + it("when --outFile is set", () => { + const outJs = "/a/out.js"; + verifyEventWithOutSettings({ outFile: outJs }); + }); + }); + + describe("with modules and configured project", () => { + const file1Consumer1Path = "/a/b/file1Consumer1.ts"; + const moduleFile1Path = "/a/b/moduleFile1.ts"; + const configFilePath = "/a/b/tsconfig.json"; + interface InitialStateParams { + /** custom config file options */ + configObj?: any; + /** Additional files and folders to add */ + getAdditionalFileOrFolder?(): FileOrFolder[]; + /** initial list of files to reload in fs and first file in this list being the file to open */ + firstReloadFileList?: string[]; + } + function getInitialState({ configObj = {}, getAdditionalFileOrFolder, firstReloadFileList }: InitialStateParams = {}) { + const moduleFile1: FileOrFolder = { + path: moduleFile1Path, + content: "export function Foo() { };", + }; + + const file1Consumer1: FileOrFolder = { + path: file1Consumer1Path, + content: `import {Foo} from "./moduleFile1"; export var y = 10;`, + }; + + const file1Consumer2: FileOrFolder = { + path: "/a/b/file1Consumer2.ts", + content: `import {Foo} from "./moduleFile1"; let z = 10;`, + }; + + const moduleFile2: FileOrFolder = { + path: "/a/b/moduleFile2.ts", + content: `export var Foo4 = 10;`, + }; + + const globalFile3: FileOrFolder = { + path: "/a/b/globalFile3.ts", + content: `interface GlobalFoo { age: number }` + }; + + const additionalFiles = getAdditionalFileOrFolder ? getAdditionalFileOrFolder() : []; + const configFile = { + path: configFilePath, + content: JSON.stringify(configObj || { compilerOptions: {} }) + }; + + const files = [file1Consumer1, moduleFile1, file1Consumer2, moduleFile2, ...additionalFiles, globalFile3, libFile, configFile]; + + const filesToReload = firstReloadFileList && getFiles(firstReloadFileList) || files; + const host = createServerHost([filesToReload[0], configFile]); + + // Initial project creation + const { session, verifyProjectsUpdatedInBackgroundEventHandler, verifyInitialOpen } = createSession(host); + const openFiles = [filesToReload[0].path]; + verifyInitialOpen(filesToReload[0]); + + // Since this is first event, it will have all the files + verifyProjectsUpdatedInBackgroundEvent(filesToReload); + + return { + moduleFile1, file1Consumer1, file1Consumer2, moduleFile2, globalFile3, configFile, + files, + updateContentOfOpenFile, + verifyNoProjectsUpdatedInBackgroundEvent, + verifyProjectsUpdatedInBackgroundEvent + }; + + function getFiles(filelist: string[]) { + return map(filelist, getFile); + } + + function getFile(fileName: string) { + return find(files, file => file.path === fileName); + } + + function verifyNoProjectsUpdatedInBackgroundEvent(filesToReload?: FileOrFolder[]) { + host.reloadFS(filesToReload || files); + host.runQueuedTimeoutCallbacks(); + verifyProjectsUpdatedInBackgroundEventHandler([]); + } + + function verifyProjectsUpdatedInBackgroundEvent(filesToReload?: FileOrFolder[]) { + host.reloadFS(filesToReload || files); + host.runQueuedTimeoutCallbacks(); + verifyProjectsUpdatedInBackgroundEventHandler([{ + eventName: server.ProjectsUpdatedInBackgroundEvent, + data: { + openFiles + } + }]); + } + + function updateContentOfOpenFile(file: FileOrFolder, newContent: string) { + session.executeCommandSeq({ + command: server.CommandNames.Change, + arguments: { + file: file.path, + insertString: newContent, + endLine: 1, + endOffset: file.content.length, + line: 1, + offset: 1 + } + }); + file.content = newContent; + } + } + + it("should contains only itself if a module file's shape didn't change, and all files referencing it if its shape changed", () => { + const { moduleFile1, verifyProjectsUpdatedInBackgroundEvent } = getInitialState(); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectsUpdatedInBackgroundEvent(); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { console.log('hi'); };` + moduleFile1.content = `export var T: number;export function Foo() { console.log('hi'); };`; + verifyProjectsUpdatedInBackgroundEvent(); + }); + + it("should be up-to-date with the reference map changes", () => { + const { moduleFile1, file1Consumer1, updateContentOfOpenFile, verifyProjectsUpdatedInBackgroundEvent, verifyNoProjectsUpdatedInBackgroundEvent } = getInitialState(); + + // Change file1Consumer1 content to `export let y = Foo();` + updateContentOfOpenFile(file1Consumer1, "export let y = Foo();"); + verifyNoProjectsUpdatedInBackgroundEvent(); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectsUpdatedInBackgroundEvent(); + + // Add the import statements back to file1Consumer1 + updateContentOfOpenFile(file1Consumer1, `import {Foo} from "./moduleFile1";let y = Foo();`); + verifyNoProjectsUpdatedInBackgroundEvent(); + + // Change the content of moduleFile1 to `export var T: number;export var T2: string;export function Foo() { };` + moduleFile1.content = `export var T: number;export var T2: string;export function Foo() { };`; + verifyProjectsUpdatedInBackgroundEvent(); + + // Multiple file edits in one go: + + // Change file1Consumer1 content to `export let y = Foo();` + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + updateContentOfOpenFile(file1Consumer1, `export let y = Foo();`); + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectsUpdatedInBackgroundEvent(); + }); + + it("should be up-to-date with deleted files", () => { + const { moduleFile1, file1Consumer2, files, verifyProjectsUpdatedInBackgroundEvent } = getInitialState(); + + // Change the content of moduleFile1 to `export var T: number;export function Foo() { };` + moduleFile1.content = `export var T: number;export function Foo() { };`; + + // Delete file1Consumer2 + const filesToLoad = filter(files, file => file !== file1Consumer2); + verifyProjectsUpdatedInBackgroundEvent(filesToLoad); + }); + + it("should be up-to-date with newly created files", () => { + const { moduleFile1, files, verifyProjectsUpdatedInBackgroundEvent, } = getInitialState(); + + const file1Consumer3: FileOrFolder = { + path: "/a/b/file1Consumer3.ts", + content: `import {Foo} from "./moduleFile1"; let y = Foo();` + }; + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectsUpdatedInBackgroundEvent(files.concat(file1Consumer3)); + }); + + it("should detect changes in non-root files", () => { + const { moduleFile1, verifyProjectsUpdatedInBackgroundEvent } = getInitialState({ + configObj: { files: [file1Consumer1Path] }, + }); + + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectsUpdatedInBackgroundEvent(); + + // change file1 internal, and verify only file1 is affected + moduleFile1.content += "var T1: number;"; + verifyProjectsUpdatedInBackgroundEvent(); + }); + + it("should return all files if a global file changed shape", () => { + const { globalFile3, verifyProjectsUpdatedInBackgroundEvent } = getInitialState(); + + globalFile3.content += "var T2: string;"; + verifyProjectsUpdatedInBackgroundEvent(); + }); + + it("should always return the file itself if '--isolatedModules' is specified", () => { + const { moduleFile1, verifyProjectsUpdatedInBackgroundEvent } = getInitialState({ + configObj: { compilerOptions: { isolatedModules: true } } + }); + + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectsUpdatedInBackgroundEvent(); + }); + + it("should always return the file itself if '--out' or '--outFile' is specified", () => { + const outFilePath = "/a/b/out.js"; + const { moduleFile1, verifyProjectsUpdatedInBackgroundEvent } = getInitialState({ + configObj: { compilerOptions: { module: "system", outFile: outFilePath } } + }); + + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectsUpdatedInBackgroundEvent(); + }); + + it("should return cascaded affected file list", () => { + const file1Consumer1Consumer1: FileOrFolder = { + path: "/a/b/file1Consumer1Consumer1.ts", + content: `import {y} from "./file1Consumer1";` + }; + const { moduleFile1, file1Consumer1, updateContentOfOpenFile, verifyNoProjectsUpdatedInBackgroundEvent, verifyProjectsUpdatedInBackgroundEvent } = getInitialState({ + getAdditionalFileOrFolder: () => [file1Consumer1Consumer1] + }); + + updateContentOfOpenFile(file1Consumer1, file1Consumer1.content + "export var T: number;"); + verifyNoProjectsUpdatedInBackgroundEvent(); + + // Doesnt change the shape of file1Consumer1 + moduleFile1.content = `export var T: number;export function Foo() { };`; + verifyProjectsUpdatedInBackgroundEvent(); + + // Change both files before the timeout + updateContentOfOpenFile(file1Consumer1, file1Consumer1.content + "export var T2: number;"); + moduleFile1.content = `export var T2: number;export function Foo() { };`; + verifyProjectsUpdatedInBackgroundEvent(); + }); + + it("should work fine for files with circular references", () => { + const file1: FileOrFolder = { + path: "/a/b/file1.ts", + content: ` + /// + export var t1 = 10;` + }; + const file2: FileOrFolder = { + path: "/a/b/file2.ts", + content: ` + /// + export var t2 = 10;` + }; + const { configFile, verifyProjectsUpdatedInBackgroundEvent } = getInitialState({ + getAdditionalFileOrFolder: () => [file1, file2], + firstReloadFileList: [file1.path, libFile.path, file2.path, configFilePath] + }); + + file2.content += "export var t3 = 10;"; + verifyProjectsUpdatedInBackgroundEvent([file1, file2, libFile, configFile]); + }); + + it("should detect removed code file", () => { + const referenceFile1: FileOrFolder = { + path: "/a/b/referenceFile1.ts", + content: ` + /// + export var x = Foo();` + }; + const { configFile, verifyProjectsUpdatedInBackgroundEvent } = getInitialState({ + getAdditionalFileOrFolder: () => [referenceFile1], + firstReloadFileList: [referenceFile1.path, libFile.path, moduleFile1Path, configFilePath] + }); + + verifyProjectsUpdatedInBackgroundEvent([libFile, referenceFile1, configFile]); + }); + + it("should detect non-existing code file", () => { + const referenceFile1: FileOrFolder = { + path: "/a/b/referenceFile1.ts", + content: ` + /// + export var x = Foo();` + }; + const { configFile, moduleFile2, updateContentOfOpenFile, verifyNoProjectsUpdatedInBackgroundEvent, verifyProjectsUpdatedInBackgroundEvent } = getInitialState({ + getAdditionalFileOrFolder: () => [referenceFile1], + firstReloadFileList: [referenceFile1.path, libFile.path, configFilePath] + }); + + updateContentOfOpenFile(referenceFile1, referenceFile1.content + "export var yy = Foo();"); + verifyNoProjectsUpdatedInBackgroundEvent([libFile, referenceFile1, configFile]); + + // Create module File2 and see both files are saved + verifyProjectsUpdatedInBackgroundEvent([libFile, moduleFile2, referenceFile1, configFile]); + }); + }); + + describe("resolution when resolution cache size", () => { + function verifyWithMaxCacheLimit(limitHit: boolean) { + const file1: FileOrFolder = { + path: "/a/b/project/file1.ts", + content: 'import a from "file2"' + }; + const file2: FileOrFolder = { + path: "/a/b/node_modules/file2.d.ts", + content: "export class a { }" + }; + const file3: FileOrFolder = { + path: "/a/b/project/file3.ts", + content: "export class c { }" + }; + const configFile: FileOrFolder = { + path: "/a/b/project/tsconfig.json", + content: JSON.stringify({ compilerOptions: { typeRoots: [] } }) + }; + + const projectFiles = [file1, file3, libFile, configFile]; + const openFiles = [file1.path]; + const watchedRecursiveDirectories = ["/a/b/project", "/a/b/node_modules", "/a/node_modules", "/node_modules"]; + const host = createServerHost(projectFiles); + const { session, verifyInitialOpen, verifyProjectsUpdatedInBackgroundEventHandler } = createSession(host); + const projectService = session.getProjectService(); + verifyInitialOpen(file1); + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + const project = projectService.configuredProjects.get(configFile.path); + verifyProject(); + if (limitHit) { + (project as ResolutionCacheHost).maxNumberOfFilesToIterateForInvalidation = 1; + } + + file3.content += "export class d {}"; + host.reloadFS(projectFiles); + host.checkTimeoutQueueLengthAndRun(2); + + // Since this is first event + verifyProject(); + verifyProjectsUpdatedInBackgroundEventHandler([{ + eventName: server.ProjectsUpdatedInBackgroundEvent, + data: { + openFiles + } + }]); + + projectFiles.push(file2); + host.reloadFS(projectFiles); + host.runQueuedTimeoutCallbacks(); + watchedRecursiveDirectories.length = 2; + verifyProject(); + + verifyProjectsUpdatedInBackgroundEventHandler([{ + eventName: server.ProjectsUpdatedInBackgroundEvent, + data: { + openFiles + } + }]); + + function verifyProject() { + checkProjectActualFiles(project, map(projectFiles, file => file.path)); + checkWatchedDirectories(host, [], /*recursive*/ false); + checkWatchedDirectories(host, watchedRecursiveDirectories, /*recursive*/ true); + } + } + + it("limit not hit", () => { + verifyWithMaxCacheLimit(/*limitHit*/ false); + }); + + it("limit hit", () => { + verifyWithMaxCacheLimit(/*limitHit*/ true); + }); + }); + } + + describe("when event handler is set in the session", () => { + verifyProjectsUpdatedInBackgroundEvent(createSessionWithProjectChangedEventHandler); + + function createSessionWithProjectChangedEventHandler(host: TestServerHost): ProjectsUpdatedInBackgroundEventVerifier { + const projectChangedEvents: server.ProjectsUpdatedInBackgroundEvent[] = []; + const session = createSession(host, { + eventHandler: e => { + if (e.eventName === server.ProjectsUpdatedInBackgroundEvent) { + projectChangedEvents.push(e); + } + } + }); + + return { + session, + verifyProjectsUpdatedInBackgroundEventHandler, + verifyInitialOpen: createVerifyInitialOpen(session, verifyProjectsUpdatedInBackgroundEventHandler) + }; + + function eventToString(event: server.ProjectsUpdatedInBackgroundEvent) { + return JSON.stringify(event && { eventName: event.eventName, data: event.data }); + } + + function eventsToString(events: ReadonlyArray) { + return "[" + map(events, eventToString).join(",") + "]"; + } + + function verifyProjectsUpdatedInBackgroundEventHandler(expectedEvents: ReadonlyArray) { + assert.equal(projectChangedEvents.length, expectedEvents.length, `Incorrect number of events Actual: ${eventsToString(projectChangedEvents)} Expected: ${eventsToString(expectedEvents)}`); + forEach(projectChangedEvents, (actualEvent, i) => { + const expectedEvent = expectedEvents[i]; + assert.strictEqual(actualEvent.eventName, expectedEvent.eventName); + verifyFiles("openFiles", actualEvent.data.openFiles, expectedEvent.data.openFiles); + }); + + // Verified the events, reset them + projectChangedEvents.length = 0; + } + } + }); + + describe("when event handler is not set but session is created with canUseEvents = true", () => { + verifyProjectsUpdatedInBackgroundEvent(createSessionThatUsesEvents); + + function createSessionThatUsesEvents(host: TestServerHost): ProjectsUpdatedInBackgroundEventVerifier { + const session = createSession(host, { canUseEvents: true }); + + return { + session, + verifyProjectsUpdatedInBackgroundEventHandler, + verifyInitialOpen: createVerifyInitialOpen(session, verifyProjectsUpdatedInBackgroundEventHandler) + }; + + function verifyProjectsUpdatedInBackgroundEventHandler(expected: ReadonlyArray) { + const expectedEvents: protocol.ProjectsUpdatedInBackgroundEventBody[] = map(expected, e => { + return { + openFiles: e.data.openFiles + }; + }); + const outputEventRegex = /Content\-Length: [\d]+\r\n\r\n/; + const events: protocol.ProjectsUpdatedInBackgroundEvent[] = filter( + map( + host.getOutput(), s => convertToObject( + ts.parseJsonText("json.json", s.replace(outputEventRegex, "")), + [] + ) + ), + e => e.event === server.ProjectsUpdatedInBackgroundEvent + ); + assert.equal(events.length, expectedEvents.length, `Incorrect number of events Actual: ${map(events, e => e.body)} Expected: ${expectedEvents}`); + forEach(events, (actualEvent, i) => { + const expectedEvent = expectedEvents[i]; + verifyFiles("openFiles", actualEvent.body.openFiles, expectedEvent.openFiles); + }); + + // Verified the events, reset them + host.clearOutput(); + } + } + }); + }); } diff --git a/src/harness/unittests/typingsInstaller.ts b/src/harness/unittests/typingsInstaller.ts index df0c1dd909595..e644f8730107b 100644 --- a/src/harness/unittests/typingsInstaller.ts +++ b/src/harness/unittests/typingsInstaller.ts @@ -38,7 +38,7 @@ namespace ts.projectSystem { function executeCommand(self: Installer, host: TestServerHost, installedTypings: string[] | string, typingFiles: FileOrFolder[], cb: TI.RequestCompletedAction): void { self.addPostExecAction(installedTypings, success => { for (const file of typingFiles) { - host.createFileOrFolder(file, /*createParentDirectory*/ true); + host.ensureFileOrFolder(file); } cb(success); }); @@ -92,7 +92,7 @@ namespace ts.projectSystem { const service = createProjectService(host, { typingsInstaller: installer }); service.openClientFile(f1.path); service.checkNumberOfProjects({ configuredProjects: 1 }); - checkProjectActualFiles(service.configuredProjects[0], [f1.path, f2.path, config.path]); + checkProjectActualFiles(configuredProjectAt(service, 0), [f1.path, f2.path, config.path]); installer.installAll(0); }); }); @@ -144,12 +144,13 @@ namespace ts.projectSystem { projectService.openClientFile(file1.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - const p = projectService.configuredProjects[0]; + const p = configuredProjectAt(projectService, 0); checkProjectActualFiles(p, [file1.path, tsconfig.path]); installer.installAll(/*expectedCount*/ 1); checkNumberOfProjects(projectService, { configuredProjects: 1 }); + host.checkTimeoutQueueLengthAndRun(2); checkProjectActualFiles(p, [file1.path, jquery.path, tsconfig.path]); }); @@ -349,6 +350,8 @@ namespace ts.projectSystem { installer.installAll(/*expectedCount*/ 1); + checkNumberOfProjects(projectService, { externalProjects: 1 }); + host.checkTimeoutQueueLengthAndRun(2); checkNumberOfProjects(projectService, { externalProjects: 1 }); checkProjectActualFiles(p, [file1.path, file2.path, file3.path, lodash.path, react.path]); }); @@ -470,6 +473,8 @@ namespace ts.projectSystem { installer.installAll(/*expectedCount*/ 1); + checkNumberOfProjects(projectService, { externalProjects: 1 }); + host.checkTimeoutQueueLengthAndRun(2); checkNumberOfProjects(projectService, { externalProjects: 1 }); checkProjectActualFiles(p, [file1.path, file2.path, file3.path, commander.path, express.path, jquery.path, moment.path]); }); @@ -548,7 +553,7 @@ namespace ts.projectSystem { for (const f of typingFiles) { assert.isTrue(host.fileExists(f.path), `expected file ${f.path} to exist`); } - + host.checkTimeoutQueueLengthAndRun(2); checkNumberOfProjects(projectService, { externalProjects: 1 }); checkProjectActualFiles(p, [lodashJs.path, commanderJs.path, file3.path, commander.path, express.path, jquery.path, moment.path, lodash.path]); }); @@ -651,7 +656,7 @@ namespace ts.projectSystem { assert.equal(installer.pendingRunRequests.length, 0, "expected no throttled requests"); installer.executePendingCommands(); - + host.checkTimeoutQueueLengthAndRun(3); // for 2 projects and 1 refreshing inferred project checkProjectActualFiles(p1, [lodashJs.path, commanderJs.path, file3.path, commander.path, jquery.path, lodash.path, cordova.path]); checkProjectActualFiles(p2, [file3.path, grunt.path, gulp.path]); }); @@ -699,12 +704,13 @@ namespace ts.projectSystem { projectService.openClientFile(app.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - const p = projectService.configuredProjects[0]; + const p = configuredProjectAt(projectService, 0); checkProjectActualFiles(p, [app.path, jsconfig.path]); installer.installAll(/*expectedCount*/ 1); checkNumberOfProjects(projectService, { configuredProjects: 1 }); + host.checkTimeoutQueueLengthAndRun(2); checkProjectActualFiles(p, [app.path, jqueryDTS.path, jsconfig.path]); }); @@ -745,13 +751,14 @@ namespace ts.projectSystem { projectService.openClientFile(app.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - const p = projectService.configuredProjects[0]; + const p = configuredProjectAt(projectService, 0); checkProjectActualFiles(p, [app.path, jsconfig.path]); checkWatchedFiles(host, [jsconfig.path, "/bower_components", "/node_modules", libFile.path]); installer.installAll(/*expectedCount*/ 1); checkNumberOfProjects(projectService, { configuredProjects: 1 }); + host.checkTimeoutQueueLengthAndRun(2); checkProjectActualFiles(p, [app.path, jqueryDTS.path, jsconfig.path]); }); @@ -792,12 +799,13 @@ namespace ts.projectSystem { projectService.openClientFile(app.path); checkNumberOfProjects(projectService, { configuredProjects: 1 }); - const p = projectService.configuredProjects[0]; + const p = configuredProjectAt(projectService, 0); checkProjectActualFiles(p, [app.path, jsconfig.path]); installer.installAll(/*expectedCount*/ 1); checkNumberOfProjects(projectService, { configuredProjects: 1 }); + host.checkTimeoutQueueLengthAndRun(2); checkProjectActualFiles(p, [app.path, jqueryDTS.path, jsconfig.path]); }); @@ -836,10 +844,10 @@ namespace ts.projectSystem { installer.checkPendingCommands(/*expectedCount*/ 0); host.reloadFS([f, fixedPackageJson]); - host.triggerFileWatcherCallback(fixedPackageJson.path, FileWatcherEventKind.Changed); + host.checkTimeoutQueueLengthAndRun(2); // To refresh the project and refresh inferred projects // expected install request installer.installAll(/*expectedCount*/ 1); - + host.checkTimeoutQueueLengthAndRun(2); service.checkNumberOfProjects({ inferredProjects: 1 }); checkProjectActualFiles(service.inferredProjects[0], [f.path, commander.path]); }); @@ -963,8 +971,7 @@ namespace ts.projectSystem { } }; session.executeCommand(changeRequest); - host.checkTimeoutQueueLength(1); - host.runQueuedTimeoutCallbacks(); + host.checkTimeoutQueueLengthAndRun(2); // This enqueues the updategraph and refresh inferred projects const version2 = proj.getCachedUnresolvedImportsPerFile_TestOnly().getVersion(); assert.equal(version1, version2, "set of unresolved imports should not change"); }); @@ -1171,6 +1178,7 @@ namespace ts.projectSystem { installer.installAll(/*expectedCount*/ 1); assert.isTrue(seenTelemetryEvent); + host.checkTimeoutQueueLengthAndRun(2); checkNumberOfProjects(projectService, { inferredProjects: 1 }); checkProjectActualFiles(projectService.inferredProjects[0], [f1.path, commander.path]); }); @@ -1224,6 +1232,7 @@ namespace ts.projectSystem { assert.isTrue(!!endEvent); assert.isTrue(beginEvent.eventId === endEvent.eventId); assert.isTrue(endEvent.installSuccess); + host.checkTimeoutQueueLengthAndRun(2); checkNumberOfProjects(projectService, { inferredProjects: 1 }); checkProjectActualFiles(projectService.inferredProjects[0], [f1.path, commander.path]); }); diff --git a/src/harness/virtualFileSystemWithWatch.ts b/src/harness/virtualFileSystemWithWatch.ts new file mode 100644 index 0000000000000..c16f57235e412 --- /dev/null +++ b/src/harness/virtualFileSystemWithWatch.ts @@ -0,0 +1,593 @@ +/// + +namespace ts.TestFSWithWatch { + const { content: libFileContent } = Harness.getDefaultLibraryFile(Harness.IO); + export const libFile: FileOrFolder = { + path: "/a/lib/lib.d.ts", + content: libFileContent + }; + + export const safeList = { + path: "/safeList.json", + content: JSON.stringify({ + commander: "commander", + express: "express", + jquery: "jquery", + lodash: "lodash", + moment: "moment", + chroma: "chroma-js" + }) + }; + + function getExecutingFilePathFromLibFile(): string { + return combinePaths(getDirectoryPath(libFile.path), "tsc.js"); + } + + interface TestServerHostCreationParameters { + useCaseSensitiveFileNames?: boolean; + executingFilePath?: string; + currentDirectory?: string; + newLine?: string; + } + + export function createWatchedSystem(fileOrFolderList: ReadonlyArray, params?: TestServerHostCreationParameters): TestServerHost { + if (!params) { + params = {}; + } + const host = new TestServerHost(/*withSafelist*/ false, + params.useCaseSensitiveFileNames !== undefined ? params.useCaseSensitiveFileNames : false, + params.executingFilePath || getExecutingFilePathFromLibFile(), + params.currentDirectory || "/", + fileOrFolderList, + params.newLine); + return host; + } + + export function createServerHost(fileOrFolderList: ReadonlyArray, params?: TestServerHostCreationParameters): TestServerHost { + if (!params) { + params = {}; + } + const host = new TestServerHost(/*withSafelist*/ true, + params.useCaseSensitiveFileNames !== undefined ? params.useCaseSensitiveFileNames : false, + params.executingFilePath || getExecutingFilePathFromLibFile(), + params.currentDirectory || "/", + fileOrFolderList, + params.newLine); + return host; + } + + export interface FileOrFolder { + path: string; + content?: string; + fileSize?: number; + } + + interface FSEntry { + path: Path; + fullPath: string; + } + + interface File extends FSEntry { + content: string; + fileSize?: number; + } + + interface Folder extends FSEntry { + entries: FSEntry[]; + } + + function isFolder(s: FSEntry): s is Folder { + return s && isArray((s).entries); + } + + function isFile(s: FSEntry): s is File { + return s && isString((s).content); + } + + function invokeWatcherCallbacks(callbacks: T[], invokeCallback: (cb: T) => void): void { + if (callbacks) { + // The array copy is made to ensure that even if one of the callback removes the callbacks, + // we dont miss any callbacks following it + const cbs = callbacks.slice(); + for (const cb of cbs) { + invokeCallback(cb); + } + } + } + + function getDiffInKeys(map: Map, expectedKeys: ReadonlyArray) { + if (map.size === expectedKeys.length) { + return ""; + } + const notInActual: string[] = []; + const duplicates: string[] = []; + const seen = createMap(); + forEach(expectedKeys, expectedKey => { + if (seen.has(expectedKey)) { + duplicates.push(expectedKey); + return; + } + seen.set(expectedKey, true); + if (!map.has(expectedKey)) { + notInActual.push(expectedKey); + } + }); + const inActualNotExpected: string[] = []; + map.forEach((_value, key) => { + if (!seen.has(key)) { + inActualNotExpected.push(key); + } + seen.set(key, true); + }); + return `\n\nNotInActual: ${notInActual}\nDuplicates: ${duplicates}\nInActualButNotInExpected: ${inActualNotExpected}`; + } + + function checkMapKeys(caption: string, map: Map, expectedKeys: ReadonlyArray) { + assert.equal(map.size, expectedKeys.length, `${caption}: incorrect size of map: Actual keys: ${arrayFrom(map.keys())} Expected: ${expectedKeys}${getDiffInKeys(map, expectedKeys)}`); + for (const name of expectedKeys) { + assert.isTrue(map.has(name), `${caption} is expected to contain ${name}, actual keys: ${arrayFrom(map.keys())}`); + } + } + + export function checkFileNames(caption: string, actualFileNames: ReadonlyArray, expectedFileNames: string[]) { + assert.equal(actualFileNames.length, expectedFileNames.length, `${caption}: incorrect actual number of files, expected ${expectedFileNames}, got ${actualFileNames}`); + for (const f of expectedFileNames) { + assert.isTrue(contains(actualFileNames, f), `${caption}: expected to find ${f} in ${actualFileNames}`); + } + } + + export function checkWatchedFiles(host: TestServerHost, expectedFiles: string[]) { + checkMapKeys("watchedFiles", host.watchedFiles, expectedFiles); + } + + export function checkWatchedDirectories(host: TestServerHost, expectedDirectories: string[], recursive = false) { + checkMapKeys(`watchedDirectories${recursive ? " recursive" : ""}`, recursive ? host.watchedDirectoriesRecursive : host.watchedDirectories, expectedDirectories); + } + + export function checkOutputContains(host: TestServerHost, expected: ReadonlyArray) { + const mapExpected = arrayToSet(expected); + const mapSeen = createMap(); + for (const f of host.getOutput()) { + assert.isUndefined(mapSeen.get(f), `Already found ${f} in ${JSON.stringify(host.getOutput())}`); + if (mapExpected.has(f)) { + mapExpected.delete(f); + mapSeen.set(f, true); + } + } + assert.equal(mapExpected.size, 0, `Output has missing ${JSON.stringify(flatMapIter(mapExpected.keys(), key => key))} in ${JSON.stringify(host.getOutput())}`); + } + + export function checkOutputDoesNotContain(host: TestServerHost, expectedToBeAbsent: string[] | ReadonlyArray) { + const mapExpectedToBeAbsent = arrayToSet(expectedToBeAbsent); + for (const f of host.getOutput()) { + assert.isFalse(mapExpectedToBeAbsent.has(f), `Contains ${f} in ${JSON.stringify(host.getOutput())}`); + } + } + + class Callbacks { + private map: TimeOutCallback[] = []; + private nextId = 1; + + register(cb: (...args: any[]) => void, args: any[]) { + const timeoutId = this.nextId; + this.nextId++; + this.map[timeoutId] = cb.bind(/*this*/ undefined, ...args); + return timeoutId; + } + + unregister(id: any) { + if (typeof id === "number") { + delete this.map[id]; + } + } + + count() { + let n = 0; + for (const _ in this.map) { + n++; + } + return n; + } + + invoke() { + // Note: invoking a callback may result in new callbacks been queued, + // so do not clear the entire callback list regardless. Only remove the + // ones we have invoked. + for (const key in this.map) { + this.map[key](); + delete this.map[key]; + } + } + } + + type TimeOutCallback = () => any; + + export interface TestFileWatcher { + cb: FileWatcherCallback; + fileName: string; + } + + export interface TestDirectoryWatcher { + cb: DirectoryWatcherCallback; + directoryName: string; + } + + export class TestServerHost implements server.ServerHost { + args: string[] = []; + + private readonly output: string[] = []; + + private fs: Map = createMap(); + private getCanonicalFileName: (s: string) => string; + private toPath: (f: string) => Path; + private timeoutCallbacks = new Callbacks(); + private immediateCallbacks = new Callbacks(); + + readonly watchedDirectories = createMultiMap(); + readonly watchedDirectoriesRecursive = createMultiMap(); + readonly watchedFiles = createMultiMap(); + + constructor(public withSafeList: boolean, public useCaseSensitiveFileNames: boolean, private executingFilePath: string, private currentDirectory: string, fileOrFolderList: ReadonlyArray, public readonly newLine = "\n") { + this.getCanonicalFileName = createGetCanonicalFileName(useCaseSensitiveFileNames); + this.toPath = s => toPath(s, currentDirectory, this.getCanonicalFileName); + + this.reloadFS(fileOrFolderList); + } + + toNormalizedAbsolutePath(s: string) { + return getNormalizedAbsolutePath(s, this.currentDirectory); + } + + toFullPath(s: string) { + return this.toPath(this.toNormalizedAbsolutePath(s)); + } + + reloadFS(fileOrFolderList: ReadonlyArray) { + const mapNewLeaves = createMap(); + const isNewFs = this.fs.size === 0; + // always inject safelist file in the list of files + for (const fileOrDirectory of fileOrFolderList.concat(this.withSafeList ? safeList : [])) { + const path = this.toFullPath(fileOrDirectory.path); + mapNewLeaves.set(path, true); + // If its a change + const currentEntry = this.fs.get(path); + if (currentEntry) { + if (isFile(currentEntry)) { + if (isString(fileOrDirectory.content)) { + // Update file + if (currentEntry.content !== fileOrDirectory.content) { + currentEntry.content = fileOrDirectory.content; + this.invokeFileWatcher(currentEntry.fullPath, FileWatcherEventKind.Changed); + } + } + else { + // TODO: Changing from file => folder + } + } + else { + // Folder + if (isString(fileOrDirectory.content)) { + // TODO: Changing from folder => file + } + else { + // Folder update: Nothing to do. + } + } + } + else { + this.ensureFileOrFolder(fileOrDirectory); + } + } + + if (!isNewFs) { + this.fs.forEach((fileOrDirectory, path) => { + // If this entry is not from the new file or folder + if (!mapNewLeaves.get(path)) { + // Leaf entries that arent in new list => remove these + if (isFile(fileOrDirectory) || isFolder(fileOrDirectory) && fileOrDirectory.entries.length === 0) { + this.removeFileOrFolder(fileOrDirectory, folder => !mapNewLeaves.get(folder.path)); + } + } + }); + } + } + + ensureFileOrFolder(fileOrDirectory: FileOrFolder) { + if (isString(fileOrDirectory.content)) { + const file = this.toFile(fileOrDirectory); + Debug.assert(!this.fs.get(file.path)); + const baseFolder = this.ensureFolder(getDirectoryPath(file.fullPath)); + this.addFileOrFolderInFolder(baseFolder, file); + } + else { + const fullPath = getNormalizedAbsolutePath(fileOrDirectory.path, this.currentDirectory); + this.ensureFolder(fullPath); + } + } + + private ensureFolder(fullPath: string): Folder { + const path = this.toPath(fullPath); + let folder = this.fs.get(path) as Folder; + if (!folder) { + folder = this.toFolder(fullPath); + const baseFullPath = getDirectoryPath(fullPath); + if (fullPath !== baseFullPath) { + // Add folder in the base folder + const baseFolder = this.ensureFolder(baseFullPath); + this.addFileOrFolderInFolder(baseFolder, folder); + } + else { + // root folder + Debug.assert(this.fs.size === 0); + this.fs.set(path, folder); + } + } + Debug.assert(isFolder(folder)); + return folder; + } + + private addFileOrFolderInFolder(folder: Folder, fileOrDirectory: File | Folder) { + folder.entries.push(fileOrDirectory); + this.fs.set(fileOrDirectory.path, fileOrDirectory); + + if (isFile(fileOrDirectory)) { + this.invokeFileWatcher(fileOrDirectory.fullPath, FileWatcherEventKind.Created); + } + this.invokeDirectoryWatcher(folder.fullPath, fileOrDirectory.fullPath); + } + + private removeFileOrFolder(fileOrDirectory: File | Folder, isRemovableLeafFolder: (folder: Folder) => boolean) { + const basePath = getDirectoryPath(fileOrDirectory.path); + const baseFolder = this.fs.get(basePath) as Folder; + if (basePath !== fileOrDirectory.path) { + Debug.assert(!!baseFolder); + filterMutate(baseFolder.entries, entry => entry !== fileOrDirectory); + } + this.fs.delete(fileOrDirectory.path); + + if (isFile(fileOrDirectory)) { + this.invokeFileWatcher(fileOrDirectory.fullPath, FileWatcherEventKind.Deleted); + } + else { + Debug.assert(fileOrDirectory.entries.length === 0); + const relativePath = this.getRelativePathToDirectory(fileOrDirectory.fullPath, fileOrDirectory.fullPath); + // Invoke directory and recursive directory watcher for the folder + // Here we arent invoking recursive directory watchers for the base folders + // since that is something we would want to do for both file as well as folder we are deleting + invokeWatcherCallbacks(this.watchedDirectories.get(fileOrDirectory.path), cb => this.directoryCallback(cb, relativePath)); + invokeWatcherCallbacks(this.watchedDirectoriesRecursive.get(fileOrDirectory.path), cb => this.directoryCallback(cb, relativePath)); + } + + if (basePath !== fileOrDirectory.path) { + if (baseFolder.entries.length === 0 && isRemovableLeafFolder(baseFolder)) { + this.removeFileOrFolder(baseFolder, isRemovableLeafFolder); + } + else { + this.invokeRecursiveDirectoryWatcher(baseFolder.fullPath, fileOrDirectory.fullPath); + } + } + } + + private invokeFileWatcher(fileFullPath: string, eventKind: FileWatcherEventKind) { + const callbacks = this.watchedFiles.get(this.toPath(fileFullPath)); + invokeWatcherCallbacks(callbacks, ({ cb, fileName }) => cb(fileName, eventKind)); + } + + private getRelativePathToDirectory(directoryFullPath: string, fileFullPath: string) { + return getRelativePathToDirectoryOrUrl(directoryFullPath, fileFullPath, this.currentDirectory, this.getCanonicalFileName, /*isAbsolutePathAnUrl*/ false); + } + + /** + * This will call the directory watcher for the folderFullPath and recursive directory watchers for this and base folders + */ + private invokeDirectoryWatcher(folderFullPath: string, fileName: string) { + const relativePath = this.getRelativePathToDirectory(folderFullPath, fileName); + invokeWatcherCallbacks(this.watchedDirectories.get(this.toPath(folderFullPath)), cb => this.directoryCallback(cb, relativePath)); + this.invokeRecursiveDirectoryWatcher(folderFullPath, fileName); + } + + private directoryCallback({ cb, directoryName }: TestDirectoryWatcher, relativePath: string) { + cb(combinePaths(directoryName, relativePath)); + } + + /** + * This will call the recursive directory watcher for this directory as well as all the base directories + */ + private invokeRecursiveDirectoryWatcher(fullPath: string, fileName: string) { + const relativePath = this.getRelativePathToDirectory(fullPath, fileName); + invokeWatcherCallbacks(this.watchedDirectoriesRecursive.get(this.toPath(fullPath)), cb => this.directoryCallback(cb, relativePath)); + const basePath = getDirectoryPath(fullPath); + if (this.getCanonicalFileName(fullPath) !== this.getCanonicalFileName(basePath)) { + this.invokeRecursiveDirectoryWatcher(basePath, fileName); + } + } + + private toFile(fileOrDirectory: FileOrFolder): File { + const fullPath = getNormalizedAbsolutePath(fileOrDirectory.path, this.currentDirectory); + return { + path: this.toPath(fullPath), + content: fileOrDirectory.content, + fullPath, + fileSize: fileOrDirectory.fileSize + }; + } + + private toFolder(path: string): Folder { + const fullPath = getNormalizedAbsolutePath(path, this.currentDirectory); + return { + path: this.toPath(fullPath), + entries: [], + fullPath + }; + } + + fileExists(s: string) { + const path = this.toFullPath(s); + return isFile(this.fs.get(path)); + } + + readFile(s: string) { + const fsEntry = this.fs.get(this.toFullPath(s)); + return isFile(fsEntry) ? fsEntry.content : undefined; + } + + getFileSize(s: string) { + const path = this.toFullPath(s); + const entry = this.fs.get(path); + if (isFile(entry)) { + return entry.fileSize ? entry.fileSize : entry.content.length; + } + return undefined; + } + + directoryExists(s: string) { + const path = this.toFullPath(s); + return isFolder(this.fs.get(path)); + } + + getDirectories(s: string) { + const path = this.toFullPath(s); + const folder = this.fs.get(path); + if (isFolder(folder)) { + return mapDefined(folder.entries, entry => isFolder(entry) ? getBaseFileName(entry.fullPath) : undefined); + } + Debug.fail(folder ? "getDirectories called on file" : "getDirectories called on missing folder"); + return []; + } + + readDirectory(path: string, extensions?: ReadonlyArray, exclude?: ReadonlyArray, include?: ReadonlyArray, depth?: number): string[] { + return ts.matchFiles(this.toNormalizedAbsolutePath(path), extensions, exclude, include, this.useCaseSensitiveFileNames, this.getCurrentDirectory(), depth, (dir) => { + const directories: string[] = []; + const files: string[] = []; + const dirEntry = this.fs.get(this.toPath(dir)); + if (isFolder(dirEntry)) { + dirEntry.entries.forEach((entry) => { + if (isFolder(entry)) { + directories.push(getBaseFileName(entry.fullPath)); + } + else if (isFile(entry)) { + files.push(getBaseFileName(entry.fullPath)); + } + else { + Debug.fail("Unknown entry"); + } + }); + } + return { directories, files }; + }); + } + + watchDirectory(directoryName: string, cb: DirectoryWatcherCallback, recursive: boolean): FileWatcher { + const path = this.toFullPath(directoryName); + const map = recursive ? this.watchedDirectoriesRecursive : this.watchedDirectories; + const callback: TestDirectoryWatcher = { + cb, + directoryName + }; + map.add(path, callback); + return { + close: () => map.remove(path, callback) + }; + } + + createHash(s: string): string { + return Harness.mockHash(s); + } + + watchFile(fileName: string, cb: FileWatcherCallback) { + const path = this.toFullPath(fileName); + const callback: TestFileWatcher = { fileName, cb }; + this.watchedFiles.add(path, callback); + return { close: () => this.watchedFiles.remove(path, callback) }; + } + + // TOOD: record and invoke callbacks to simulate timer events + setTimeout(callback: TimeOutCallback, _time: number, ...args: any[]) { + return this.timeoutCallbacks.register(callback, args); + } + + clearTimeout(timeoutId: any): void { + this.timeoutCallbacks.unregister(timeoutId); + } + + checkTimeoutQueueLengthAndRun(expected: number) { + this.checkTimeoutQueueLength(expected); + this.runQueuedTimeoutCallbacks(); + } + + checkTimeoutQueueLength(expected: number) { + const callbacksCount = this.timeoutCallbacks.count(); + assert.equal(callbacksCount, expected, `expected ${expected} timeout callbacks queued but found ${callbacksCount}.`); + } + + runQueuedTimeoutCallbacks() { + try { + this.timeoutCallbacks.invoke(); + } + catch (e) { + if (e.message === this.existMessage) { + return; + } + throw e; + } + } + + runQueuedImmediateCallbacks() { + this.immediateCallbacks.invoke(); + } + + setImmediate(callback: TimeOutCallback, _time: number, ...args: any[]) { + return this.immediateCallbacks.register(callback, args); + } + + clearImmediate(timeoutId: any): void { + this.immediateCallbacks.unregister(timeoutId); + } + + createDirectory(directoryName: string): void { + const folder = this.toFolder(directoryName); + + // base folder has to be present + const base = getDirectoryPath(folder.fullPath); + const baseFolder = this.fs.get(base) as Folder; + Debug.assert(isFolder(baseFolder)); + + Debug.assert(!this.fs.get(folder.path)); + this.addFileOrFolderInFolder(baseFolder, folder); + } + + writeFile(path: string, content: string): void { + const file = this.toFile({ path, content }); + + // base folder has to be present + const base = getDirectoryPath(file.fullPath); + const folder = this.fs.get(base) as Folder; + Debug.assert(isFolder(folder)); + + this.addFileOrFolderInFolder(folder, file); + } + + write(message: string) { + this.output.push(message); + } + + getOutput(): ReadonlyArray { + return this.output; + } + + clearOutput() { + clear(this.output); + } + + readonly existMessage = "System Exit"; + exitCode: number; + readonly resolvePath = (s: string) => s; + readonly getExecutingFilePath = () => this.executingFilePath; + readonly getCurrentDirectory = () => this.currentDirectory; + exit(exitCode?: number) { + this.exitCode = exitCode; + throw new Error(this.existMessage); + } + readonly getEnvironmentVariable = notImplemented; + } +} diff --git a/src/server/builder.ts b/src/server/builder.ts deleted file mode 100644 index 5cf65611fb35d..0000000000000 --- a/src/server/builder.ts +++ /dev/null @@ -1,359 +0,0 @@ -/// -/// -/// - -namespace ts.server { - - export function shouldEmitFile(scriptInfo: ScriptInfo) { - return !scriptInfo.hasMixedContent && !scriptInfo.isDynamic; - } - - /** - * An abstract file info that maintains a shape signature. - */ - export class BuilderFileInfo { - - private lastCheckedShapeSignature: string; - - constructor(public readonly scriptInfo: ScriptInfo, public readonly project: Project) { - } - - public isExternalModuleOrHasOnlyAmbientExternalModules() { - const sourceFile = this.getSourceFile(); - return isExternalModule(sourceFile) || this.containsOnlyAmbientModules(sourceFile); - } - - /** - * For script files that contains only ambient external modules, although they are not actually external module files, - * they can only be consumed via importing elements from them. Regular script files cannot consume them. Therefore, - * there are no point to rebuild all script files if these special files have changed. However, if any statement - * in the file is not ambient external module, we treat it as a regular script file. - */ - private containsOnlyAmbientModules(sourceFile: SourceFile) { - for (const statement of sourceFile.statements) { - if (statement.kind !== SyntaxKind.ModuleDeclaration || (statement).name.kind !== SyntaxKind.StringLiteral) { - return false; - } - } - return true; - } - - private computeHash(text: string): string { - return this.project.projectService.host.createHash(text); - } - - private getSourceFile(): SourceFile { - return this.project.getSourceFile(this.scriptInfo.path); - } - - /** - * @return {boolean} indicates if the shape signature has changed since last update. - */ - public updateShapeSignature() { - const sourceFile = this.getSourceFile(); - if (!sourceFile) { - return true; - } - - const lastSignature = this.lastCheckedShapeSignature; - if (sourceFile.isDeclarationFile) { - this.lastCheckedShapeSignature = this.computeHash(sourceFile.text); - } - else { - const emitOutput = this.project.getFileEmitOutput(this.scriptInfo, /*emitOnlyDtsFiles*/ true); - if (emitOutput.outputFiles && emitOutput.outputFiles.length > 0) { - this.lastCheckedShapeSignature = this.computeHash(emitOutput.outputFiles[0].text); - } - } - return !lastSignature || this.lastCheckedShapeSignature !== lastSignature; - } - } - - export interface Builder { - readonly project: Project; - getFilesAffectedBy(scriptInfo: ScriptInfo): string[]; - onProjectUpdateGraph(): void; - emitFile(scriptInfo: ScriptInfo, writeFile: (path: string, data: string, writeByteOrderMark?: boolean) => void): boolean; - clear(): void; - } - - abstract class AbstractBuilder implements Builder { - - /** - * stores set of files from the project. - * NOTE: this field is created on demand and should not be accessed directly. - * Use 'getFileInfos' instead. - */ - private fileInfos_doNotAccessDirectly: Map; - - constructor(public readonly project: Project, private ctor: { new (scriptInfo: ScriptInfo, project: Project): T }) { - } - - private getFileInfos() { - return this.fileInfos_doNotAccessDirectly || (this.fileInfos_doNotAccessDirectly = createMap()); - } - - protected hasFileInfos() { - return !!this.fileInfos_doNotAccessDirectly; - } - - public clear() { - // drop the existing list - it will be re-created as necessary - this.fileInfos_doNotAccessDirectly = undefined; - } - - protected getFileInfo(path: Path): T { - return this.getFileInfos().get(path); - } - - protected getOrCreateFileInfo(path: Path): T { - let fileInfo = this.getFileInfo(path); - if (!fileInfo) { - const scriptInfo = this.project.getScriptInfo(path); - fileInfo = new this.ctor(scriptInfo, this.project); - this.setFileInfo(path, fileInfo); - } - return fileInfo; - } - - protected getFileInfoPaths(): Path[] { - return arrayFrom(this.getFileInfos().keys() as Iterator); - } - - protected setFileInfo(path: Path, info: T) { - this.getFileInfos().set(path, info); - } - - protected removeFileInfo(path: Path) { - this.getFileInfos().delete(path); - } - - protected forEachFileInfo(action: (fileInfo: T) => any) { - this.getFileInfos().forEach(action); - } - - abstract getFilesAffectedBy(scriptInfo: ScriptInfo): string[]; - abstract onProjectUpdateGraph(): void; - protected abstract ensureFileInfoIfInProject(scriptInfo: ScriptInfo): void; - - /** - * @returns {boolean} whether the emit was conducted or not - */ - emitFile(scriptInfo: ScriptInfo, writeFile: (path: string, data: string, writeByteOrderMark?: boolean) => void): boolean { - this.ensureFileInfoIfInProject(scriptInfo); - const fileInfo = this.getFileInfo(scriptInfo.path); - if (!fileInfo) { - return false; - } - - const { emitSkipped, outputFiles } = this.project.getFileEmitOutput(fileInfo.scriptInfo, /*emitOnlyDtsFiles*/ false); - if (!emitSkipped) { - const projectRootPath = this.project.getProjectRootPath(); - for (const outputFile of outputFiles) { - const outputFileAbsoluteFileName = getNormalizedAbsolutePath(outputFile.name, projectRootPath ? projectRootPath : getDirectoryPath(scriptInfo.fileName)); - writeFile(outputFileAbsoluteFileName, outputFile.text, outputFile.writeByteOrderMark); - } - } - return !emitSkipped; - } - } - - class NonModuleBuilder extends AbstractBuilder { - - constructor(public readonly project: Project) { - super(project, BuilderFileInfo); - } - - protected ensureFileInfoIfInProject(scriptInfo: ScriptInfo) { - if (this.project.containsScriptInfo(scriptInfo)) { - this.getOrCreateFileInfo(scriptInfo.path); - } - } - - onProjectUpdateGraph() { - if (this.hasFileInfos()) { - this.forEachFileInfo(fileInfo => { - if (!this.project.containsScriptInfo(fileInfo.scriptInfo)) { - // This file was deleted from this project - this.removeFileInfo(fileInfo.scriptInfo.path); - } - }); - } - } - - /** - * Note: didn't use path as parameter because the returned file names will be directly - * consumed by the API user, which will use it to interact with file systems. Path - * should only be used internally, because the case sensitivity is not trustable. - */ - getFilesAffectedBy(scriptInfo: ScriptInfo): string[] { - const info = this.getOrCreateFileInfo(scriptInfo.path); - const singleFileResult = scriptInfo.hasMixedContent || scriptInfo.isDynamic ? [] : [scriptInfo.fileName]; - if (info.updateShapeSignature()) { - const options = this.project.getCompilerOptions(); - // If `--out` or `--outFile` is specified, any new emit will result in re-emitting the entire project, - // so returning the file itself is good enough. - if (options && (options.out || options.outFile)) { - return singleFileResult; - } - return this.project.getAllEmittableFiles(); - } - return singleFileResult; - } - } - - class ModuleBuilderFileInfo extends BuilderFileInfo { - references = createSortedArray(); - readonly referencedBy = createSortedArray(); - scriptVersionForReferences: string; - - static compareFileInfos(lf: ModuleBuilderFileInfo, rf: ModuleBuilderFileInfo): Comparison { - return compareStrings(lf.scriptInfo.fileName, rf.scriptInfo.fileName); - } - - addReferencedBy(fileInfo: ModuleBuilderFileInfo): void { - insertSorted(this.referencedBy, fileInfo, ModuleBuilderFileInfo.compareFileInfos); - } - - removeReferencedBy(fileInfo: ModuleBuilderFileInfo): void { - removeSorted(this.referencedBy, fileInfo, ModuleBuilderFileInfo.compareFileInfos); - } - - removeFileReferences() { - for (const reference of this.references) { - reference.removeReferencedBy(this); - } - clear(this.references); - } - } - - class ModuleBuilder extends AbstractBuilder { - - constructor(public readonly project: Project) { - super(project, ModuleBuilderFileInfo); - } - - private projectVersionForDependencyGraph: string; - - public clear() { - this.projectVersionForDependencyGraph = undefined; - super.clear(); - } - - private getReferencedFileInfos(fileInfo: ModuleBuilderFileInfo): SortedArray { - if (!fileInfo.isExternalModuleOrHasOnlyAmbientExternalModules()) { - return createSortedArray(); - } - - const referencedFilePaths = this.project.getReferencedFiles(fileInfo.scriptInfo.path); - return toSortedArray(referencedFilePaths.map(f => this.getOrCreateFileInfo(f)), ModuleBuilderFileInfo.compareFileInfos); - } - - protected ensureFileInfoIfInProject(_scriptInfo: ScriptInfo) { - this.ensureProjectDependencyGraphUpToDate(); - } - - onProjectUpdateGraph() { - // Update the graph only if we have computed graph earlier - if (this.hasFileInfos()) { - this.ensureProjectDependencyGraphUpToDate(); - } - } - - private ensureProjectDependencyGraphUpToDate() { - if (!this.projectVersionForDependencyGraph || this.project.getProjectVersion() !== this.projectVersionForDependencyGraph) { - const currentScriptInfos = this.project.getScriptInfos(); - for (const scriptInfo of currentScriptInfos) { - const fileInfo = this.getOrCreateFileInfo(scriptInfo.path); - this.updateFileReferences(fileInfo); - } - this.forEachFileInfo(fileInfo => { - if (!this.project.containsScriptInfo(fileInfo.scriptInfo)) { - // This file was deleted from this project - fileInfo.removeFileReferences(); - this.removeFileInfo(fileInfo.scriptInfo.path); - } - }); - this.projectVersionForDependencyGraph = this.project.getProjectVersion(); - } - } - - private updateFileReferences(fileInfo: ModuleBuilderFileInfo) { - // Only need to update if the content of the file changed. - if (fileInfo.scriptVersionForReferences === fileInfo.scriptInfo.getLatestVersion()) { - return; - } - - const newReferences = this.getReferencedFileInfos(fileInfo); - const oldReferences = fileInfo.references; - enumerateInsertsAndDeletes(newReferences, oldReferences, - /*inserted*/ newReference => newReference.addReferencedBy(fileInfo), - /*deleted*/ oldReference => { - // New reference is greater then current reference. That means - // the current reference doesn't exist anymore after parsing. So delete - // references. - oldReference.removeReferencedBy(fileInfo); - }, - /*compare*/ ModuleBuilderFileInfo.compareFileInfos); - - fileInfo.references = newReferences; - fileInfo.scriptVersionForReferences = fileInfo.scriptInfo.getLatestVersion(); - } - - getFilesAffectedBy(scriptInfo: ScriptInfo): string[] { - this.ensureProjectDependencyGraphUpToDate(); - - const singleFileResult = scriptInfo.hasMixedContent || scriptInfo.isDynamic ? [] : [scriptInfo.fileName]; - const fileInfo = this.getFileInfo(scriptInfo.path); - if (!fileInfo || !fileInfo.updateShapeSignature()) { - return singleFileResult; - } - - if (!fileInfo.isExternalModuleOrHasOnlyAmbientExternalModules()) { - return this.project.getAllEmittableFiles(); - } - - const options = this.project.getCompilerOptions(); - if (options && (options.isolatedModules || options.out || options.outFile)) { - return singleFileResult; - } - - // Now we need to if each file in the referencedBy list has a shape change as well. - // Because if so, its own referencedBy files need to be saved as well to make the - // emitting result consistent with files on disk. - - // Use slice to clone the array to avoid manipulating in place - const queue = fileInfo.referencedBy.slice(0); - const fileNameSet = createMap(); - fileNameSet.set(scriptInfo.fileName, scriptInfo); - while (queue.length > 0) { - const processingFileInfo = queue.pop(); - if (processingFileInfo.updateShapeSignature() && processingFileInfo.referencedBy.length > 0) { - for (const potentialFileInfo of processingFileInfo.referencedBy) { - if (!fileNameSet.has(potentialFileInfo.scriptInfo.fileName)) { - queue.push(potentialFileInfo); - } - } - } - fileNameSet.set(processingFileInfo.scriptInfo.fileName, processingFileInfo.scriptInfo); - } - const result: string[] = []; - fileNameSet.forEach((scriptInfo, fileName) => { - if (shouldEmitFile(scriptInfo)) { - result.push(fileName); - } - }); - return result; - } - } - - export function createBuilder(project: Project): Builder { - const moduleKind = project.getCompilerOptions().module; - switch (moduleKind) { - case ModuleKind.None: - return new NonModuleBuilder(project); - default: - return new ModuleBuilder(project); - } - } -} diff --git a/src/server/client.ts b/src/server/client.ts index 0ffac42dae4d4..d08d1e13d2e66 100644 --- a/src/server/client.ts +++ b/src/server/client.ts @@ -342,7 +342,7 @@ namespace ts.server { convertDiagnostic(entry: protocol.DiagnosticWithLinePosition, _fileName: string): Diagnostic { let category: DiagnosticCategory; for (const id in DiagnosticCategory) { - if (typeof id === "string" && entry.category === id.toLowerCase()) { + if (isString(id) && entry.category === id.toLowerCase()) { category = (DiagnosticCategory)[id]; } } diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index d86f45a9ad997..25a285929bef2 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -3,21 +3,20 @@ /// /// /// -/// /// /// namespace ts.server { export const maxProgramSizeForNonTsFiles = 20 * 1024 * 1024; - export const ContextEvent = "context"; + export const ProjectsUpdatedInBackgroundEvent = "projectsUpdatedInBackground"; export const ConfigFileDiagEvent = "configFileDiag"; export const ProjectLanguageServiceStateEvent = "projectLanguageServiceState"; export const ProjectInfoTelemetryEvent = "projectInfo"; - export interface ContextEvent { - eventName: typeof ContextEvent; - data: { project: Project; fileName: NormalizedPath }; + export interface ProjectsUpdatedInBackgroundEvent { + eventName: typeof ProjectsUpdatedInBackgroundEvent; + data: { openFiles: string[]; }; } export interface ConfigFileDiagEvent { @@ -77,7 +76,7 @@ namespace ts.server { readonly dts: number; } - export type ProjectServiceEvent = ContextEvent | ConfigFileDiagEvent | ProjectLanguageServiceStateEvent | ProjectInfoTelemetryEvent; + export type ProjectServiceEvent = ProjectsUpdatedInBackgroundEvent | ConfigFileDiagEvent | ProjectLanguageServiceStateEvent | ProjectInfoTelemetryEvent; export interface ProjectServiceEventHandler { (event: ProjectServiceEvent): void; @@ -164,7 +163,7 @@ namespace ts.server { }; export function convertFormatOptions(protocolOptions: protocol.FormatCodeSettings): FormatCodeSettings { - if (typeof protocolOptions.indentStyle === "string") { + if (isString(protocolOptions.indentStyle)) { protocolOptions.indentStyle = indentStyle.get(protocolOptions.indentStyle.toLowerCase()); Debug.assert(protocolOptions.indentStyle !== undefined); } @@ -174,7 +173,7 @@ namespace ts.server { export function convertCompilerOptions(protocolOptions: protocol.ExternalProjectCompilerOptions): CompilerOptions & protocol.CompileOnSaveMixin { compilerOptionConverters.forEach((mappedValues, id) => { const propertyValue = protocolOptions[id]; - if (typeof propertyValue === "string") { + if (isString(propertyValue)) { protocolOptions[id] = mappedValues.get(propertyValue.toLowerCase()); } }); @@ -182,9 +181,7 @@ namespace ts.server { } export function tryConvertScriptKindName(scriptKindName: protocol.ScriptKindName | ScriptKind): ScriptKind { - return typeof scriptKindName === "string" - ? convertScriptKindName(scriptKindName) - : scriptKindName; + return isString(scriptKindName) ? convertScriptKindName(scriptKindName) : scriptKindName; } export function convertScriptKindName(scriptKindName: protocol.ScriptKindName) { @@ -216,20 +213,6 @@ namespace ts.server { extraFileExtensions?: JsFileExtensionInfo[]; } - interface ConfigFileConversionResult { - success: boolean; - configFileErrors?: Diagnostic[]; - - projectOptions?: ProjectOptions; - } - - interface OpenConfigFileResult { - success: boolean; - errors?: ReadonlyArray; - - project?: ConfiguredProject; - } - export interface OpenConfiguredProjectResult { configFileName?: NormalizedPath; configFileErrors?: ReadonlyArray; @@ -239,21 +222,18 @@ namespace ts.server { getFileName(f: T): string; getScriptKind(f: T): ScriptKind; hasMixedContent(f: T, extraFileExtensions: JsFileExtensionInfo[]): boolean; - isDynamicFile(f: T): boolean; } const fileNamePropertyReader: FilePropertyReader = { getFileName: x => x, getScriptKind: _ => undefined, hasMixedContent: (fileName, extraFileExtensions) => some(extraFileExtensions, ext => ext.isMixedContent && fileExtensionIs(fileName, ext.extension)), - isDynamicFile: x => x[0] === "^", }; const externalFilePropertyReader: FilePropertyReader = { getFileName: x => x.fileName, getScriptKind: x => tryConvertScriptKindName(x.scriptKind), hasMixedContent: x => x.hasMixedContent, - isDynamicFile: x => x.fileName[0] === "^", }; function findProjectByName(projectName: string, projects: T[]): T { @@ -264,66 +244,50 @@ namespace ts.server { } } - function createFileNotFoundDiagnostic(fileName: string) { - return createCompilerDiagnostic(Diagnostics.File_0_not_found, fileName); + /* @internal */ + export const enum WatchType { + ConfigFilePath = "Config file for the program", + MissingFilePath = "Missing file from program", + WildcardDirectories = "Wild card directory", + ClosedScriptInfo = "Closed Script info", + ConfigFileForInferredRoot = "Config file for the inferred project root", + FailedLookupLocation = "Directory of Failed lookup locations in module resolution", + TypeRoots = "Type root directory" } - /** - * TODO: enforce invariants: - * - script info can be never migrate to state - root file in inferred project, this is only a starting point - * - if script info has more that one containing projects - it is not a root file in inferred project because: - * - references in inferred project supercede the root part - * - root/reference in non-inferred project beats root in inferred project - */ - function isRootFileInInferredProject(info: ScriptInfo): boolean { - if (info.containingProjects.length === 0) { - return false; - } - return info.containingProjects[0].projectKind === ProjectKind.Inferred && info.containingProjects[0].isRoot(info); + const enum ConfigFileWatcherStatus { + ReloadingFiles = "Reloading configured projects for files", + ReloadingInferredRootFiles = "Reloading configured projects for only inferred root files", + UpdatedCallback = "Updated the callback", + OpenFilesImpactedByConfigFileAdd = "File added to open files impacted by this config file", + OpenFilesImpactedByConfigFileRemove = "File removed from open files impacted by this config file", + RootOfInferredProjectTrue = "Open file was set as Inferred root", + RootOfInferredProjectFalse = "Open file was set as not inferred root", } - class DirectoryWatchers { + interface ConfigFileExistenceInfo { /** - * a path to directory watcher map that detects added tsconfig files + * Cached value of existence of config file + * It is true if there is configured project open for this file. + * It can be either true or false if this is the config file that is being watched by inferred project + * to decide when to update the structure so that it knows about updating the project for its files + * (config file may include the inferred project files after the change and hence may be wont need to be in inferred project) */ - private readonly directoryWatchersForTsconfig: Map = createMap(); + exists: boolean; /** - * count of how many projects are using the directory watcher. - * If the number becomes 0 for a watcher, then we should close it. + * openFilesImpactedByConfigFiles is a map of open files that would be impacted by this config file + * because these are the paths being looked up for their default configured project location + * The value in the map is true if the open file is root of the inferred project + * It is false when the open file that would still be impacted by existance of + * this config file but it is not the root of inferred project */ - private readonly directoryWatchersRefCount: Map = createMap(); - - constructor(private readonly projectService: ProjectService) { - } - - stopWatchingDirectory(directory: string) { - // if the ref count for this directory watcher drops to 0, it's time to close it - const refCount = this.directoryWatchersRefCount.get(directory) - 1; - this.directoryWatchersRefCount.set(directory, refCount); - if (refCount === 0) { - this.projectService.logger.info(`Close directory watcher for: ${directory}`); - this.directoryWatchersForTsconfig.get(directory).close(); - this.directoryWatchersForTsconfig.delete(directory); - } - } - - startWatchingContainingDirectoriesForFile(fileName: string, project: InferredProject, callback: (fileName: string) => void) { - let currentPath = getDirectoryPath(fileName); - let parentPath = getDirectoryPath(currentPath); - while (currentPath !== parentPath) { - if (!this.directoryWatchersForTsconfig.has(currentPath)) { - this.projectService.logger.info(`Add watcher for: ${currentPath}`); - this.directoryWatchersForTsconfig.set(currentPath, this.projectService.host.watchDirectory(currentPath, callback)); - this.directoryWatchersRefCount.set(currentPath, 1); - } - else { - this.directoryWatchersRefCount.set(currentPath, this.directoryWatchersRefCount.get(currentPath) + 1); - } - project.directoriesWatchedForTsconfig.push(currentPath); - currentPath = parentPath; - parentPath = getDirectoryPath(parentPath); - } - } + openFilesImpactedByConfigFile: Map; + /** + * The file watcher watching the config file because there is open script info that is root of + * inferred project and will be impacted by change in the status of the config file + * The watcher is present only when there is no open configured project for the config file + */ + configFileWatcherForRootOfInferredProject?: FileWatcher; } export interface ProjectServiceOptions { @@ -341,6 +305,10 @@ namespace ts.server { typesMapLocation?: string; } + type WatchFile = (host: ServerHost, file: string, cb: FileWatcherCallback, watchType: WatchType, project?: Project) => FileWatcher; + type WatchFilePath = (host: ServerHost, file: string, cb: FilePathWatcherCallback, path: Path, watchType: WatchType, project?: Project) => FileWatcher; + type WatchDirectory = (host: ServerHost, directory: string, cb: DirectoryWatcherCallback, flags: WatchDirectoryFlags, watchType: WatchType, project?: Project) => FileWatcher; + export class ProjectService { public readonly typingsCache: TypingsCache; @@ -367,7 +335,7 @@ namespace ts.server { /** * projects specified by a tsconfig.json file */ - readonly configuredProjects: ConfiguredProject[] = []; + readonly configuredProjects = createMap(); /** * list of open files */ @@ -375,19 +343,30 @@ namespace ts.server { private compilerOptionsForInferredProjects: CompilerOptions; private compilerOptionsForInferredProjectsPerProjectRoot = createMap(); + /** + * Project size for configured or external projects + */ private readonly projectToSizeMap: Map = createMap(); - private readonly directoryWatchers: DirectoryWatchers; + /** + * This is a map of config file paths existance that doesnt need query to disk + * - The entry can be present because there is inferred project that needs to watch addition of config file to directory + * In this case the exists could be true/false based on config file is present or not + * - Or it is present if we have configured project open with config file at that location + * In this case the exists property is always true + */ + private readonly configFileExistenceInfoCache = createMap(); private readonly throttledOperations: ThrottledOperations; private readonly hostConfiguration: HostConfiguration; private safelist: SafeList = defaultTypeSafeList; private changedFiles: ScriptInfo[]; + private pendingProjectUpdates = createMap(); + private pendingInferredProjectUpdate: boolean; + readonly currentDirectory: string; readonly toCanonicalFileName: (f: string) => string; - public lastDeletedFile: ScriptInfo; - public readonly host: ServerHost; public readonly logger: Logger; public readonly cancellationToken: HostCancellationToken; @@ -405,6 +384,13 @@ namespace ts.server { /** Tracks projects that we have already sent telemetry for. */ private readonly seenProjects = createMap(); + /*@internal*/ + readonly watchFile: WatchFile; + /*@internal*/ + readonly watchFilePath: WatchFilePath; + /*@internal*/ + readonly watchDirectory: WatchDirectory; + constructor(opts: ProjectServiceOptions) { this.host = opts.host; this.logger = opts.logger; @@ -421,9 +407,9 @@ namespace ts.server { Debug.assert(!!this.host.createHash, "'ServerHost.createHash' is required for ProjectService"); + this.currentDirectory = this.host.getCurrentDirectory(); this.toCanonicalFileName = createGetCanonicalFileName(this.host.useCaseSensitiveFileNames); - this.directoryWatchers = new DirectoryWatchers(this); - this.throttledOperations = new ThrottledOperations(this.host); + this.throttledOperations = new ThrottledOperations(this.host, this.logger); if (opts.typesMapLocation) { this.loadTypesMap(); @@ -439,7 +425,26 @@ namespace ts.server { extraFileExtensions: [] }; - this.documentRegistry = createDocumentRegistry(this.host.useCaseSensitiveFileNames, this.host.getCurrentDirectory()); + this.documentRegistry = createDocumentRegistry(this.host.useCaseSensitiveFileNames, this.currentDirectory); + if (this.logger.hasLevel(LogLevel.verbose)) { + this.watchFile = (host, file, cb, watchType, project) => ts.addFileWatcherWithLogging(host, file, cb, this.createWatcherLog(watchType, project)); + this.watchFilePath = (host, file, cb, path, watchType, project) => ts.addFilePathWatcherWithLogging(host, file, cb, path, this.createWatcherLog(watchType, project)); + this.watchDirectory = (host, dir, cb, flags, watchType, project) => ts.addDirectoryWatcherWithLogging(host, dir, cb, flags, this.createWatcherLog(watchType, project)); + } + else { + this.watchFile = ts.addFileWatcher; + this.watchFilePath = ts.addFilePathWatcher; + this.watchDirectory = ts.addDirectoryWatcher; + } + } + + private createWatcherLog(watchType: WatchType, project: Project | undefined): (s: string) => void { + const detailedInfo = ` Project: ${project ? project.getProjectName() : ""} WatchType: ${watchType}`; + return s => this.logger.info(s + detailedInfo); + } + + toPath(fileName: string) { + return toPath(fileName, this.currentDirectory, this.toCanonicalFileName); } /* @internal */ @@ -447,14 +452,17 @@ namespace ts.server { return this.changedFiles; } + /* @internal */ ensureInferredProjectsUpToDate_TestOnly() { - this.ensureInferredProjectsUpToDate(); + this.ensureProjectStructuresUptoDate(); } + /* @internal */ getCompilerOptionsForInferredProjects() { return this.compilerOptionsForInferredProjects; } + /* @internal */ onUpdateLanguageServiceStateForProject(project: Project, languageServiceEnabled: boolean) { if (!this.eventHandler) { return; @@ -500,7 +508,63 @@ namespace ts.server { this.typingsCache.deleteTypingsForProject(response.projectName); break; } - project.updateGraph(); + this.delayUpdateProjectGraphAndInferredProjectsRefresh(project); + } + + private delayInferredProjectsRefresh() { + this.pendingInferredProjectUpdate = true; + this.throttledOperations.schedule("*refreshInferredProjects*", /*delay*/ 250, () => { + if (this.pendingProjectUpdates.size !== 0) { + this.delayInferredProjectsRefresh(); + } + else { + if (this.pendingInferredProjectUpdate) { + this.pendingInferredProjectUpdate = false; + this.refreshInferredProjects(); + } + // Send the event to notify that there were background project updates + // send current list of open files + this.sendProjectsUpdatedInBackgroundEvent(); + } + }); + } + + private delayUpdateProjectGraph(project: Project) { + const projectName = project.getProjectName(); + this.pendingProjectUpdates.set(projectName, project); + this.throttledOperations.schedule(projectName, /*delay*/ 250, () => { + if (this.pendingProjectUpdates.delete(projectName)) { + project.updateGraph(); + } + }); + } + + private sendProjectsUpdatedInBackgroundEvent() { + if (!this.eventHandler) { + return; + } + + const event: ProjectsUpdatedInBackgroundEvent = { + eventName: ProjectsUpdatedInBackgroundEvent, + data: { + openFiles: this.openFiles.map(f => f.fileName) + } + }; + this.eventHandler(event); + } + + /* @internal */ + delayUpdateProjectGraphAndInferredProjectsRefresh(project: Project) { + project.markAsDirty(); + this.delayUpdateProjectGraph(project); + this.delayInferredProjectsRefresh(); + } + + private delayUpdateProjectGraphs(projects: Project[]) { + for (const project of projects) { + this.delayUpdateProjectGraph(project); + } + this.delayInferredProjectsRefresh(); } setCompilerOptionsForInferredProjects(projectCompilerOptions: protocol.ExternalProjectCompilerOptions, projectRootPath?: string): void { @@ -519,7 +583,7 @@ namespace ts.server { this.compilerOptionsForInferredProjects = compilerOptions; } - const updatedProjects: Project[] = []; + const projectsToUpdate: Project[] = []; for (const project of this.inferredProjects) { // Only update compiler options in the following cases: // - Inferred projects without a projectRootPath, if the new options do not apply to @@ -534,37 +598,55 @@ namespace ts.server { !project.projectRootPath || !this.compilerOptionsForInferredProjectsPerProjectRoot.has(project.projectRootPath)) { project.setCompilerOptions(compilerOptions); project.compileOnSaveEnabled = compilerOptions.compileOnSave; - updatedProjects.push(project); + project.markAsDirty(); + projectsToUpdate.push(project); } } - this.updateProjectGraphs(updatedProjects); - } - - stopWatchingDirectory(directory: string) { - this.directoryWatchers.stopWatchingDirectory(directory); + this.delayUpdateProjectGraphs(projectsToUpdate); } - findProject(projectName: string): Project { + findProject(projectName: string): Project | undefined { if (projectName === undefined) { return undefined; } if (isInferredProjectName(projectName)) { - this.ensureInferredProjectsUpToDate(); + this.ensureProjectStructuresUptoDate(); return findProjectByName(projectName, this.inferredProjects); } return this.findExternalProjectByProjectName(projectName) || this.findConfiguredProjectByProjectName(toNormalizedPath(projectName)); } - getDefaultProjectForFile(fileName: NormalizedPath, refreshInferredProjects: boolean) { - if (refreshInferredProjects) { - this.ensureInferredProjectsUpToDate(); + getDefaultProjectForFile(fileName: NormalizedPath, ensureProject: boolean) { + let scriptInfo = this.getScriptInfoForNormalizedPath(fileName); + if (ensureProject && !scriptInfo || scriptInfo.isOrphan()) { + this.ensureProjectStructuresUptoDate(); + scriptInfo = this.getScriptInfoForNormalizedPath(fileName); + if (!scriptInfo) { + return Errors.ThrowNoProject(); + } + return scriptInfo.getDefaultProject(); } - const scriptInfo = this.getScriptInfoForNormalizedPath(fileName); - return scriptInfo && scriptInfo.getDefaultProject(); + return scriptInfo && !scriptInfo.isOrphan() && scriptInfo.getDefaultProject(); + } + + getScriptInfoEnsuringProjectsUptoDate(uncheckedFileName: string) { + this.ensureProjectStructuresUptoDate(); + return this.getScriptInfo(uncheckedFileName); } - private ensureInferredProjectsUpToDate() { + /** + * Ensures the project structures are upto date + * This means, + * - if there are changedFiles (the files were updated but their containing project graph was not upto date), + * their project graph is updated + * - If there are pendingProjectUpdates (scheduled to be updated with delay so they can batch update the graph if there are several changes in short time span) + * their project graph is updated + * - If there were project graph updates and/or there was pending inferred project update and/or called forced the inferred project structure refresh + * Inferred projects are created/updated/deleted based on open files states + * @param forceInferredProjectsRefresh when true updates the inferred projects even if there is no pending work to update the files/project structures + */ + private ensureProjectStructuresUptoDate(forceInferredProjectsRefresh?: boolean) { if (this.changedFiles) { let projectsToUpdate: Project[]; if (this.changedFiles.length === 1) { @@ -574,11 +656,22 @@ namespace ts.server { else { projectsToUpdate = []; for (const f of this.changedFiles) { - projectsToUpdate = projectsToUpdate.concat(f.containingProjects); + addRange(projectsToUpdate, f.containingProjects); } } - this.updateProjectGraphs(projectsToUpdate); this.changedFiles = undefined; + this.updateProjectGraphs(projectsToUpdate); + } + + if (this.pendingProjectUpdates.size !== 0) { + const projectsToUpdate = arrayFrom(this.pendingProjectUpdates.values()); + this.pendingProjectUpdates.clear(); + this.updateProjectGraphs(projectsToUpdate); + } + + if (this.pendingInferredProjectUpdate || forceInferredProjectsRefresh) { + this.pendingInferredProjectUpdate = false; + this.refreshInferredProjects(); } } @@ -603,55 +696,44 @@ namespace ts.server { } private updateProjectGraphs(projects: Project[]) { - let shouldRefreshInferredProjects = false; for (const p of projects) { if (!p.updateGraph()) { - shouldRefreshInferredProjects = true; + this.pendingInferredProjectUpdate = true; } } - if (shouldRefreshInferredProjects) { - this.refreshInferredProjects(); - } } - private onSourceFileChanged(fileName: NormalizedPath) { + private onSourceFileChanged(fileName: NormalizedPath, eventKind: FileWatcherEventKind) { const info = this.getScriptInfoForNormalizedPath(fileName); if (!info) { - this.logger.info(`Error: got watch notification for unknown file: ${fileName}`); - return; + this.logger.msg(`Error: got watch notification for unknown file: ${fileName}`); } - - if (!this.host.fileExists(fileName)) { + else if (eventKind === FileWatcherEventKind.Deleted) { // File was deleted this.handleDeletedFile(info); } - else { - if (info && (!info.isScriptOpen())) { - if (info.containingProjects.length === 0) { - // Orphan script info, remove it as we can always reload it on next open - info.stopWatcher(); - this.filenameToScriptInfo.delete(info.path); - } - else { - // file has been changed which might affect the set of referenced files in projects that include - // this file and set of inferred projects - info.reloadFromFile(); - this.updateProjectGraphs(info.containingProjects); - } + else if (!info.isScriptOpen()) { + if (info.containingProjects.length === 0) { + // Orphan script info, remove it as we can always reload it on next open file request + this.stopWatchingScriptInfo(info); + this.filenameToScriptInfo.delete(info.path); + } + else { + // file has been changed which might affect the set of referenced files in projects that include + // this file and set of inferred projects + info.delayReloadNonMixedContentFile(); + this.delayUpdateProjectGraphs(info.containingProjects); } } } private handleDeletedFile(info: ScriptInfo) { - this.logger.info(`${info.fileName} deleted`); - - info.stopWatcher(); + this.stopWatchingScriptInfo(info); // TODO: handle isOpen = true case if (!info.isScriptOpen()) { this.filenameToScriptInfo.delete(info.path); - this.lastDeletedFile = info; // capture list of projects since detachAllProjects will wipe out original list const containingProjects = info.containingProjects.slice(); @@ -659,119 +741,104 @@ namespace ts.server { info.detachAllProjects(); // update projects to make sure that set of referenced files is correct - this.updateProjectGraphs(containingProjects); - this.lastDeletedFile = undefined; - - if (!this.eventHandler) { - return; - } - - for (const openFile of this.openFiles) { - const event: ContextEvent = { - eventName: ContextEvent, - data: { project: openFile.getDefaultProject(), fileName: openFile.fileName } - }; - this.eventHandler(event); - } + this.delayUpdateProjectGraphs(containingProjects); } - - this.printProjects(); - } - - private onTypeRootFileChanged(project: ConfiguredProject, fileName: string) { - this.logger.info(`Type root file ${fileName} changed`); - this.throttledOperations.schedule(project.getConfigFilePath() + " * type root", /*delay*/ 250, () => { - project.updateTypes(); - this.updateConfiguredProject(project); // TODO: Figure out why this is needed (should be redundant?) - this.refreshInferredProjects(); - }); } /** - * This is the callback function when a watched directory has added or removed source code files. - * @param project the project that associates with this directory watcher - * @param fileName the absolute file name that changed in watched directory + * This is to watch whenever files are added or removed to the wildcard directories */ - private onSourceFileInDirectoryChangedForConfiguredProject(project: ConfiguredProject, fileName: string) { - // If a change was made inside "folder/file", node will trigger the callback twice: - // one with the fileName being "folder/file", and the other one with "folder". - // We don't respond to the second one. - if (fileName && !isSupportedSourceFileName(fileName, project.getCompilerOptions(), this.hostConfiguration.extraFileExtensions)) { - return; - } - - this.logger.info(`Detected source file changes: ${fileName}`); - this.throttledOperations.schedule( - project.getConfigFilePath(), - /*delay*/250, - () => this.handleChangeInSourceFileForConfiguredProject(project, fileName)); - } - - private handleChangeInSourceFileForConfiguredProject(project: ConfiguredProject, triggerFile: string) { - const { projectOptions, configFileErrors } = this.convertConfigFileContentToProjectOptions(project.getConfigFilePath()); - this.reportConfigFileDiagnostics(project.getProjectName(), configFileErrors, triggerFile); - - const newRootFiles = projectOptions.files.map((f => this.getCanonicalFileName(f))); - const currentRootFiles = project.getRootFiles().map((f => this.getCanonicalFileName(f))); - - // We check if the project file list has changed. If so, we update the project. - if (!arrayIsEqualTo(currentRootFiles.sort(), newRootFiles.sort())) { - // For configured projects, the change is made outside the tsconfig file, and - // it is not likely to affect the project for other files opened by the client. We can - // just update the current project. + /*@internal*/ + watchWildcardDirectory(directory: Path, flags: WatchDirectoryFlags, project: ConfiguredProject) { + return this.watchDirectory( + this.host, + directory, + fileOrDirectory => { + const fileOrDirectoryPath = this.toPath(fileOrDirectory); + project.getCachedDirectoryStructureHost().addOrDeleteFileOrDirectory(fileOrDirectory, fileOrDirectoryPath); + const configFilename = project.getConfigFilePath(); + + // If the the added or created file or directory is not supported file name, ignore the file + // But when watched directory is added/removed, we need to reload the file list + if (fileOrDirectoryPath !== directory && !isSupportedSourceFileName(fileOrDirectory, project.getCompilationSettings(), this.hostConfiguration.extraFileExtensions)) { + this.logger.info(`Project: ${configFilename} Detected file add/remove of non supported extension: ${fileOrDirectory}`); + return; + } - this.logger.info("Updating configured project"); - this.updateConfiguredProject(project); + // Reload is pending, do the reload + if (!project.pendingReload) { + const configFileSpecs = project.configFileSpecs; + const result = getFileNamesFromConfigSpecs(configFileSpecs, getDirectoryPath(configFilename), project.getCompilationSettings(), project.getCachedDirectoryStructureHost(), this.hostConfiguration.extraFileExtensions); + project.updateErrorOnNoInputFiles(result.fileNames.length !== 0); + this.updateNonInferredProjectFiles(project, result.fileNames, fileNamePropertyReader); + this.delayUpdateProjectGraphAndInferredProjectsRefresh(project); + } + }, + flags, + WatchType.WildcardDirectories, + project + ); + } + + private onConfigChangedForConfiguredProject(project: ConfiguredProject, eventKind: FileWatcherEventKind) { + const configFileExistenceInfo = this.configFileExistenceInfoCache.get(project.canonicalConfigFilePath); + if (eventKind === FileWatcherEventKind.Deleted) { + // Update the cached status + // We arent updating or removing the cached config file presence info as that will be taken care of by + // setConfigFilePresenceByClosedConfigFile when the project is closed (depending on tracking open files) + configFileExistenceInfo.exists = false; + this.removeProject(project); - // Call refreshInferredProjects to clean up inferred projects we may have - // created for the new files - this.refreshInferredProjects(); + // Reload the configured projects for the open files in the map as they are affectected by this config file + // Since the configured project was deleted, we want to reload projects for all the open files including files + // that are not root of the inferred project + this.logConfigFileWatchUpdate(project.getConfigFilePath(), project.canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.ReloadingFiles); + this.delayReloadConfiguredProjectForFiles(configFileExistenceInfo, /*ignoreIfNotInferredProjectRoot*/ false); + } + else { + this.logConfigFileWatchUpdate(project.getConfigFilePath(), project.canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.ReloadingInferredRootFiles); + project.pendingReload = true; + this.delayUpdateProjectGraph(project); + // As we scheduled the update on configured project graph, + // we would need to schedule the project reload for only the root of inferred projects + this.delayReloadConfiguredProjectForFiles(configFileExistenceInfo, /*ignoreIfNotInferredProjectRoot*/ true); } - } - - private onConfigChangedForConfiguredProject(project: ConfiguredProject) { - const configFileName = project.getConfigFilePath(); - this.logger.info(`Config file changed: ${configFileName}`); - const configFileErrors = this.updateConfiguredProject(project); - this.reportConfigFileDiagnostics(configFileName, configFileErrors, /*triggerFile*/ configFileName); - this.refreshInferredProjects(); } /** - * This is the callback function when a watched directory has an added tsconfig file. + * This is the callback function for the config file add/remove/change at any location + * that matters to open script info but doesnt have configured project open + * for the config file */ - private onConfigFileAddedForInferredProject(fileName: string) { - // TODO: check directory separators - if (getBaseFileName(fileName) !== "tsconfig.json") { - this.logger.info(`${fileName} is not tsconfig.json`); - return; - } - - const { configFileErrors } = this.convertConfigFileContentToProjectOptions(fileName); - this.reportConfigFileDiagnostics(fileName, configFileErrors, fileName); - - this.logger.info(`Detected newly added tsconfig file: ${fileName}`); - this.reloadProjects(); - } + private onConfigFileChangeForOpenScriptInfo(configFileName: NormalizedPath, eventKind: FileWatcherEventKind) { + // This callback is called only if we dont have config file project for this config file + const canonicalConfigPath = normalizedPathToPath(configFileName, this.currentDirectory, this.toCanonicalFileName); + const configFileExistenceInfo = this.configFileExistenceInfoCache.get(canonicalConfigPath); + configFileExistenceInfo.exists = (eventKind !== FileWatcherEventKind.Deleted); + this.logConfigFileWatchUpdate(configFileName, canonicalConfigPath, configFileExistenceInfo, ConfigFileWatcherStatus.ReloadingFiles); - private getCanonicalFileName(fileName: string) { - const name = this.host.useCaseSensitiveFileNames ? fileName : fileName.toLowerCase(); - return normalizePath(name); + // Because there is no configured project open for the config file, the tracking open files map + // will only have open files that need the re-detection of the project and hence + // reload projects for all the tracking open files in the map + this.delayReloadConfiguredProjectForFiles(configFileExistenceInfo, /*ignoreIfNotInferredProjectRoot*/ false); } private removeProject(project: Project) { this.logger.info(`remove project: ${project.getRootFiles().toString()}`); project.close(); + // Remove the project from pending project updates + this.pendingProjectUpdates.delete(project.getProjectName()); switch (project.projectKind) { case ProjectKind.External: unorderedRemoveItem(this.externalProjects, project); - this.projectToSizeMap.delete((project as ExternalProject).externalProjectName); + this.projectToSizeMap.delete(project.getProjectName()); break; case ProjectKind.Configured: - unorderedRemoveItem(this.configuredProjects, project); + this.configuredProjects.delete((project).canonicalConfigFilePath); this.projectToSizeMap.delete((project as ConfiguredProject).canonicalConfigFilePath); + this.setConfigFileExistenceInfoByClosedConfiguredProject(project); break; case ProjectKind.Inferred: unorderedRemoveItem(this.inferredProjects, project); @@ -779,68 +846,50 @@ namespace ts.server { } } - private assignScriptInfoToInferredProjectIfNecessary(info: ScriptInfo, addToListOfOpenFiles: boolean, projectRootPath?: string): void { - const externalProject = this.findContainingExternalProject(info.fileName); - if (externalProject) { - // file is already included in some external project - do nothing - if (addToListOfOpenFiles) { - this.openFiles.push(info); - } - return; - } + /*@internal*/ + assignOrphanScriptInfoToInferredProject(info: ScriptInfo, projectRootPath?: string) { + Debug.assert(info.isOrphan()); - let foundConfiguredProject = false; - for (const p of info.containingProjects) { - // file is the part of configured project - if (p.projectKind === ProjectKind.Configured) { - foundConfiguredProject = true; - if (addToListOfOpenFiles) { - ((p)).addOpenRef(); + const project = this.getOrCreateInferredProjectForProjectRootPathIfEnabled(info, projectRootPath) || + this.getOrCreateSingleInferredProjectIfEnabled() || + this.createInferredProject(getDirectoryPath(info.path)); + + project.addRoot(info); + project.updateGraph(); + + if (!this.useSingleInferredProject && !project.projectRootPath) { + // Note that we need to create a copy of the array since the list of project can change + for (const inferredProject of this.inferredProjects.slice(0, this.inferredProjects.length - 1)) { + Debug.assert(inferredProject !== project); + // Remove the inferred project if the root of it is now part of newly created inferred project + // e.g through references + // Which means if any root of inferred project is part of more than 1 project can be removed + // This logic is same as iterating over all open files and calling + // this.removeRootOfInferredProjectIfNowPartOfOtherProject(f); + // Since this is also called from refreshInferredProject and closeOpen file + // to update inferred projects of the open file, this iteration might be faster + // instead of scanning all open files + const roots = inferredProject.getRootScriptInfos(); + Debug.assert(roots.length === 1 || !!inferredProject.projectRootPath); + if (roots.length === 1 && roots[0].containingProjects.length > 1) { + this.removeProject(inferredProject); } } } - if (foundConfiguredProject) { - if (addToListOfOpenFiles) { - this.openFiles.push(info); - } - return; - } - if (info.containingProjects.length === 0) { - // get (or create) an inferred project using the newly opened file as a root. - const inferredProject = this.createInferredProjectWithRootFileIfNecessary(info, projectRootPath); - if (!this.useSingleInferredProject && !inferredProject.projectRootPath) { - // if useSingleInferredProject is false and the inferred project is not associated - // with a project root, then try to repair the ownership of open files. - for (const f of this.openFiles) { - if (f.containingProjects.length === 0 || !inferredProject.containsScriptInfo(f)) { - // this is orphaned file that we have not processed yet - skip it - continue; - } + return project; + } - for (const containingProject of f.containingProjects) { - // We verify 'containingProject !== inferredProject' to handle cases - // where the inferred project for some file has added other open files - // into this project (i.e. as referenced files) as we don't want to - // delete the project that was just created - if (containingProject.projectKind === ProjectKind.Inferred && - containingProject !== inferredProject && - containingProject.isRoot(f)) { - // open file used to be root in inferred project, - // this inferred project is different from the one we've just created for current file - // and new inferred project references this open file. - // We should delete old inferred project and attach open file to the new one - this.removeProject(containingProject); - f.attachToProject(inferredProject); - } - } - } + private addToListOfOpenFiles(info: ScriptInfo) { + Debug.assert(!info.isOrphan()); + for (const p of info.containingProjects) { + // file is the part of configured project, addref the project + if (p.projectKind === ProjectKind.Configured) { + ((p)).addOpenRef(); } } - if (addToListOfOpenFiles) { - this.openFiles.push(info); - } + this.openFiles.push(info); } /** @@ -852,9 +901,12 @@ namespace ts.server { // because the user may chose to discard the buffer content before saving // to the disk, and the server's version of the file can be out of sync. info.close(); + this.stopWatchingConfigFilesForClosedScriptInfo(info); unorderedRemoveItem(this.openFiles, info); + const fileExists = this.host.fileExists(info.fileName); + // collect all projects that should be removed let projectsToRemove: Project[]; for (const p of info.containingProjects) { @@ -862,14 +914,21 @@ namespace ts.server { if (info.hasMixedContent) { info.registerFileUpdate(); } - // last open file in configured project - close it - if ((p).deleteOpenRef() === 0) { - (projectsToRemove || (projectsToRemove = [])).push(p); - } + // Delete the reference to the open configured projects but + // do not remove the project so that we can reuse this project + // if it would need to be re-created with next file open + (p).deleteOpenRef(); } else if (p.projectKind === ProjectKind.Inferred && p.isRoot(info)) { - // open file in inferred project - (projectsToRemove || (projectsToRemove = [])).push(p); + // If this was the open root file of inferred project + if ((p as InferredProject).isProjectWithSingleRoot()) { + // - when useSingleInferredProject is not set, we can guarantee that this will be the only root + // - other wise remove the project if it is the only root + (projectsToRemove || (projectsToRemove = [])).push(p); + } + else { + p.removeFile(info, fileExists, /*detachFromProject*/ true); + } } if (!p.languageServiceEnabled) { @@ -884,29 +943,21 @@ namespace ts.server { this.removeProject(project); } - let orphanFiles: ScriptInfo[]; - // for all open files + // collect orphaned files and assign them to inferred project just like we treat open of a file for (const f of this.openFiles) { - // collect orphanted files and try to re-add them as newly opened - if (f.containingProjects.length === 0) { - (orphanFiles || (orphanFiles = [])).push(f); + if (f.isOrphan()) { + this.assignOrphanScriptInfoToInferredProject(f); } } - // treat orphaned files as newly opened - if (orphanFiles) { - for (const f of orphanFiles) { - this.assignScriptInfoToInferredProjectIfNecessary(f, /*addToListOfOpenFiles*/ false); - } - } - - // Cleanup script infos that arent part of any project is postponed to - // next file open so that if file from same project is opened we wont end up creating same script infos + // Cleanup script infos that arent part of any project (eg. those could be closed script infos not referenced by any project) + // is postponed to next file open so that if file from same project is opened, + // we wont end up creating same script infos } // If the current info is being just closed - add the watcher file to track changes // But if file was deleted, handle that part - if (this.host.fileExists(info.fileName)) { + if (fileExists) { this.watchClosedScriptInfo(info); } else { @@ -916,67 +967,250 @@ namespace ts.server { private deleteOrphanScriptInfoNotInAnyProject() { this.filenameToScriptInfo.forEach(info => { - if (!info.isScriptOpen() && info.containingProjects.length === 0) { + if (!info.isScriptOpen() && info.isOrphan()) { // if there are not projects that include this script info - delete it - info.stopWatcher(); + this.stopWatchingScriptInfo(info); this.filenameToScriptInfo.delete(info.path); } }); } - /** - * This function tries to search for a tsconfig.json for the given file. If we found it, - * we first detect if there is already a configured project created for it: if so, we re-read - * the tsconfig file content and update the project; otherwise we create a new one. - */ - private openOrUpdateConfiguredProjectForFile(fileName: NormalizedPath, projectRootPath?: NormalizedPath): OpenConfiguredProjectResult { - const searchPath = getDirectoryPath(fileName); - this.logger.info(`Search path: ${searchPath}`); - - // check if this file is already included in one of external projects - const configFileName = this.findConfigFile(asNormalizedPath(searchPath), projectRootPath); - if (!configFileName) { - this.logger.info("No config files found."); - return {}; + private configFileExists(configFileName: NormalizedPath, canonicalConfigFilePath: string, info: ScriptInfo) { + let configFileExistenceInfo = this.configFileExistenceInfoCache.get(canonicalConfigFilePath); + if (configFileExistenceInfo) { + // By default the info would get impacted by presence of config file since its in the detection path + // Only adding the info as a root to inferred project will need the existence to be watched by file watcher + if (!configFileExistenceInfo.openFilesImpactedByConfigFile.has(info.path)) { + configFileExistenceInfo.openFilesImpactedByConfigFile.set(info.path, false); + this.logConfigFileWatchUpdate(configFileName, canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.OpenFilesImpactedByConfigFileAdd); + } + return configFileExistenceInfo.exists; } - this.logger.info(`Config file name: ${configFileName}`); + // Theoretically we should be adding watch for the directory here itself. + // In practice there will be very few scenarios where the config file gets added + // somewhere inside the another config file directory. + // And technically we could handle that case in configFile's directory watcher in some cases + // But given that its a rare scenario it seems like too much overhead. (we werent watching those directories earlier either) - const project = this.findConfiguredProjectByProjectName(configFileName); - if (!project) { - const { success, errors } = this.openConfigFile(configFileName, fileName); - if (!success) { - return { configFileName, configFileErrors: errors }; + // So what we are now watching is: configFile if the configured project corresponding to it is open + // Or the whole chain of config files for the roots of the inferred projects + + // Cache the host value of file exists and add the info to map of open files impacted by this config file + const openFilesImpactedByConfigFile = createMap(); + openFilesImpactedByConfigFile.set(info.path, false); + const exists = this.host.fileExists(configFileName); + configFileExistenceInfo = { exists, openFilesImpactedByConfigFile }; + this.configFileExistenceInfoCache.set(canonicalConfigFilePath, configFileExistenceInfo); + this.logConfigFileWatchUpdate(configFileName, canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.OpenFilesImpactedByConfigFileAdd); + return exists; + } + + private setConfigFileExistenceByNewConfiguredProject(project: ConfiguredProject) { + const configFileExistenceInfo = this.configFileExistenceInfoCache.get(project.canonicalConfigFilePath); + if (configFileExistenceInfo) { + Debug.assert(configFileExistenceInfo.exists); + // close existing watcher + if (configFileExistenceInfo.configFileWatcherForRootOfInferredProject) { + const configFileName = project.getConfigFilePath(); + configFileExistenceInfo.configFileWatcherForRootOfInferredProject.close(); + configFileExistenceInfo.configFileWatcherForRootOfInferredProject = undefined; + this.logConfigFileWatchUpdate(configFileName, project.canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.UpdatedCallback); } + } + else { + // We could be in this scenario if project is the configured project tracked by external project + // Since that route doesnt check if the config file is present or not + this.configFileExistenceInfoCache.set(project.canonicalConfigFilePath, { + exists: true, + openFilesImpactedByConfigFile: createMap() + }); + } + } + + /** + * Returns true if the configFileExistenceInfo is needed/impacted by open files that are root of inferred project + */ + private configFileExistenceImpactsRootOfInferredProject(configFileExistenceInfo: ConfigFileExistenceInfo) { + return forEachEntry(configFileExistenceInfo.openFilesImpactedByConfigFile, (isRootOfInferredProject, __key) => isRootOfInferredProject); + } - // even if opening config file was successful, it could still - // contain errors that were tolerated. - this.logger.info(`Opened configuration file ${configFileName}`); - if (errors && errors.length > 0) { - return { configFileName, configFileErrors: errors }; + private setConfigFileExistenceInfoByClosedConfiguredProject(closedProject: ConfiguredProject) { + const configFileExistenceInfo = this.configFileExistenceInfoCache.get(closedProject.canonicalConfigFilePath); + Debug.assert(!!configFileExistenceInfo); + if (configFileExistenceInfo.openFilesImpactedByConfigFile.size) { + const configFileName = closedProject.getConfigFilePath(); + // If there are open files that are impacted by this config file existence + // but none of them are root of inferred project, the config file watcher will be + // created when any of the script infos are added as root of inferred project + if (this.configFileExistenceImpactsRootOfInferredProject(configFileExistenceInfo)) { + Debug.assert(!configFileExistenceInfo.configFileWatcherForRootOfInferredProject); + this.createConfigFileWatcherOfConfigFileExistence(configFileName, closedProject.canonicalConfigFilePath, configFileExistenceInfo); } } else { - this.updateConfiguredProject(project); + // There is not a single file open thats tracking the status of this config file. Remove from cache + this.configFileExistenceInfoCache.delete(closedProject.canonicalConfigFilePath); } + } - return { configFileName }; + private logConfigFileWatchUpdate(configFileName: NormalizedPath, canonicalConfigFilePath: string, configFileExistenceInfo: ConfigFileExistenceInfo, status: ConfigFileWatcherStatus) { + if (!this.logger.hasLevel(LogLevel.verbose)) { + return; + } + const inferredRoots: string[] = []; + const otherFiles: string[] = []; + configFileExistenceInfo.openFilesImpactedByConfigFile.forEach((isRootOfInferredProject, key) => { + const info = this.getScriptInfoForPath(key as Path); + (isRootOfInferredProject ? inferredRoots : otherFiles).push(info.fileName); + }); + + const watches: WatchType[] = []; + if (configFileExistenceInfo.configFileWatcherForRootOfInferredProject) { + watches.push(WatchType.ConfigFileForInferredRoot); + } + if (this.configuredProjects.has(canonicalConfigFilePath)) { + watches.push(WatchType.ConfigFilePath); + } + this.logger.info(`ConfigFilePresence:: Current Watches: ${watches}:: File: ${configFileName} Currently impacted open files: RootsOfInferredProjects: ${inferredRoots} OtherOpenFiles: ${otherFiles} Status: ${status}`); } - // This is different from the method the compiler uses because - // the compiler can assume it will always start searching in the - // current directory (the directory in which tsc was invoked). - // The server must start searching from the directory containing - // the newly opened file. - private findConfigFile(searchPath: NormalizedPath, projectRootPath?: NormalizedPath): NormalizedPath { + /** + * Create the watcher for the configFileExistenceInfo + */ + private createConfigFileWatcherOfConfigFileExistence( + configFileName: NormalizedPath, + canonicalConfigFilePath: string, + configFileExistenceInfo: ConfigFileExistenceInfo + ) { + configFileExistenceInfo.configFileWatcherForRootOfInferredProject = this.watchFile( + this.host, + configFileName, + (_filename, eventKind) => this.onConfigFileChangeForOpenScriptInfo(configFileName, eventKind), + WatchType.ConfigFileForInferredRoot + ); + this.logConfigFileWatchUpdate(configFileName, canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.UpdatedCallback); + } + + /** + * Close the config file watcher in the cached ConfigFileExistenceInfo + * if there arent any open files that are root of inferred project + */ + private closeConfigFileWatcherOfConfigFileExistenceInfo(configFileExistenceInfo: ConfigFileExistenceInfo) { + // Close the config file watcher if there are no more open files that are root of inferred project + if (configFileExistenceInfo.configFileWatcherForRootOfInferredProject && + !this.configFileExistenceImpactsRootOfInferredProject(configFileExistenceInfo)) { + configFileExistenceInfo.configFileWatcherForRootOfInferredProject.close(); + configFileExistenceInfo.configFileWatcherForRootOfInferredProject = undefined; + } + } + + /** + * This is called on file close, so that we stop watching the config file for this script info + */ + private stopWatchingConfigFilesForClosedScriptInfo(info: ScriptInfo) { + Debug.assert(!info.isScriptOpen()); + this.forEachConfigFileLocation(info, (configFileName, canonicalConfigFilePath) => { + const configFileExistenceInfo = this.configFileExistenceInfoCache.get(canonicalConfigFilePath); + if (configFileExistenceInfo) { + const infoIsRootOfInferredProject = configFileExistenceInfo.openFilesImpactedByConfigFile.get(info.path); + + // Delete the info from map, since this file is no more open + configFileExistenceInfo.openFilesImpactedByConfigFile.delete(info.path); + this.logConfigFileWatchUpdate(configFileName, canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.OpenFilesImpactedByConfigFileRemove); + + // If the script info was not root of inferred project, + // there wont be config file watch open because of this script info + if (infoIsRootOfInferredProject) { + // But if it is a root, it could be the last script info that is root of inferred project + // and hence we would need to close the config file watcher + this.closeConfigFileWatcherOfConfigFileExistenceInfo(configFileExistenceInfo); + } + + // If there are no open files that are impacted by configFileExistenceInfo after closing this script info + // there is no configured project present, remove the cached existence info + if (!configFileExistenceInfo.openFilesImpactedByConfigFile.size && + !this.getConfiguredProjectByCanonicalConfigFilePath(canonicalConfigFilePath)) { + Debug.assert(!configFileExistenceInfo.configFileWatcherForRootOfInferredProject); + this.configFileExistenceInfoCache.delete(canonicalConfigFilePath); + } + } + }); + } + + /** + * This is called by inferred project whenever script info is added as a root + */ + /* @internal */ + startWatchingConfigFilesForInferredProjectRoot(info: ScriptInfo) { + Debug.assert(info.isScriptOpen()); + this.forEachConfigFileLocation(info, (configFileName, canonicalConfigFilePath) => { + let configFileExistenceInfo = this.configFileExistenceInfoCache.get(canonicalConfigFilePath); + if (!configFileExistenceInfo) { + // Create the cache + configFileExistenceInfo = { + exists: this.host.fileExists(configFileName), + openFilesImpactedByConfigFile: createMap() + }; + this.configFileExistenceInfoCache.set(canonicalConfigFilePath, configFileExistenceInfo); + } + + // Set this file as the root of inferred project + configFileExistenceInfo.openFilesImpactedByConfigFile.set(info.path, true); + this.logConfigFileWatchUpdate(configFileName, canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.RootOfInferredProjectTrue); + + // If there is no configured project for this config file, add the file watcher + if (!configFileExistenceInfo.configFileWatcherForRootOfInferredProject && + !this.getConfiguredProjectByCanonicalConfigFilePath(canonicalConfigFilePath)) { + this.createConfigFileWatcherOfConfigFileExistence(configFileName, canonicalConfigFilePath, configFileExistenceInfo); + } + }); + } + + /** + * This is called by inferred project whenever root script info is removed from it + */ + /* @internal */ + stopWatchingConfigFilesForInferredProjectRoot(info: ScriptInfo) { + this.forEachConfigFileLocation(info, (configFileName, canonicalConfigFilePath) => { + const configFileExistenceInfo = this.configFileExistenceInfoCache.get(canonicalConfigFilePath); + if (configFileExistenceInfo && configFileExistenceInfo.openFilesImpactedByConfigFile.has(info.path)) { + Debug.assert(info.isScriptOpen()); + + // Info is not root of inferred project any more + configFileExistenceInfo.openFilesImpactedByConfigFile.set(info.path, false); + this.logConfigFileWatchUpdate(configFileName, canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.RootOfInferredProjectFalse); + + // Close the config file watcher + this.closeConfigFileWatcherOfConfigFileExistenceInfo(configFileExistenceInfo); + } + }); + } + + /** + * This function tries to search for a tsconfig.json for the given file. + * This is different from the method the compiler uses because + * the compiler can assume it will always start searching in the + * current directory (the directory in which tsc was invoked). + * The server must start searching from the directory containing + * the newly opened file. + */ + private forEachConfigFileLocation(info: ScriptInfo, + action: (configFileName: NormalizedPath, canonicalConfigFilePath: string) => boolean | void, + projectRootPath?: NormalizedPath) { + let searchPath = asNormalizedPath(getDirectoryPath(info.fileName)); + while (!projectRootPath || searchPath.indexOf(projectRootPath) >= 0) { + const canonicalSearchPath = normalizedPathToPath(searchPath, this.currentDirectory, this.toCanonicalFileName); const tsconfigFileName = asNormalizedPath(combinePaths(searchPath, "tsconfig.json")); - if (this.host.fileExists(tsconfigFileName)) { + let result = action(tsconfigFileName, combinePaths(canonicalSearchPath, "tsconfig.json")); + if (result) { return tsconfigFileName; } const jsconfigFileName = asNormalizedPath(combinePaths(searchPath, "jsconfig.json")); - if (this.host.fileExists(jsconfigFileName)) { + result = action(jsconfigFileName, combinePaths(canonicalSearchPath, "jsconfig.json")); + if (result) { return jsconfigFileName; } @@ -986,28 +1220,54 @@ namespace ts.server { } searchPath = parentPath; } + return undefined; } + /** + * This function tries to search for a tsconfig.json for the given file. + * This is different from the method the compiler uses because + * the compiler can assume it will always start searching in the + * current directory (the directory in which tsc was invoked). + * The server must start searching from the directory containing + * the newly opened file. + */ + private getConfigFileNameForFile(info: ScriptInfo, projectRootPath?: NormalizedPath) { + Debug.assert(info.isScriptOpen()); + this.logger.info(`Search path: ${getDirectoryPath(info.fileName)}`); + const configFileName = this.forEachConfigFileLocation(info, + (configFileName, canonicalConfigFilePath) => + this.configFileExists(configFileName, canonicalConfigFilePath, info), + projectRootPath + ); + if (configFileName) { + this.logger.info(`For info: ${info.fileName} :: Config file name: ${configFileName}`); + } + else { + this.logger.info(`For info: ${info.fileName} :: No config files found.`); + } + return configFileName; + } + private printProjects() { - if (!this.logger.hasLevel(LogLevel.verbose)) { + if (!this.logger.hasLevel(LogLevel.normal)) { return; } + const writeProjectFileNames = this.logger.hasLevel(LogLevel.verbose); this.logger.startGroup(); let counter = 0; const printProjects = (projects: Project[], counter: number): number => { for (const project of projects) { - project.updateGraph(); this.logger.info(`Project '${project.getProjectName()}' (${ProjectKind[project.projectKind]}) ${counter}`); - this.logger.info(project.filesToString()); + this.logger.info(project.filesToString(writeProjectFileNames)); this.logger.info("-----------------------------------------------"); counter++; } return counter; }; counter = printProjects(this.externalProjects, counter); - counter = printProjects(this.configuredProjects, counter); + counter = printProjects(arrayFrom(this.configuredProjects.values()), counter); printProjects(this.inferredProjects, counter); this.logger.info("Open files: "); @@ -1018,21 +1278,21 @@ namespace ts.server { this.logger.endGroup(); } - private findConfiguredProjectByProjectName(configFileName: NormalizedPath) { + private findConfiguredProjectByProjectName(configFileName: NormalizedPath): ConfiguredProject | undefined { // make sure that casing of config file name is consistent - configFileName = asNormalizedPath(this.toCanonicalFileName(configFileName)); - for (const proj of this.configuredProjects) { - if (proj.canonicalConfigFilePath === configFileName) { - return proj; - } - } + const canonicalConfigFilePath = asNormalizedPath(this.toCanonicalFileName(configFileName)); + return this.getConfiguredProjectByCanonicalConfigFilePath(canonicalConfigFilePath); + } + + private getConfiguredProjectByCanonicalConfigFilePath(canonicalConfigFilePath: string): ConfiguredProject | undefined { + return this.configuredProjects.get(canonicalConfigFilePath); } private findExternalProjectByProjectName(projectFileName: string) { return findProjectByName(projectFileName, this.externalProjects); } - private convertConfigFileContentToProjectOptions(configFilename: string): ConfigFileConversionResult { + private convertConfigFileContentToProjectOptions(configFilename: string, cachedDirectoryStructureHost: CachedDirectoryStructureHost) { configFilename = normalizePath(configFilename); const configFileContent = this.host.readFile(configFilename); @@ -1044,7 +1304,7 @@ namespace ts.server { const errors = result.parseDiagnostics; const parsedCommandLine = parseJsonSourceFileConfigFileContent( result, - this.host, + cachedDirectoryStructureHost, getDirectoryPath(configFilename), /*existingOptions*/ {}, configFilename, @@ -1057,11 +1317,6 @@ namespace ts.server { Debug.assert(!!parsedCommandLine.fileNames); - if (parsedCommandLine.fileNames.length === 0) { - errors.push(createCompilerDiagnostic(Diagnostics.The_config_file_0_found_doesn_t_contain_any_source_files, configFilename)); - return { success: false, configFileErrors: errors }; - } - const projectOptions: ProjectOptions = { files: parsedCommandLine.fileNames, compilerOptions: parsedCommandLine.options, @@ -1073,7 +1328,8 @@ namespace ts.server { typeAcquisition: parsedCommandLine.typeAcquisition, compileOnSave: parsedCommandLine.compileOnSave }; - return { success: true, projectOptions, configFileErrors: errors }; + + return { projectOptions, configFileErrors: errors, configFileSpecs: parsedCommandLine.configFileSpecs }; } private exceededTotalSizeLimitForNonTsFiles(name: string, options: CompilerOptions, fileNames: T[], propertyReader: FilePropertyReader) { @@ -1106,7 +1362,7 @@ namespace ts.server { return false; } - private createAndAddExternalProject(projectFileName: string, files: protocol.ExternalFile[], options: protocol.ExternalProjectCompilerOptions, typeAcquisition: TypeAcquisition) { + private createExternalProject(projectFileName: string, files: protocol.ExternalFile[], options: protocol.ExternalProjectCompilerOptions, typeAcquisition: TypeAcquisition) { const compilerOptions = convertCompilerOptions(options); const project = new ExternalProject( projectFileName, @@ -1116,9 +1372,9 @@ namespace ts.server { /*languageServiceEnabled*/ !this.exceededTotalSizeLimitForNonTsFiles(projectFileName, compilerOptions, files, externalFilePropertyReader), options.compileOnSave === undefined ? true : options.compileOnSave); - this.addFilesToProjectAndUpdateGraph(project, files, externalFilePropertyReader, /*clientFileName*/ undefined, typeAcquisition, /*configFileErrors*/ undefined); + this.addFilesToNonInferredProjectAndUpdateGraph(project, files, externalFilePropertyReader, typeAcquisition); this.externalProjects.push(project); - this.sendProjectTelemetry(project.externalProjectName, project); + this.sendProjectTelemetry(projectFileName, project); return project; } @@ -1133,7 +1389,7 @@ namespace ts.server { const data: ProjectInfoTelemetryEventData = { projectId: this.host.createHash(projectKey), fileStats: countEachFileTypes(project.getScriptInfos()), - compilerOptions: convertCompilerOptionsForTelemetry(project.getCompilerOptions()), + compilerOptions: convertCompilerOptionsForTelemetry(project.getCompilationSettings()), typeAcquisition: convertTypeAcquisition(project.getTypeAcquisition()), extends: projectOptions && projectOptions.configHasExtendsProperty, files: projectOptions && projectOptions.configHasFilesProperty, @@ -1153,8 +1409,7 @@ namespace ts.server { } const configFilePath = project instanceof server.ConfiguredProject && project.getConfigFilePath(); - const base = getBaseFileName(configFilePath); - return base === "tsconfig.json" || base === "jsconfig.json" ? base : "other"; + return getBaseConfigFileName(configFilePath) || "other"; } function convertTypeAcquisition({ enable, include, exclude }: TypeAcquisition): ProjectInfoTypeAcquisitionData { @@ -1166,194 +1421,158 @@ namespace ts.server { } } - private reportConfigFileDiagnostics(configFileName: string, diagnostics: ReadonlyArray, triggerFile: string) { - if (!this.eventHandler) { - return; - } - - const event: ConfigFileDiagEvent = { - eventName: ConfigFileDiagEvent, - data: { configFileName, diagnostics: diagnostics || emptyArray, triggerFile } - }; - this.eventHandler(event); + private addFilesToNonInferredProjectAndUpdateGraph(project: ConfiguredProject | ExternalProject, files: T[], propertyReader: FilePropertyReader, typeAcquisition: TypeAcquisition): void { + this.updateNonInferredProjectFiles(project, files, propertyReader); + project.setTypeAcquisition(typeAcquisition); + // This doesnt need scheduling since its either creation or reload of the project + project.updateGraph(); } - private createAndAddConfiguredProject(configFileName: NormalizedPath, projectOptions: ProjectOptions, configFileErrors: ReadonlyArray, clientFileName?: string) { - const sizeLimitExceeded = this.exceededTotalSizeLimitForNonTsFiles(configFileName, projectOptions.compilerOptions, projectOptions.files, fileNamePropertyReader); + private createConfiguredProject(configFileName: NormalizedPath) { + const cachedDirectoryStructureHost = createCachedDirectoryStructureHost(this.host); + const { projectOptions, configFileErrors, configFileSpecs } = this.convertConfigFileContentToProjectOptions(configFileName, cachedDirectoryStructureHost); + this.logger.info(`Opened configuration file ${configFileName}`); + const languageServiceEnabled = !this.exceededTotalSizeLimitForNonTsFiles(configFileName, projectOptions.compilerOptions, projectOptions.files, fileNamePropertyReader); const project = new ConfiguredProject( configFileName, this, this.documentRegistry, projectOptions.configHasFilesProperty, projectOptions.compilerOptions, - projectOptions.wildcardDirectories, - /*languageServiceEnabled*/ !sizeLimitExceeded, - projectOptions.compileOnSave === undefined ? false : projectOptions.compileOnSave); - - const filesToAdd = projectOptions.files.concat(project.getExternalFiles()); - this.addFilesToProjectAndUpdateGraph(project, filesToAdd, fileNamePropertyReader, clientFileName, projectOptions.typeAcquisition, configFileErrors); + languageServiceEnabled, + projectOptions.compileOnSave === undefined ? false : projectOptions.compileOnSave, + cachedDirectoryStructureHost); - project.watchConfigFile(project => this.onConfigChangedForConfiguredProject(project)); - if (!sizeLimitExceeded) { - this.watchConfigDirectoryForProject(project, projectOptions); + project.configFileSpecs = configFileSpecs; + // TODO: We probably should also watch the configFiles that are extended + project.configFileWatcher = this.watchFile( + this.host, + configFileName, + (_fileName, eventKind) => this.onConfigChangedForConfiguredProject(project, eventKind), + WatchType.ConfigFilePath, + project + ); + if (languageServiceEnabled) { + project.watchWildcards(projectOptions.wildcardDirectories); } - project.watchWildcards((project, path) => this.onSourceFileInDirectoryChangedForConfiguredProject(project, path)); - project.watchTypeRoots((project, path) => this.onTypeRootFileChanged(project, path)); - this.configuredProjects.push(project); - this.sendProjectTelemetry(project.getConfigFilePath(), project, projectOptions); + project.setProjectErrors(configFileErrors); + const filesToAdd = projectOptions.files.concat(project.getExternalFiles()); + this.addFilesToNonInferredProjectAndUpdateGraph(project, filesToAdd, fileNamePropertyReader, projectOptions.typeAcquisition); + this.configuredProjects.set(project.canonicalConfigFilePath, project); + this.setConfigFileExistenceByNewConfiguredProject(project); + this.sendProjectTelemetry(configFileName, project, projectOptions); return project; } - private watchConfigDirectoryForProject(project: ConfiguredProject, options: ProjectOptions): void { - if (!options.configHasFilesProperty) { - project.watchConfigDirectory((project, path) => this.onSourceFileInDirectoryChangedForConfiguredProject(project, path)); - } - } + private updateNonInferredProjectFiles(project: ExternalProject | ConfiguredProject, files: T[], propertyReader: FilePropertyReader) { + const projectRootFilesMap = project.getRootFilesMap(); + const newRootScriptInfoMap = createMap(); - private addFilesToProjectAndUpdateGraph(project: ConfiguredProject | ExternalProject, files: ReadonlyArray, propertyReader: FilePropertyReader, clientFileName: string, typeAcquisition: TypeAcquisition, configFileErrors: ReadonlyArray): void { - let errors: Diagnostic[]; for (const f of files) { - const rootFileName = propertyReader.getFileName(f); - const scriptKind = propertyReader.getScriptKind(f); - const hasMixedContent = propertyReader.hasMixedContent(f, this.hostConfiguration.extraFileExtensions); - const isDynamicFile = propertyReader.isDynamicFile(f); - if (isDynamicFile || this.host.fileExists(rootFileName)) { - const info = this.getOrCreateScriptInfoForNormalizedPath(toNormalizedPath(rootFileName), /*openedByClient*/ clientFileName === rootFileName, /*fileContent*/ undefined, scriptKind, hasMixedContent, isDynamicFile); - project.addRoot(info); - } - else { - (errors || (errors = [])).push(createFileNotFoundDiagnostic(rootFileName)); - } - } - project.setProjectErrors(concatenate(configFileErrors, errors)); - project.setTypeAcquisition(typeAcquisition); - project.updateGraph(); - } - - private openConfigFile(configFileName: NormalizedPath, clientFileName?: string): OpenConfigFileResult { - const conversionResult = this.convertConfigFileContentToProjectOptions(configFileName); - const projectOptions: ProjectOptions = conversionResult.success - ? conversionResult.projectOptions - : { files: [], compilerOptions: {}, configHasExtendsProperty: false, configHasFilesProperty: false, configHasIncludeProperty: false, configHasExcludeProperty: false, typeAcquisition: { enable: false } }; - const project = this.createAndAddConfiguredProject(configFileName, projectOptions, conversionResult.configFileErrors, clientFileName); - return { - success: conversionResult.success, - project, - errors: project.getGlobalProjectErrors() - }; - } - - private updateNonInferredProject(project: ExternalProject | ConfiguredProject, newUncheckedFiles: T[], propertyReader: FilePropertyReader, newOptions: CompilerOptions, newTypeAcquisition: TypeAcquisition, compileOnSave: boolean, configFileErrors: Diagnostic[]) { - const oldRootScriptInfos = project.getRootScriptInfos(); - const newRootScriptInfos: ScriptInfo[] = []; - const newRootScriptInfoMap: NormalizedPathMap = createNormalizedPathMap(); - - let projectErrors: Diagnostic[]; - let rootFilesChanged = false; - for (const f of newUncheckedFiles) { const newRootFile = propertyReader.getFileName(f); - const isDynamic = propertyReader.isDynamicFile(f); - if (!isDynamic && !this.host.fileExists(newRootFile)) { - (projectErrors || (projectErrors = [])).push(createFileNotFoundDiagnostic(newRootFile)); - continue; - } const normalizedPath = toNormalizedPath(newRootFile); - let scriptInfo = this.getScriptInfoForNormalizedPath(normalizedPath); - if (!scriptInfo || !project.isRoot(scriptInfo)) { - rootFilesChanged = true; - if (!scriptInfo) { - const scriptKind = propertyReader.getScriptKind(f); - const hasMixedContent = propertyReader.hasMixedContent(f, this.hostConfiguration.extraFileExtensions); - scriptInfo = this.getOrCreateScriptInfoForNormalizedPath(normalizedPath, /*openedByClient*/ false, /*fileContent*/ undefined, scriptKind, hasMixedContent, isDynamic); - } - } - newRootScriptInfos.push(scriptInfo); - newRootScriptInfoMap.set(scriptInfo.fileName, scriptInfo); - } - - if (rootFilesChanged || newRootScriptInfos.length !== oldRootScriptInfos.length) { - let toAdd: ScriptInfo[]; - let toRemove: ScriptInfo[]; - for (const oldFile of oldRootScriptInfos) { - if (!newRootScriptInfoMap.contains(oldFile.fileName)) { - (toRemove || (toRemove = [])).push(oldFile); - } - } - for (const newFile of newRootScriptInfos) { - if (!project.isRoot(newFile)) { - (toAdd || (toAdd = [])).push(newFile); + const isDynamic = isDynamicFileName(normalizedPath); + let scriptInfo: ScriptInfo | NormalizedPath; + let path: Path; + // Use the project's fileExists so that it can use caching instead of reaching to disk for the query + if (!isDynamic && !project.fileExists(newRootFile)) { + path = normalizedPathToPath(normalizedPath, this.currentDirectory, this.toCanonicalFileName); + const existingValue = projectRootFilesMap.get(path); + if (isScriptInfo(existingValue)) { + project.removeFile(existingValue, /*fileExists*/ false, /*detachFromProject*/ true); } + projectRootFilesMap.set(path, normalizedPath); + scriptInfo = normalizedPath; } - if (toRemove) { - for (const f of toRemove) { - project.removeFile(f); - } - } - if (toAdd) { - for (const f of toAdd) { - if (f.isScriptOpen() && isRootFileInInferredProject(f)) { + else { + const scriptKind = propertyReader.getScriptKind(f); + const hasMixedContent = propertyReader.hasMixedContent(f, this.hostConfiguration.extraFileExtensions); + scriptInfo = this.getOrCreateScriptInfoNotOpenedByClientForNormalizedPath(normalizedPath, scriptKind, hasMixedContent, project.directoryStructureHost); + path = scriptInfo.path; + // If this script info is not already a root add it + if (!project.isRoot(scriptInfo)) { + project.addRoot(scriptInfo); + if (scriptInfo.isScriptOpen()) { // if file is already root in some inferred project // - remove the file from that project and delete the project if necessary - const inferredProject = f.containingProjects[0]; - inferredProject.removeFile(f); - if (!inferredProject.hasRoots()) { - this.removeProject(inferredProject); - } + this.removeRootOfInferredProjectIfNowPartOfOtherProject(scriptInfo); } - project.addRoot(f); } } + newRootScriptInfoMap.set(path, scriptInfo); } - project.setCompilerOptions(newOptions); - (project).setTypeAcquisition(newTypeAcquisition); + // project's root file map size is always going to be same or larger than new roots map + // as we have already all the new files to the project + if (projectRootFilesMap.size > newRootScriptInfoMap.size) { + projectRootFilesMap.forEach((value, path) => { + if (!newRootScriptInfoMap.has(path)) { + if (isScriptInfo(value)) { + project.removeFile(value, project.fileExists(path), /*detachFromProject*/ true); + } + else { + projectRootFilesMap.delete(path); + } + } + }); + } + + // Just to ensure that even if root files dont change, the changes to the non root file are picked up, + // mark the project as dirty unconditionally + project.markAsDirty(); + } + private updateNonInferredProject(project: ExternalProject | ConfiguredProject, newUncheckedFiles: T[], propertyReader: FilePropertyReader, newOptions: CompilerOptions, newTypeAcquisition: TypeAcquisition, compileOnSave: boolean) { + project.setCompilerOptions(newOptions); // VS only set the CompileOnSaveEnabled option in the request if the option was changed recently // therefore if it is undefined, it should not be updated. if (compileOnSave !== undefined) { project.compileOnSaveEnabled = compileOnSave; } - project.setProjectErrors(concatenate(configFileErrors, projectErrors)); - - project.updateGraph(); + this.addFilesToNonInferredProjectAndUpdateGraph(project, newUncheckedFiles, propertyReader, newTypeAcquisition); } - private updateConfiguredProject(project: ConfiguredProject) { - if (!this.host.fileExists(project.getConfigFilePath())) { - this.logger.info("Config file deleted"); - this.removeProject(project); - return; - } + /** + * Read the config file of the project again and update the project + */ + /* @internal */ + reloadConfiguredProject(project: ConfiguredProject) { + // At this point, there is no reason to not have configFile in the host + const host = project.getCachedDirectoryStructureHost(); - // note: the returned "success" is true does not mean the "configFileErrors" is empty. - // because we might have tolerated the errors and kept going. So always return the configFileErrors - // regardless the "success" here is true or not. - const { success, projectOptions, configFileErrors } = this.convertConfigFileContentToProjectOptions(project.getConfigFilePath()); - if (!success) { - // reset project settings to default - this.updateNonInferredProject(project, [], fileNamePropertyReader, {}, {}, /*compileOnSave*/ false, configFileErrors); - return configFileErrors; - } + // Clear the cache since we are reloading the project from disk + host.clearCache(); + const configFileName = project.getConfigFilePath(); + this.logger.info(`Reloading configured project ${configFileName}`); + + // Read updated contents from disk + const { projectOptions, configFileErrors, configFileSpecs } = this.convertConfigFileContentToProjectOptions(configFileName, host); + // Update the project + project.configFileSpecs = configFileSpecs; + project.setProjectErrors(configFileErrors); if (this.exceededTotalSizeLimitForNonTsFiles(project.canonicalConfigFilePath, projectOptions.compilerOptions, projectOptions.files, fileNamePropertyReader)) { - project.setCompilerOptions(projectOptions.compilerOptions); - if (!project.languageServiceEnabled) { - // language service is already disabled - return configFileErrors; - } project.disableLanguageService(); - project.stopWatchingDirectory(); + project.stopWatchingWildCards(); } else { project.enableLanguageService(); - this.watchConfigDirectoryForProject(project, projectOptions); - this.updateNonInferredProject(project, projectOptions.files, fileNamePropertyReader, projectOptions.compilerOptions, projectOptions.typeAcquisition, projectOptions.compileOnSave, configFileErrors); + project.watchWildcards(projectOptions.wildcardDirectories); + } + this.updateNonInferredProject(project, projectOptions.files, fileNamePropertyReader, projectOptions.compilerOptions, projectOptions.typeAcquisition, projectOptions.compileOnSave); + + if (!this.eventHandler) { + return; } - return configFileErrors; + + this.eventHandler({ + eventName: ConfigFileDiagEvent, + data: { configFileName, diagnostics: project.getGlobalProjectErrors() || [], triggerFile: configFileName } + }); } - private getOrCreateInferredProjectForProjectRootPathIfEnabled(root: ScriptInfo, projectRootPath: string | undefined): InferredProject | undefined { + private getOrCreateInferredProjectForProjectRootPathIfEnabled(info: ScriptInfo, projectRootPath: string | undefined): InferredProject | undefined { if (!this.useInferredProjectPerProjectRoot) { return undefined; } @@ -1365,7 +1584,7 @@ namespace ts.server { return project; } } - return this.createInferredProject(/*isSingleInferredProject*/ false, projectRootPath); + return this.createInferredProject(projectRootPath, /*isSingleInferredProject*/ false, projectRootPath); } // we don't have an explicit root path, so we should try to find an inferred project @@ -1375,7 +1594,7 @@ namespace ts.server { // ignore single inferred projects (handled elsewhere) if (!project.projectRootPath) continue; // ignore inferred projects that don't contain the root's path - if (!containsPath(project.projectRootPath, root.path, this.host.getCurrentDirectory(), !this.host.useCaseSensitiveFileNames)) continue; + if (!containsPath(project.projectRootPath, info.path, this.host.getCurrentDirectory(), !this.host.useCaseSensitiveFileNames)) continue; // ignore inferred projects that are higher up in the project root. // TODO(rbuckton): Should we add the file as a root to these as well? if (bestMatch && bestMatch.projectRootPath.length > project.projectRootPath.length) continue; @@ -1402,12 +1621,12 @@ namespace ts.server { return this.inferredProjects[0]; } - return this.createInferredProject(/*isSingleInferredProject*/ true); + return this.createInferredProject(/*rootDirectoryForResolution*/ undefined, /*isSingleInferredProject*/ true); } - private createInferredProject(isSingleInferredProject?: boolean, projectRootPath?: string): InferredProject { + private createInferredProject(rootDirectoryForResolution: string | undefined, isSingleInferredProject?: boolean, projectRootPath?: string): InferredProject { const compilerOptions = projectRootPath && this.compilerOptionsForInferredProjectsPerProjectRoot.get(projectRootPath) || this.compilerOptionsForInferredProjects; - const project = new InferredProject(this, this.documentRegistry, compilerOptions, projectRootPath); + const project = new InferredProject(this, this.documentRegistry, compilerOptions, projectRootPath, rootDirectoryForResolution); if (isSingleInferredProject) { this.inferredProjects.unshift(project); } @@ -1417,88 +1636,88 @@ namespace ts.server { return project; } - createInferredProjectWithRootFileIfNecessary(root: ScriptInfo, projectRootPath?: string) { - const project = this.getOrCreateInferredProjectForProjectRootPathIfEnabled(root, projectRootPath) || - this.getOrCreateSingleInferredProjectIfEnabled() || - this.createInferredProject(); - - project.addRoot(root); - - this.directoryWatchers.startWatchingContainingDirectoriesForFile( - root.fileName, - project, - fileName => this.onConfigFileAddedForInferredProject(fileName)); - - project.updateGraph(); - - return project; - } - - /** - * @param uncheckedFileName is absolute pathname - * @param fileContent is a known version of the file content that is more up to date than the one on disk - */ - - getOrCreateScriptInfo(uncheckedFileName: string, openedByClient: boolean, fileContent?: string, scriptKind?: ScriptKind) { - return this.getOrCreateScriptInfoForNormalizedPath(toNormalizedPath(uncheckedFileName), openedByClient, fileContent, scriptKind); + /*@internal*/ + getOrCreateScriptInfoNotOpenedByClient(uncheckedFileName: string, hostToQueryFileExistsOn: DirectoryStructureHost) { + return this.getOrCreateScriptInfoNotOpenedByClientForNormalizedPath( + toNormalizedPath(uncheckedFileName), /*scriptKind*/ undefined, + /*hasMixedContent*/ undefined, hostToQueryFileExistsOn + ); } getScriptInfo(uncheckedFileName: string) { return this.getScriptInfoForNormalizedPath(toNormalizedPath(uncheckedFileName)); } - watchClosedScriptInfo(info: ScriptInfo) { + private watchClosedScriptInfo(info: ScriptInfo) { + Debug.assert(!info.fileWatcher); // do not watch files with mixed content - server doesn't know how to interpret it - if (!info.hasMixedContent && !info.isDynamic) { + if (!info.isDynamicOrHasMixedContent()) { const { fileName } = info; - info.setWatcher(this.host.watchFile(fileName, _ => this.onSourceFileChanged(fileName))); + info.fileWatcher = this.watchFile( + this.host, + fileName, + (_fileName, eventKind) => this.onSourceFileChanged(fileName, eventKind), + WatchType.ClosedScriptInfo + ); } } - getOrCreateScriptInfoForNormalizedPath(fileName: NormalizedPath, openedByClient: boolean, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, isDynamic?: boolean) { - let info = this.getScriptInfoForNormalizedPath(fileName); - if (!info) { - if (openedByClient || isDynamic || this.host.fileExists(fileName)) { - info = new ScriptInfo(this.host, fileName, scriptKind, hasMixedContent, isDynamic); + private stopWatchingScriptInfo(info: ScriptInfo) { + if (info.fileWatcher) { + info.fileWatcher.close(); + info.fileWatcher = undefined; + } + } - this.filenameToScriptInfo.set(info.path, info); + /*@internal*/ + getOrCreateScriptInfoNotOpenedByClientForNormalizedPath(fileName: NormalizedPath, scriptKind?: ScriptKind, hasMixedContent?: boolean, hostToQueryFileExistsOn?: DirectoryStructureHost) { + return this.getOrCreateScriptInfoForNormalizedPath(fileName, /*openedByClient*/ false, /*fileContent*/ undefined, scriptKind, hasMixedContent, hostToQueryFileExistsOn); + } - if (openedByClient) { - if (fileContent === undefined) { - // if file is opened by client and its content is not specified - use file text - fileContent = this.host.readFile(fileName) || ""; - } - } + /*@internal*/ + getOrCreateScriptInfoOpenedByClientForNormalizedPath(fileName: NormalizedPath, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, hostToQueryFileExistsOn?: DirectoryStructureHost) { + return this.getOrCreateScriptInfoForNormalizedPath(fileName, /*openedByClient*/ true, fileContent, scriptKind, hasMixedContent, hostToQueryFileExistsOn); + } - else { - this.watchClosedScriptInfo(info); - } + getOrCreateScriptInfoForNormalizedPath(fileName: NormalizedPath, openedByClient: boolean, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, hostToQueryFileExistsOn?: DirectoryStructureHost) { + Debug.assert(fileContent === undefined || openedByClient, "ScriptInfo needs to be opened by client to be able to set its user defined content"); + const path = normalizedPathToPath(fileName, this.currentDirectory, this.toCanonicalFileName); + let info = this.getScriptInfoForPath(path); + if (!info) { + const isDynamic = isDynamicFileName(fileName); + // If the file is not opened by client and the file doesnot exist on the disk, return + if (!openedByClient && !isDynamic && !(hostToQueryFileExistsOn || this.host).fileExists(fileName)) { + return; } - } - if (info) { - if (openedByClient && !info.isScriptOpen()) { - info.stopWatcher(); - info.open(fileContent); - if (hasMixedContent) { - info.registerFileUpdate(); - } + info = new ScriptInfo(this.host, fileName, scriptKind, hasMixedContent, path); + this.filenameToScriptInfo.set(info.path, info); + if (!openedByClient) { + this.watchClosedScriptInfo(info); } - else if (fileContent !== undefined) { - info.reload(fileContent); + } + if (openedByClient && !info.isScriptOpen()) { + // Opening closed script info + // either it was created just now, or was part of projects but was closed + this.stopWatchingScriptInfo(info); + info.open(fileContent); + if (hasMixedContent) { + info.registerFileUpdate(); } } + else { + Debug.assert(fileContent === undefined); + } return info; } getScriptInfoForNormalizedPath(fileName: NormalizedPath) { - return this.getScriptInfoForPath(normalizedPathToPath(fileName, this.host.getCurrentDirectory(), this.toCanonicalFileName)); + return this.getScriptInfoForPath(normalizedPathToPath(fileName, this.currentDirectory, this.toCanonicalFileName)); } getScriptInfoForPath(fileName: Path) { return this.filenameToScriptInfo.get(fileName); } - setHostConfiguration(args: protocol.ConfigureRequestArguments) { if (args.file) { const info = this.getScriptInfoForNormalizedPath(toNormalizedPath(args.file)); @@ -1518,6 +1737,9 @@ namespace ts.server { } if (args.extraFileExtensions) { this.hostConfiguration.extraFileExtensions = args.extraFileExtensions; + // We need to update the project structures again as it is possible that existing + // project structure could have more or less files depending on extensions permitted + this.reloadProjects(); this.logger.info("Host file extension mappings updated"); } } @@ -1529,50 +1751,129 @@ namespace ts.server { /** * This function rebuilds the project for every file opened by the client + * This does not reload contents of open files from disk. But we could do that if needed */ reloadProjects() { this.logger.info("reload projects."); + // If we want this to also reload open files from disk, we could do that, + // but then we need to make sure we arent calling this function + // (and would separate out below reloading of projects to be called when immediate reload is needed) + // as there is no need to load contents of the files from the disk + + // Reload Projects + this.reloadConfiguredProjectForFiles(this.openFiles, /*delayReload*/ false); + this.refreshInferredProjects(); + } + + private delayReloadConfiguredProjectForFiles(configFileExistenceInfo: ConfigFileExistenceInfo, ignoreIfNotRootOfInferredProject: boolean) { + // Get open files to reload projects for + const openFiles = mapDefinedIter( + configFileExistenceInfo.openFilesImpactedByConfigFile.entries(), + ([path, isRootOfInferredProject]) => { + if (!ignoreIfNotRootOfInferredProject || isRootOfInferredProject) { + const info = this.getScriptInfoForPath(path as Path); + Debug.assert(!!info); + return info; + } + } + ); + this.reloadConfiguredProjectForFiles(openFiles, /*delayReload*/ true); + this.delayInferredProjectsRefresh(); + } + + /** + * This function goes through all the openFiles and tries to file the config file for them. + * If the config file is found and it refers to existing project, it reloads it either immediately + * or schedules it for reload depending on delayReload option + * If the there is no existing project it just opens the configured project for the config file + */ + private reloadConfiguredProjectForFiles(openFiles: ReadonlyArray, delayReload: boolean) { + const updatedProjects = createMap(); // try to reload config file for all open files - for (const info of this.openFiles) { - this.openOrUpdateConfiguredProjectForFile(info.fileName); + for (const info of openFiles) { + // This tries to search for a tsconfig.json for the given file. If we found it, + // we first detect if there is already a configured project created for it: if so, + // we re- read the tsconfig file content and update the project only if we havent already done so + // otherwise we create a new one. + const configFileName = this.getConfigFileNameForFile(info); + if (configFileName) { + const project = this.findConfiguredProjectByProjectName(configFileName); + if (!project) { + this.createConfiguredProject(configFileName); + updatedProjects.set(configFileName, true); + } + else if (!updatedProjects.has(configFileName)) { + if (delayReload) { + project.pendingReload = true; + this.delayUpdateProjectGraph(project); + } + else { + this.reloadConfiguredProject(project); + } + updatedProjects.set(configFileName, true); + } + } + } + } + + /** + * Remove the root of inferred project if script info is part of another project + */ + private removeRootOfInferredProjectIfNowPartOfOtherProject(info: ScriptInfo) { + // If the script info is root of inferred project, it could only be first containing project + // since info is added as root to the inferred project only when there are no other projects containing it + // So when it is root of the inferred project and after project structure updates its now part + // of multiple project it needs to be removed from that inferred project because: + // - references in inferred project supercede the root part + // - root / reference in non - inferred project beats root in inferred project + + // eg. say this is structure /a/b/a.ts /a/b/c.ts where c.ts references a.ts + // When a.ts is opened, since there is no configured project/external project a.ts can be part of + // a.ts is added as root to inferred project. + // Now at time of opening c.ts, c.ts is also not aprt of any existing project, + // so it will be added to inferred project as a root. (for sake of this example assume single inferred project is false) + // So at this poing a.ts is part of first inferred project and second inferred project (of which c.ts is root) + // And hence it needs to be removed from the first inferred project. + if (info.containingProjects.length > 1 && + info.containingProjects[0].projectKind === ProjectKind.Inferred && + info.containingProjects[0].isRoot(info)) { + const inferredProject = info.containingProjects[0] as InferredProject; + if (inferredProject.isProjectWithSingleRoot()) { + this.removeProject(inferredProject); + } + else { + inferredProject.removeFile(info, /*fileExists*/ true, /*detachFromProject*/ true); + } } - this.refreshInferredProjects(); } /** - * This function is to update the project structure for every projects. + * This function is to update the project structure for every inferred project. * It is called on the premise that all the configured projects are * up to date. + * This will go through open files and assign them to inferred project if open file is not part of any other project + * After that all the inferred project graphs are updated */ - refreshInferredProjects() { - this.logger.info("updating project structure from ..."); + private refreshInferredProjects() { + this.logger.info("refreshInferredProjects: updating project structure from ..."); this.printProjects(); - const orphantedFiles: ScriptInfo[] = []; - // collect all orphanted script infos from open files for (const info of this.openFiles) { - if (info.containingProjects.length === 0) { - orphantedFiles.push(info); + // collect all orphaned script infos from open files + if (info.isOrphan()) { + this.assignOrphanScriptInfoToInferredProject(info); } else { - if (isRootFileInInferredProject(info) && info.containingProjects.length > 1) { - const inferredProject = info.containingProjects[0]; - Debug.assert(inferredProject.projectKind === ProjectKind.Inferred); - inferredProject.removeFile(info); - if (!inferredProject.hasRoots()) { - this.removeProject(inferredProject); - } - } + // Or remove the root of inferred project if is referenced in more than one projects + this.removeRootOfInferredProjectIfNowPartOfOtherProject(info); } } - for (const f of orphantedFiles) { - this.assignScriptInfoToInferredProjectIfNecessary(f, /*addToListOfOpenFiles*/ false); - } for (const p of this.inferredProjects) { p.updateGraph(); } + this.logger.info("refreshInferredProjects: updated project structure ..."); this.printProjects(); } @@ -1589,11 +1890,23 @@ namespace ts.server { let configFileName: NormalizedPath; let configFileErrors: ReadonlyArray; + const info = this.getOrCreateScriptInfoOpenedByClientForNormalizedPath(fileName, fileContent, scriptKind, hasMixedContent); let project: ConfiguredProject | ExternalProject = this.findContainingExternalProject(fileName); if (!project) { - ({ configFileName, configFileErrors } = this.openOrUpdateConfiguredProjectForFile(fileName, projectRootPath)); + configFileName = this.getConfigFileNameForFile(info, projectRootPath); if (configFileName) { project = this.findConfiguredProjectByProjectName(configFileName); + if (!project) { + project = this.createConfiguredProject(configFileName); + + // even if opening config file was successful, it could still + // contain errors that were tolerated. + const errors = project.getGlobalProjectErrors(); + if (errors && errors.length > 0) { + // set configFileErrors only when the errors array is non-empty + configFileErrors = errors; + } + } } } if (project && !project.languageServiceEnabled) { @@ -1603,9 +1916,22 @@ namespace ts.server { project.markAsDirty(); } - // at this point if file is the part of some configured/external project then this project should be created - const info = this.getOrCreateScriptInfoForNormalizedPath(fileName, /*openedByClient*/ true, fileContent, scriptKind, hasMixedContent); - this.assignScriptInfoToInferredProjectIfNecessary(info, /*addToListOfOpenFiles*/ true, projectRootPath); + // At this point if file is part of any any configured or external project, then it would be present in the containing projects + // So if it still doesnt have any containing projects, it needs to be part of inferred project + if (info.isOrphan()) { + this.assignOrphanScriptInfoToInferredProject(info, projectRootPath); + } + this.addToListOfOpenFiles(info); + + // Remove the configured projects that have zero references from open files. + // This was postponed from closeOpenFile to after opening next file, + // so that we can reuse the project if we need to right away + this.configuredProjects.forEach(project => { + if (!project.hasOpenRef()) { + this.removeProject(project); + } + }); + // Delete the orphan files here because there might be orphan script infos (which are not part of project) // when some file/s were closed which resulted in project removal. // It was then postponed to cleanup these script infos so that they can be reused if @@ -1638,14 +1964,13 @@ namespace ts.server { synchronizeProjectList(knownProjects: protocol.ProjectVersionInfo[]): ProjectFilesWithTSDiagnostics[] { const files: ProjectFilesWithTSDiagnostics[] = []; this.collectChanges(knownProjects, this.externalProjects, files); - this.collectChanges(knownProjects, this.configuredProjects, files); + this.collectChanges(knownProjects, arrayFrom(this.configuredProjects.values()), files); this.collectChanges(knownProjects, this.inferredProjects, files); return files; } /* @internal */ applyChangesInOpenFiles(openFiles: protocol.ExternalFile[], changedFiles: protocol.ChangedOpenFile[], closedFiles: string[]): void { - const recordChangedFiles = changedFiles && !openFiles && !closedFiles; if (openFiles) { for (const file of openFiles) { const scriptInfo = this.getScriptInfo(file.fileName); @@ -1659,19 +1984,7 @@ namespace ts.server { for (const file of changedFiles) { const scriptInfo = this.getScriptInfo(file.fileName); Debug.assert(!!scriptInfo); - // apply changes in reverse order - for (let i = file.changes.length - 1; i >= 0; i--) { - const change = file.changes[i]; - scriptInfo.editContent(change.span.start, change.span.start + change.span.length, change.newText); - } - if (recordChangedFiles) { - if (!this.changedFiles) { - this.changedFiles = [scriptInfo]; - } - else if (this.changedFiles.indexOf(scriptInfo) < 0) { - this.changedFiles.push(scriptInfo); - } - } + this.applyChangesToFile(scriptInfo, file.changes); } } @@ -1683,7 +1996,22 @@ namespace ts.server { // if files were open or closed then explicitly refresh list of inferred projects // otherwise if there were only changes in files - record changed files in `changedFiles` and defer the update if (openFiles || closedFiles) { - this.refreshInferredProjects(); + this.ensureProjectStructuresUptoDate(/*refreshInferredProjects*/ true); + } + } + + /* @internal */ + applyChangesToFile(scriptInfo: ScriptInfo, changes: TextChange[]) { + // apply changes in reverse order + for (let i = changes.length - 1; i >= 0; i--) { + const change = changes[i]; + scriptInfo.editContent(change.span.start, change.span.start + change.span.length, change.newText); + } + if (!this.changedFiles) { + this.changedFiles = [scriptInfo]; + } + else if (!contains(this.changedFiles, scriptInfo)) { + this.changedFiles.push(scriptInfo); } } @@ -1708,7 +2036,7 @@ namespace ts.server { } this.externalProjectToConfiguredProjectMap.delete(fileName); if (shouldRefreshInferredProjects && !suppressRefresh) { - this.refreshInferredProjects(); + this.ensureProjectStructuresUptoDate(/*refreshInferredProjects*/ true); } } else { @@ -1717,7 +2045,7 @@ namespace ts.server { if (externalProject) { this.removeProject(externalProject); if (!suppressRefresh) { - this.refreshInferredProjects(); + this.ensureProjectStructuresUptoDate(/*refreshInferredProjects*/ true); } } } @@ -1741,7 +2069,7 @@ namespace ts.server { this.closeExternalProject(externalProjectName, /*suppressRefresh*/ true); }); - this.refreshInferredProjects(); + this.ensureProjectStructuresUptoDate(/*refreshInferredProjects*/ true); } /** Makes a filename safe to insert in a RegExp */ @@ -1785,7 +2113,7 @@ namespace ts.server { // RegExp group numbers are 1-based, but the first element in groups // is actually the original string, so it all works out in the end. if (typeof groupNumberOrString === "number") { - if (typeof groups[groupNumberOrString] !== "string") { + if (!isString(groups[groupNumberOrString])) { // Specification was wrong - exclude nothing! this.logger.info(`Incorrect RegExp specification in safelist rule ${name} - not enough groups`); // * can't appear in a filename; escape it because it's feeding into a RegExp @@ -1847,7 +2175,7 @@ namespace ts.server { const rootFiles: protocol.ExternalFile[] = []; for (const file of proj.rootFiles) { const normalized = toNormalizedPath(file.fileName); - if (getBaseFileName(normalized) === "tsconfig.json") { + if (getBaseConfigFileName(normalized)) { if (this.host.fileExists(normalized)) { (tsConfigFiles || (tsConfigFiles = [])).push(normalized); } @@ -1875,7 +2203,7 @@ namespace ts.server { externalProject.enableLanguageService(); } // external project already exists and not config files were added - update the project and return; - this.updateNonInferredProject(externalProject, proj.rootFiles, externalFilePropertyReader, compilerOptions, proj.typeAcquisition, proj.options.compileOnSave, /*configFileErrors*/ undefined); + this.updateNonInferredProject(externalProject, proj.rootFiles, externalFilePropertyReader, compilerOptions, proj.typeAcquisition, proj.options.compileOnSave); return; } // some config files were added to external project (that previously were not there) @@ -1922,9 +2250,8 @@ namespace ts.server { for (const tsconfigFile of tsConfigFiles) { let project = this.findConfiguredProjectByProjectName(tsconfigFile); if (!project) { - const result = this.openConfigFile(tsconfigFile); - // TODO: save errors - project = result.success && result.project; + // errors are stored in the project + project = this.createConfiguredProject(tsconfigFile); } if (project && !contains(exisingConfigFiles, tsconfigFile)) { // keep project alive even if no documents are opened - its lifetime is bound to the lifetime of containing external project @@ -1935,11 +2262,11 @@ namespace ts.server { else { // no config files - remove the item from the collection this.externalProjectToConfiguredProjectMap.delete(proj.projectFileName); - const newProj = this.createAndAddExternalProject(proj.projectFileName, rootFiles, proj.options, proj.typeAcquisition); + const newProj = this.createExternalProject(proj.projectFileName, rootFiles, proj.options, proj.typeAcquisition); newProj.excludedFiles = excludedFiles; } if (!suppressRefreshOfInferredProjects) { - this.refreshInferredProjects(); + this.ensureProjectStructuresUptoDate(/*refreshInferredProjects*/ true); } } } diff --git a/src/server/lsHost.ts b/src/server/lsHost.ts deleted file mode 100644 index 13b9505a658c0..0000000000000 --- a/src/server/lsHost.ts +++ /dev/null @@ -1,249 +0,0 @@ -/// -/// -/// - -namespace ts.server { - export class LSHost implements LanguageServiceHost, ModuleResolutionHost { - private compilationSettings: CompilerOptions; - private readonly resolvedModuleNames = createMap>(); - private readonly resolvedTypeReferenceDirectives = createMap>(); - private readonly getCanonicalFileName: (fileName: string) => string; - - private filesWithChangedSetOfUnresolvedImports: Path[]; - - private resolveModuleName: typeof resolveModuleName; - readonly trace: (s: string) => void; - readonly realpath?: (path: string) => string; - - constructor(private readonly host: ServerHost, private project: Project, private readonly cancellationToken: HostCancellationToken) { - this.cancellationToken = new ThrottledCancellationToken(cancellationToken, project.projectService.throttleWaitMilliseconds); - this.getCanonicalFileName = createGetCanonicalFileName(this.host.useCaseSensitiveFileNames); - - if (host.trace) { - this.trace = s => host.trace(s); - } - - this.resolveModuleName = (moduleName, containingFile, compilerOptions, host) => { - const globalCache = this.project.getTypeAcquisition().enable - ? this.project.projectService.typingsInstaller.globalTypingsCacheLocation - : undefined; - const primaryResult = resolveModuleName(moduleName, containingFile, compilerOptions, host); - // return result immediately only if it is .ts, .tsx or .d.ts - if (!isExternalModuleNameRelative(moduleName) && !(primaryResult.resolvedModule && extensionIsTypeScript(primaryResult.resolvedModule.extension)) && globalCache !== undefined) { - // otherwise try to load typings from @types - - // create different collection of failed lookup locations for second pass - // if it will fail and we've already found something during the first pass - we don't want to pollute its results - const { resolvedModule, failedLookupLocations } = loadModuleFromGlobalCache(moduleName, this.project.getProjectName(), compilerOptions, host, globalCache); - if (resolvedModule) { - return { resolvedModule, failedLookupLocations: primaryResult.failedLookupLocations.concat(failedLookupLocations) }; - } - } - return primaryResult; - }; - - if (this.host.realpath) { - this.realpath = path => this.host.realpath(path); - } - } - - dispose() { - this.project = undefined; - this.resolveModuleName = undefined; - } - - public startRecordingFilesWithChangedResolutions() { - this.filesWithChangedSetOfUnresolvedImports = []; - } - - public finishRecordingFilesWithChangedResolutions() { - const collected = this.filesWithChangedSetOfUnresolvedImports; - this.filesWithChangedSetOfUnresolvedImports = undefined; - return collected; - } - - private resolveNamesWithLocalCache( - names: string[], - containingFile: string, - cache: Map>, - loader: (name: string, containingFile: string, options: CompilerOptions, host: ModuleResolutionHost) => T, - getResult: (s: T) => R, - getResultFileName: (result: R) => string | undefined, - logChanges: boolean): R[] { - - const path = toPath(containingFile, this.host.getCurrentDirectory(), this.getCanonicalFileName); - const currentResolutionsInFile = cache.get(path); - - const newResolutions: Map = createMap(); - const resolvedModules: R[] = []; - const compilerOptions = this.getCompilationSettings(); - const lastDeletedFileName = this.project.projectService.lastDeletedFile && this.project.projectService.lastDeletedFile.fileName; - - for (const name of names) { - // check if this is a duplicate entry in the list - let resolution = newResolutions.get(name); - if (!resolution) { - const existingResolution = currentResolutionsInFile && currentResolutionsInFile.get(name); - if (moduleResolutionIsValid(existingResolution)) { - // ok, it is safe to use existing name resolution results - resolution = existingResolution; - } - else { - resolution = loader(name, containingFile, compilerOptions, this); - newResolutions.set(name, resolution); - } - if (logChanges && this.filesWithChangedSetOfUnresolvedImports && !resolutionIsEqualTo(existingResolution, resolution)) { - this.filesWithChangedSetOfUnresolvedImports.push(path); - // reset log changes to avoid recording the same file multiple times - logChanges = false; - } - } - - Debug.assert(resolution !== undefined); - - resolvedModules.push(getResult(resolution)); - } - - // replace old results with a new one - cache.set(path, newResolutions); - return resolvedModules; - - function resolutionIsEqualTo(oldResolution: T, newResolution: T): boolean { - if (oldResolution === newResolution) { - return true; - } - if (!oldResolution || !newResolution) { - return false; - } - const oldResult = getResult(oldResolution); - const newResult = getResult(newResolution); - if (oldResult === newResult) { - return true; - } - if (!oldResult || !newResult) { - return false; - } - return getResultFileName(oldResult) === getResultFileName(newResult); - } - - function moduleResolutionIsValid(resolution: T): boolean { - if (!resolution) { - return false; - } - - const result = getResult(resolution); - if (result) { - return getResultFileName(result) !== lastDeletedFileName; - } - - // consider situation if we have no candidate locations as valid resolution. - // after all there is no point to invalidate it if we have no idea where to look for the module. - return resolution.failedLookupLocations.length === 0; - } - } - - getNewLine() { - return this.host.newLine; - } - - getProjectVersion() { - return this.project.getProjectVersion(); - } - - getCompilationSettings() { - return this.compilationSettings; - } - - useCaseSensitiveFileNames() { - return this.host.useCaseSensitiveFileNames; - } - - getCancellationToken() { - return this.cancellationToken; - } - - resolveTypeReferenceDirectives(typeDirectiveNames: string[], containingFile: string): ResolvedTypeReferenceDirective[] { - return this.resolveNamesWithLocalCache(typeDirectiveNames, containingFile, this.resolvedTypeReferenceDirectives, resolveTypeReferenceDirective, - m => m.resolvedTypeReferenceDirective, r => r.resolvedFileName, /*logChanges*/ false); - } - - resolveModuleNames(moduleNames: string[], containingFile: string): ResolvedModuleFull[] { - return this.resolveNamesWithLocalCache(moduleNames, containingFile, this.resolvedModuleNames, this.resolveModuleName, - m => m.resolvedModule, r => r.resolvedFileName, /*logChanges*/ true); - } - - getDefaultLibFileName() { - const nodeModuleBinDir = getDirectoryPath(normalizePath(this.host.getExecutingFilePath())); - return combinePaths(nodeModuleBinDir, getDefaultLibFileName(this.compilationSettings)); - } - - getScriptSnapshot(filename: string): IScriptSnapshot { - const scriptInfo = this.project.getScriptInfoLSHost(filename); - if (scriptInfo) { - return scriptInfo.getSnapshot(); - } - } - - getScriptFileNames() { - return this.project.getRootFilesLSHost(); - } - - getTypeRootsVersion() { - return this.project.typesVersion; - } - - getScriptKind(fileName: string) { - const info = this.project.getScriptInfoLSHost(fileName); - return info && info.scriptKind; - } - - getScriptVersion(filename: string) { - const info = this.project.getScriptInfoLSHost(filename); - return info && info.getLatestVersion(); - } - - getCurrentDirectory(): string { - return this.host.getCurrentDirectory(); - } - - resolvePath(path: string): string { - return this.host.resolvePath(path); - } - - fileExists(file: string): boolean { - // As an optimization, don't hit the disks for files we already know don't exist - // (because we're watching for their creation). - const path = toPath(file, this.host.getCurrentDirectory(), this.getCanonicalFileName); - return !this.project.isWatchedMissingFile(path) && this.host.fileExists(file); - } - - readFile(fileName: string): string | undefined { - return this.host.readFile(fileName); - } - - directoryExists(path: string): boolean { - return this.host.directoryExists(path); - } - - readDirectory(path: string, extensions?: ReadonlyArray, exclude?: ReadonlyArray, include?: ReadonlyArray, depth?: number): string[] { - return this.host.readDirectory(path, extensions, exclude, include, depth); - } - - getDirectories(path: string): string[] { - return this.host.getDirectories(path); - } - - notifyFileRemoved(info: ScriptInfo) { - this.resolvedModuleNames.delete(info.path); - this.resolvedTypeReferenceDirectives.delete(info.path); - } - - setCompilationSettings(opt: CompilerOptions) { - if (changesAffectModuleResolution(this.compilationSettings, opt)) { - this.resolvedModuleNames.clear(); - this.resolvedTypeReferenceDirectives.clear(); - } - this.compilationSettings = opt; - } - } -} diff --git a/src/server/project.ts b/src/server/project.ts index 28d029cf58049..27f0744537ac3 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -1,9 +1,9 @@ /// /// /// -/// +/// /// -/// +/// namespace ts.server { @@ -102,12 +102,22 @@ namespace ts.server { (mod: { typescript: typeof ts }): PluginModule; } - export abstract class Project { + /** + * The project root can be script info - if root is present, + * or it could be just normalized path if root wasnt present on the host(only for non inferred project) + */ + export type ProjectRoot = ScriptInfo | NormalizedPath; + /* @internal */ + export function isScriptInfo(value: ProjectRoot): value is ScriptInfo { + return value instanceof ScriptInfo; + } + + export abstract class Project implements LanguageServiceHost, ModuleResolutionHost { private rootFiles: ScriptInfo[] = []; - private rootFilesMap: Map = createMap(); + private rootFilesMap: Map = createMap(); private program: Program; private externalFiles: SortedReadonlyArray; - private missingFilesMap: Map = createMap(); + private missingFilesMap: Map; private cachedUnresolvedImportsPerFile = new UnresolvedImportsMap(); private lastCachedUnresolvedImportsList: SortedReadonlyArray; @@ -117,9 +127,16 @@ namespace ts.server { public languageServiceEnabled = true; - protected lsHost: LSHost; + readonly trace?: (s: string) => void; + readonly realpath?: (path: string) => string; - builder: Builder; + /*@internal*/ + hasInvalidatedResolution: HasInvalidatedResolution; + + /*@internal*/ + resolutionCache: ResolutionCache; + + private builder: Builder; /** * Set of files names that were updated since the last call to getChangesSinceVersion. */ @@ -145,11 +162,10 @@ namespace ts.server { */ private projectStateVersion = 0; - private typingFiles: SortedReadonlyArray; - - protected projectErrors: ReadonlyArray; + /*@internal*/ + hasChangedAutomaticTypeDirectiveNames = false; - public typesVersion = 0; + private typingFiles: SortedReadonlyArray; public isNonTsProject() { this.updateGraph(); @@ -177,15 +193,18 @@ namespace ts.server { return result.module; } + /*@internal*/ constructor( - private readonly projectName: string, + /*@internal*/readonly projectName: string, readonly projectKind: ProjectKind, readonly projectService: ProjectService, private documentRegistry: DocumentRegistry, hasExplicitListOfFiles: boolean, languageServiceEnabled: boolean, private compilerOptions: CompilerOptions, - public compileOnSaveEnabled: boolean) { + public compileOnSaveEnabled: boolean, + /*@internal*/public directoryStructureHost: DirectoryStructureHost, + rootDirectoryForResolution: string | undefined) { if (!this.compilerOptions) { this.compilerOptions = getDefaultCompilerOptions(); @@ -198,20 +217,180 @@ namespace ts.server { } this.setInternalCompilerOptionsForEmittingJsFiles(); + const host = this.projectService.host; + if (host.trace) { + this.trace = s => host.trace(s); + } - this.lsHost = new LSHost(this.projectService.host, this, this.projectService.cancellationToken); - this.lsHost.setCompilationSettings(this.compilerOptions); - - this.languageService = createLanguageService(this.lsHost, this.documentRegistry); + if (host.realpath) { + this.realpath = path => host.realpath(path); + } + this.languageService = createLanguageService(this, this.documentRegistry); + this.resolutionCache = createResolutionCache(this, rootDirectoryForResolution); if (!languageServiceEnabled) { this.disableLanguageService(); } - - this.builder = createBuilder(this); this.markAsDirty(); } + getCompilationSettings() { + return this.compilerOptions; + } + + getNewLine() { + return this.directoryStructureHost.newLine; + } + + getProjectVersion() { + return this.projectStateVersion.toString(); + } + + getScriptFileNames() { + if (!this.rootFiles) { + return ts.emptyArray; + } + + let result: string[] | undefined; + this.rootFilesMap.forEach(value => { + if (this.languageServiceEnabled || (isScriptInfo(value) && value.isScriptOpen())) { + // if language service is disabled - process only files that are open + (result || (result = [])).push(isScriptInfo(value) ? value.fileName : value); + } + }); + + return addRange(result, this.typingFiles) || ts.emptyArray; + } + + private getOrCreateScriptInfoAndAttachToProject(fileName: string) { + const scriptInfo = this.projectService.getOrCreateScriptInfoNotOpenedByClient(fileName, this.directoryStructureHost); + if (scriptInfo) { + const existingValue = this.rootFilesMap.get(scriptInfo.path); + if (existingValue !== scriptInfo && existingValue !== undefined) { + // This was missing path earlier but now the file exists. Update the root + this.rootFiles.push(scriptInfo); + this.rootFilesMap.set(scriptInfo.path, scriptInfo); + } + scriptInfo.attachToProject(this); + } + return scriptInfo; + } + + getScriptKind(fileName: string) { + const info = this.getOrCreateScriptInfoAndAttachToProject(fileName); + return info && info.scriptKind; + } + + getScriptVersion(filename: string) { + const info = this.getOrCreateScriptInfoAndAttachToProject(filename); + return info && info.getLatestVersion(); + } + + getScriptSnapshot(filename: string): IScriptSnapshot { + const scriptInfo = this.getOrCreateScriptInfoAndAttachToProject(filename); + if (scriptInfo) { + return scriptInfo.getSnapshot(); + } + } + + getCancellationToken() { + return this.projectService.cancellationToken; + } + + getCurrentDirectory(): string { + return this.directoryStructureHost.getCurrentDirectory(); + } + + getDefaultLibFileName() { + const nodeModuleBinDir = getDirectoryPath(normalizePath(this.projectService.host.getExecutingFilePath())); + return combinePaths(nodeModuleBinDir, getDefaultLibFileName(this.compilerOptions)); + } + + useCaseSensitiveFileNames() { + return this.directoryStructureHost.useCaseSensitiveFileNames; + } + + readDirectory(path: string, extensions?: ReadonlyArray, exclude?: ReadonlyArray, include?: ReadonlyArray, depth?: number): string[] { + return this.directoryStructureHost.readDirectory(path, extensions, exclude, include, depth); + } + + readFile(fileName: string): string | undefined { + return this.directoryStructureHost.readFile(fileName); + } + + fileExists(file: string): boolean { + // As an optimization, don't hit the disks for files we already know don't exist + // (because we're watching for their creation). + const path = this.toPath(file); + return !this.isWatchedMissingFile(path) && this.directoryStructureHost.fileExists(file); + } + + resolveModuleNames(moduleNames: string[], containingFile: string, reusedNames?: string[]): ResolvedModuleFull[] { + return this.resolutionCache.resolveModuleNames(moduleNames, containingFile, reusedNames, /*logChanges*/ true); + } + + resolveTypeReferenceDirectives(typeDirectiveNames: string[], containingFile: string): ResolvedTypeReferenceDirective[] { + return this.resolutionCache.resolveTypeReferenceDirectives(typeDirectiveNames, containingFile); + } + + directoryExists(path: string): boolean { + return this.directoryStructureHost.directoryExists(path); + } + + getDirectories(path: string): string[] { + return this.directoryStructureHost.getDirectories(path); + } + + /*@internal*/ + toPath(fileName: string) { + return this.projectService.toPath(fileName); + } + + /*@internal*/ + watchDirectoryOfFailedLookupLocation(directory: string, cb: DirectoryWatcherCallback, flags: WatchDirectoryFlags) { + return this.projectService.watchDirectory( + this.projectService.host, + directory, + cb, + flags, + WatchType.FailedLookupLocation, + this + ); + } + + /*@internal*/ + onInvalidatedResolution() { + this.projectService.delayUpdateProjectGraphAndInferredProjectsRefresh(this); + } + + /*@internal*/ + watchTypeRootsDirectory(directory: string, cb: DirectoryWatcherCallback, flags: WatchDirectoryFlags) { + return this.projectService.watchDirectory( + this.projectService.host, + directory, + cb, + flags, + WatchType.TypeRoots, + this + ); + } + + /*@internal*/ + onChangedAutomaticTypeDirectiveNames() { + this.hasChangedAutomaticTypeDirectiveNames = true; + this.projectService.delayUpdateProjectGraphAndInferredProjectsRefresh(this); + } + + /*@internal*/ + getGlobalCache() { + return this.getTypeAcquisition().enable ? this.projectService.typingsInstaller.globalTypingsCacheLocation : undefined; + } + + /*@internal*/ + writeLog(s: string) { + this.projectService.logger.info(s); + } + private setInternalCompilerOptionsForEmittingJsFiles() { if (this.projectKind === ProjectKind.Inferred || this.projectKind === ProjectKind.External) { this.compilerOptions.noEmitForJsFiles = true; @@ -221,12 +400,12 @@ namespace ts.server { /** * Get the errors that dont have any file name associated */ - getGlobalProjectErrors() { - return filter(this.projectErrors, diagnostic => !diagnostic.file); + getGlobalProjectErrors(): ReadonlyArray { + return emptyArray; } - getAllProjectErrors() { - return this.projectErrors; + getAllProjectErrors(): ReadonlyArray { + return emptyArray; } getLanguageService(ensureSynchronized = true): LanguageService { @@ -236,16 +415,41 @@ namespace ts.server { return this.languageService; } + private ensureBuilder() { + if (!this.builder) { + this.builder = createBuilder( + this.projectService.toCanonicalFileName, + (_program, sourceFile, emitOnlyDts, isDetailed) => this.getFileEmitOutput(sourceFile, emitOnlyDts, isDetailed), + data => this.projectService.host.createHash(data), + sourceFile => !this.projectService.getScriptInfoForPath(sourceFile.path).isDynamicOrHasMixedContent() + ); + } + } + getCompileOnSaveAffectedFileList(scriptInfo: ScriptInfo): string[] { if (!this.languageServiceEnabled) { return []; } this.updateGraph(); - return this.builder.getFilesAffectedBy(scriptInfo); + this.ensureBuilder(); + return this.builder.getFilesAffectedBy(this.program, scriptInfo.path); } - getProjectVersion() { - return this.projectStateVersion.toString(); + /** + * Returns true if emit was conducted + */ + emitFile(scriptInfo: ScriptInfo, writeFile: (path: string, data: string, writeByteOrderMark?: boolean) => void): boolean { + this.ensureBuilder(); + const { emitSkipped, outputFiles } = this.builder.emitFile(this.program, scriptInfo.path); + if (!emitSkipped) { + const projectRootPath = this.getProjectRootPath(); + for (const outputFile of outputFiles) { + const outputFileAbsoluteFileName = getNormalizedAbsolutePath(outputFile.name, projectRootPath ? projectRootPath : getDirectoryPath(scriptInfo.fileName)); + writeFile(outputFileAbsoluteFileName, outputFile.text, outputFile.writeByteOrderMark); + } + } + + return !emitSkipped; } enableLanguageService() { @@ -262,6 +466,7 @@ namespace ts.server { } this.languageService.cleanupSemanticCache(); this.languageServiceEnabled = false; + this.resolutionCache.closeTypeRootsWatch(); this.projectService.onUpdateLanguageServiceStateForProject(this, /*languageServiceEnabled*/ false); } @@ -282,18 +487,16 @@ namespace ts.server { return this.program.getSourceFileByPath(path); } - updateTypes() { - this.typesVersion++; - this.markAsDirty(); - this.updateGraph(); - } - close() { if (this.program) { // if we have a program - release all files that are enlisted in program for (const f of this.program.getSourceFiles()) { const info = this.projectService.getScriptInfo(f.fileName); - info.detachFromProject(this); + // We might not find the script info in case its not associated with the project any more + // and project graph was not updated (eg delayed update graph in case of files changed/deleted on the disk) + if (info) { + info.detachFromProject(this); + } } } if (!this.program || !this.languageServiceEnabled) { @@ -307,22 +510,24 @@ namespace ts.server { this.rootFilesMap = undefined; this.program = undefined; this.builder = undefined; + this.resolutionCache.clear(); + this.resolutionCache = undefined; this.cachedUnresolvedImportsPerFile = undefined; - this.projectErrors = undefined; - this.lsHost.dispose(); - this.lsHost = undefined; + this.directoryStructureHost = undefined; // Clean up file watchers waiting for missing files - this.missingFilesMap.forEach(fileWatcher => fileWatcher.close()); - this.missingFilesMap = undefined; + if (this.missingFilesMap) { + clearMap(this.missingFilesMap, closeFileWatcher); + this.missingFilesMap = undefined; + } // signal language service to release source files acquired from document registry this.languageService.dispose(); this.languageService = undefined; } - getCompilerOptions() { - return this.compilerOptions; + isClosed() { + return this.rootFiles === undefined; } hasRoots() { @@ -333,22 +538,9 @@ namespace ts.server { return this.rootFiles && this.rootFiles.map(info => info.fileName); } - getRootFilesLSHost() { - const result: string[] = []; - if (this.rootFiles) { - for (const f of this.rootFiles) { - if (this.languageServiceEnabled || f.isScriptOpen()) { - // if language service is disabled - process only files that are open - result.push(f.fileName); - } - } - if (this.typingFiles) { - for (const f of this.typingFiles) { - result.push(f); - } - } - } - return result; + /*@internal*/ + getRootFilesMap() { + return this.rootFilesMap; } getRootScriptInfos() { @@ -369,11 +561,11 @@ namespace ts.server { }); } - getFileEmitOutput(info: ScriptInfo, emitOnlyDtsFiles: boolean) { + private getFileEmitOutput(sourceFile: SourceFile, emitOnlyDtsFiles: boolean, isDetailed: boolean) { if (!this.languageServiceEnabled) { return undefined; } - return this.getLanguageService().getEmitOutput(info.fileName, emitOnlyDtsFiles); + return this.getLanguageService(/*ensureSynchronized*/ false).getEmitOutput(sourceFile.fileName, emitOnlyDtsFiles, isDetailed); } getExcludedFiles(): ReadonlyArray { @@ -436,21 +628,6 @@ namespace ts.server { return false; } - getAllEmittableFiles() { - if (!this.languageServiceEnabled) { - return []; - } - const defaultLibraryFileName = getDefaultLibFileName(this.compilerOptions); - const infos = this.getScriptInfos(); - const result: string[] = []; - for (const info of infos) { - if (getBaseFileName(info.fileName) !== defaultLibraryFileName && shouldEmitFile(info)) { - result.push(info.fileName); - } - } - return result; - } - containsScriptInfo(info: ScriptInfo): boolean { return this.isRoot(info) || (this.program && this.program.getSourceFileByPath(info.path) !== undefined); } @@ -463,25 +640,37 @@ namespace ts.server { } isRoot(info: ScriptInfo) { - return this.rootFilesMap && this.rootFilesMap.has(info.path); + return this.rootFilesMap && this.rootFilesMap.get(info.path) === info; } // add a root file to project addRoot(info: ScriptInfo) { - if (!this.isRoot(info)) { - this.rootFiles.push(info); - this.rootFilesMap.set(info.path, info); - info.attachToProject(this); + Debug.assert(!this.isRoot(info)); + this.rootFiles.push(info); + this.rootFilesMap.set(info.path, info); + info.attachToProject(this); - this.markAsDirty(); - } + this.markAsDirty(); + } + + // add a root file that doesnt exist on host + addMissingFileRoot(fileName: NormalizedPath) { + const path = this.projectService.toPath(fileName); + this.rootFilesMap.set(path, fileName); + this.markAsDirty(); } - removeFile(info: ScriptInfo, detachFromProject = true) { + removeFile(info: ScriptInfo, fileExists: boolean, detachFromProject: boolean) { if (this.isRoot(info)) { this.removeRoot(info); } - this.lsHost.notifyFileRemoved(info); + if (fileExists) { + // If file is present, just remove the resolutions for the file + this.resolutionCache.removeResolutionsOfFile(info.path); + } + else { + this.resolutionCache.invalidateResolutionOfFile(info.path); + } this.cachedUnresolvedImportsPerFile.remove(info.path); if (detachFromProject) { @@ -536,11 +725,12 @@ namespace ts.server { * @returns: true if set of files in the project stays the same and false - otherwise. */ updateGraph(): boolean { - this.lsHost.startRecordingFilesWithChangedResolutions(); + this.resolutionCache.startRecordingFilesWithChangedResolutions(); + this.hasInvalidatedResolution = this.resolutionCache.createHasInvalidatedResolution(); let hasChanges = this.updateGraphWorker(); - const changedFiles: ReadonlyArray = this.lsHost.finishRecordingFilesWithChangedResolutions() || emptyArray; + const changedFiles: ReadonlyArray = this.resolutionCache.finishRecordingFilesWithChangedResolutions() || emptyArray; for (const file of changedFiles) { // delete cached information for changed files @@ -568,12 +758,15 @@ namespace ts.server { if (this.setTypings(cachedTypings)) { hasChanges = this.updateGraphWorker() || hasChanges; } - - this.builder.onProjectUpdateGraph(); + if (this.builder) { + this.builder.updateProgram(this.program); + } } else { this.lastCachedUnresolvedImportsList = undefined; - this.builder.clear(); + if (this.builder) { + this.builder.clear(); + } } if (hasChanges) { @@ -593,13 +786,18 @@ namespace ts.server { private updateGraphWorker() { const oldProgram = this.program; + + this.writeLog(`Starting updateGraphWorker: Project: ${this.getProjectName()}`); + const start = timestamp(); + this.resolutionCache.startCachingPerDirectoryResolution(); this.program = this.languageService.getProgram(); + this.resolutionCache.finishCachingPerDirectoryResolution(); // bump up the version if // - oldProgram is not set - this is a first time updateGraph is called // - newProgram is different from the old program and structure of the old program was not reused. const hasChanges = !oldProgram || (this.program !== oldProgram && !(oldProgram.structureIsReused & StructureIsReused.Completely)); - + this.hasChangedAutomaticTypeDirectiveNames = false; if (hasChanges) { if (oldProgram) { for (const f of oldProgram.getSourceFiles()) { @@ -607,40 +805,21 @@ namespace ts.server { continue; } // new program does not contain this file - detach it from the project - const scriptInfoToDetach = this.projectService.getScriptInfo(f.fileName); - if (scriptInfoToDetach) { - scriptInfoToDetach.detachFromProject(this); - } + this.detachScriptInfoFromProject(f.fileName); } } - const missingFilePaths = this.program.getMissingFilePaths(); - const missingFilePathsSet = arrayToSet(missingFilePaths); - - // Files that are no longer missing (e.g. because they are no longer required) - // should no longer be watched. - this.missingFilesMap.forEach((fileWatcher, missingFilePath) => { - if (!missingFilePathsSet.has(missingFilePath)) { - this.missingFilesMap.delete(missingFilePath); - fileWatcher.close(); - } - }); - - // Missing files that are not yet watched should be added to the map. - for (const missingFilePath of missingFilePaths) { - if (!this.missingFilesMap.has(missingFilePath)) { - const fileWatcher = this.projectService.host.watchFile(missingFilePath, (_filename: string, eventKind: FileWatcherEventKind) => { - if (eventKind === FileWatcherEventKind.Created && this.missingFilesMap.has(missingFilePath)) { - fileWatcher.close(); - this.missingFilesMap.delete(missingFilePath); - - // When a missing file is created, we should update the graph. - this.markAsDirty(); - this.updateGraph(); - } - }); - this.missingFilesMap.set(missingFilePath, fileWatcher); - } + // Update the missing file paths watcher + updateMissingFilePathsWatch( + this.program, + this.missingFilesMap || (this.missingFilesMap = createMap()), + // Watch the missing files + missingFilePath => this.addMissingFileWatcher(missingFilePath) + ); + + // Watch the type locations that would be added to program as part of automatic type resolutions + if (this.languageServiceEnabled) { + this.resolutionCache.updateTypeRootsWatch(); } } @@ -651,33 +830,55 @@ namespace ts.server { // by the LSHost for files in the program when the program is retrieved above but // the program doesn't contain external files so this must be done explicitly. inserted => { - const scriptInfo = this.projectService.getOrCreateScriptInfo(inserted, /*openedByClient*/ false); + const scriptInfo = this.projectService.getOrCreateScriptInfoNotOpenedByClient(inserted, this.directoryStructureHost); scriptInfo.attachToProject(this); }, - removed => { - const scriptInfoToDetach = this.projectService.getScriptInfo(removed); - if (scriptInfoToDetach) { - scriptInfoToDetach.detachFromProject(this); - } - }); - + removed => this.detachScriptInfoFromProject(removed) + ); + const elapsed = timestamp() - start; + this.writeLog(`Finishing updateGraphWorker: Project: ${this.getProjectName()} structureChanged: ${hasChanges} Elapsed: ${elapsed}ms`); return hasChanges; } - isWatchedMissingFile(path: Path) { - return this.missingFilesMap.has(path); + private detachScriptInfoFromProject(uncheckedFileName: string) { + const scriptInfoToDetach = this.projectService.getScriptInfo(uncheckedFileName); + if (scriptInfoToDetach) { + scriptInfoToDetach.detachFromProject(this); + this.resolutionCache.removeResolutionsOfFile(scriptInfoToDetach.path); + } } - getScriptInfoLSHost(fileName: string) { - const scriptInfo = this.projectService.getOrCreateScriptInfo(fileName, /*openedByClient*/ false); - if (scriptInfo) { - scriptInfo.attachToProject(this); - } - return scriptInfo; + private addMissingFileWatcher(missingFilePath: Path) { + const fileWatcher = this.projectService.watchFile( + this.projectService.host, + missingFilePath, + (fileName, eventKind) => { + if (this.projectKind === ProjectKind.Configured) { + (this.directoryStructureHost as CachedDirectoryStructureHost).addOrDeleteFile(fileName, missingFilePath, eventKind); + } + + if (eventKind === FileWatcherEventKind.Created && this.missingFilesMap.has(missingFilePath)) { + this.missingFilesMap.delete(missingFilePath); + fileWatcher.close(); + + // When a missing file is created, we should update the graph. + this.projectService.delayUpdateProjectGraphAndInferredProjectsRefresh(this); + } + }, + WatchType.MissingFilePath, + this + ); + return fileWatcher; + } + + private isWatchedMissingFile(path: Path) { + return this.missingFilesMap && this.missingFilesMap.has(path); } getScriptInfoForNormalizedPath(fileName: NormalizedPath) { - const scriptInfo = this.projectService.getOrCreateScriptInfoForNormalizedPath(fileName, /*openedByClient*/ false); + const scriptInfo = this.projectService.getOrCreateScriptInfoNotOpenedByClientForNormalizedPath( + fileName, /*scriptKind*/ undefined, /*hasMixedContent*/ undefined, this.directoryStructureHost + ); if (scriptInfo && !scriptInfo.isAttached(this)) { return Errors.ThrowProjectDoesNotContainDocument(fileName, this); } @@ -688,13 +889,16 @@ namespace ts.server { return this.getScriptInfoForNormalizedPath(toNormalizedPath(uncheckedFileName)); } - filesToString() { + filesToString(writeProjectFileNames: boolean) { if (!this.program) { - return ""; + return "\tFiles (0)\n"; } - let strBuilder = ""; - for (const file of this.program.getSourceFiles()) { - strBuilder += `\t${file.fileName}\n`; + const sourceFiles = this.program.getSourceFiles(); + let strBuilder = `\tFiles (${sourceFiles.length})\n`; + if (writeProjectFileNames) { + for (const file of sourceFiles) { + strBuilder += `\t${file.fileName}\n`; + } } return strBuilder; } @@ -707,10 +911,12 @@ namespace ts.server { this.cachedUnresolvedImportsPerFile.clear(); this.lastCachedUnresolvedImportsList = undefined; } + const oldOptions = this.compilerOptions; this.compilerOptions = compilerOptions; this.setInternalCompilerOptionsForEmittingJsFiles(); - this.lsHost.setCompilationSettings(compilerOptions); - + if (changesAffectModuleResolution(oldOptions, compilerOptions)) { + this.resolutionCache.clear(); + } this.markAsDirty(); } } @@ -733,7 +939,7 @@ namespace ts.server { projectName: this.getProjectName(), version: this.projectStructureVersion, isInferred: this.projectKind === ProjectKind.Inferred, - options: this.getCompilerOptions(), + options: this.getCompilationSettings(), languageServiceDisabled: !this.languageServiceEnabled }; const updatedFileNames = this.updatedFileNames; @@ -778,59 +984,6 @@ namespace ts.server { } } - getReferencedFiles(path: Path): Path[] { - if (!this.languageServiceEnabled) { - return []; - } - - const sourceFile = this.getSourceFile(path); - if (!sourceFile) { - return []; - } - // We need to use a set here since the code can contain the same import twice, - // but that will only be one dependency. - // To avoid invernal conversion, the key of the referencedFiles map must be of type Path - const referencedFiles = createMap(); - if (sourceFile.imports && sourceFile.imports.length > 0) { - const checker: TypeChecker = this.program.getTypeChecker(); - for (const importName of sourceFile.imports) { - const symbol = checker.getSymbolAtLocation(importName); - if (symbol && symbol.declarations && symbol.declarations[0]) { - const declarationSourceFile = symbol.declarations[0].getSourceFile(); - if (declarationSourceFile) { - referencedFiles.set(declarationSourceFile.path, true); - } - } - } - } - - const currentDirectory = getDirectoryPath(path); - const getCanonicalFileName = createGetCanonicalFileName(this.projectService.host.useCaseSensitiveFileNames); - // Handle triple slash references - if (sourceFile.referencedFiles && sourceFile.referencedFiles.length > 0) { - for (const referencedFile of sourceFile.referencedFiles) { - const referencedPath = toPath(referencedFile.fileName, currentDirectory, getCanonicalFileName); - referencedFiles.set(referencedPath, true); - } - } - - // Handle type reference directives - if (sourceFile.resolvedTypeReferenceDirectiveNames) { - sourceFile.resolvedTypeReferenceDirectiveNames.forEach((resolvedTypeReferenceDirective) => { - if (!resolvedTypeReferenceDirective) { - return; - } - - const fileName = resolvedTypeReferenceDirective.resolvedFileName; - const typeFilePath = toPath(fileName, currentDirectory, getCanonicalFileName); - referencedFiles.set(typeFilePath, true); - }); - } - - const allFileNames = arrayFrom(referencedFiles.keys()) as Path[]; - return filter(allFileNames, file => this.projectService.host.fileExists(file)); - } - // remove a root file from project protected removeRoot(info: ScriptInfo): void { orderedRemoveItem(this.rootFiles, info); @@ -843,8 +996,6 @@ namespace ts.server { * the file and its imports/references are put into an InferredProject. */ export class InferredProject extends Project { - public readonly projectRootPath: string | undefined; - private static readonly newName = (() => { let nextId = 1; return () => { @@ -865,7 +1016,7 @@ namespace ts.server { setCompilerOptions(options?: CompilerOptions) { // Avoid manipulating the given options directly - const newOptions = options ? cloneCompilerOptions(options) : this.getCompilerOptions(); + const newOptions = options ? cloneCompilerOptions(options) : this.getCompilationSettings(); if (!newOptions) { return; } @@ -880,10 +1031,13 @@ namespace ts.server { super.setCompilerOptions(newOptions); } - // Used to keep track of what directories are watched for this project - directoriesWatchedForTsconfig: string[] = []; - - constructor(projectService: ProjectService, documentRegistry: DocumentRegistry, compilerOptions: CompilerOptions, projectRootPath?: string) { + /*@internal*/ + constructor( + projectService: ProjectService, + documentRegistry: DocumentRegistry, + compilerOptions: CompilerOptions, + public readonly projectRootPath: string | undefined, + rootDirectoryForResolution: string | undefined) { super(InferredProject.newName(), ProjectKind.Inferred, projectService, @@ -891,11 +1045,13 @@ namespace ts.server { /*files*/ undefined, /*languageServiceEnabled*/ true, compilerOptions, - /*compileOnSaveEnabled*/ false); - this.projectRootPath = projectRootPath; + /*compileOnSaveEnabled*/ false, + projectService.host, + rootDirectoryForResolution); } addRoot(info: ScriptInfo) { + this.projectService.startWatchingConfigFilesForInferredProjectRoot(info); if (!this._isJsInferredProject && info.isJavaScript()) { this.toggleJsInferredProject(/*isJsInferredProject*/ true); } @@ -903,29 +1059,32 @@ namespace ts.server { } removeRoot(info: ScriptInfo) { + this.projectService.stopWatchingConfigFilesForInferredProjectRoot(info); + super.removeRoot(info); if (this._isJsInferredProject && info.isJavaScript()) { - if (filter(this.getRootScriptInfos(), info => info.isJavaScript()).length === 0) { + if (every(this.getRootScriptInfos(), rootInfo => !rootInfo.isJavaScript())) { this.toggleJsInferredProject(/*isJsInferredProject*/ false); } } - super.removeRoot(info); + } + + isProjectWithSingleRoot() { + // - when useSingleInferredProject is not set and projectRootPath is not set, + // we can guarantee that this will be the only root + // - other wise it has single root if it has single root script info + return (!this.projectRootPath && !this.projectService.useSingleInferredProject) || + this.getRootScriptInfos().length === 1; } getProjectRootPath() { - // Single inferred project does not have a project root. - if (this.projectService.useSingleInferredProject) { - return undefined; - } - const rootFiles = this.getRootFiles(); - return getDirectoryPath(rootFiles[0]); + return this.projectRootPath || + // Single inferred project does not have a project root. + !this.projectService.useSingleInferredProject && getDirectoryPath(this.getRootFiles()[0]); } close() { + forEach(this.getRootScriptInfos(), info => this.projectService.stopWatchingConfigFilesForInferredProjectRoot(info)); super.close(); - - for (const directory of this.directoriesWatchedForTsconfig) { - this.projectService.stopWatchingDirectory(directory); - } } getTypeAcquisition(): TypeAcquisition { @@ -944,37 +1103,72 @@ namespace ts.server { */ export class ConfiguredProject extends Project { private typeAcquisition: TypeAcquisition; - private projectFileWatcher: FileWatcher; - private directoryWatcher: FileWatcher | undefined; - private directoriesWatchedForWildcards: Map | undefined; - private typeRootsWatchers: FileWatcher[] | undefined; + /* @internal */ + configFileWatcher: FileWatcher; + private directoriesWatchedForWildcards: Map | undefined; readonly canonicalConfigFilePath: NormalizedPath; + /* @internal */ + pendingReload: boolean; + + /*@internal*/ + configFileSpecs: ConfigFileSpecs; + private plugins: PluginModule[] = []; /** Used for configured projects which may have multiple open roots */ openRefCount = 0; + private projectErrors: Diagnostic[]; + + /*@internal*/ constructor(configFileName: NormalizedPath, projectService: ProjectService, documentRegistry: DocumentRegistry, hasExplicitListOfFiles: boolean, compilerOptions: CompilerOptions, - private wildcardDirectories: Map, languageServiceEnabled: boolean, - public compileOnSaveEnabled: boolean) { - super(configFileName, ProjectKind.Configured, projectService, documentRegistry, hasExplicitListOfFiles, languageServiceEnabled, compilerOptions, compileOnSaveEnabled); + public compileOnSaveEnabled: boolean, + cachedDirectoryStructureHost: CachedDirectoryStructureHost) { + super(configFileName, + ProjectKind.Configured, + projectService, + documentRegistry, + hasExplicitListOfFiles, + languageServiceEnabled, + compilerOptions, + compileOnSaveEnabled, + cachedDirectoryStructureHost, + getDirectoryPath(configFileName)); this.canonicalConfigFilePath = asNormalizedPath(projectService.toCanonicalFileName(configFileName)); this.enablePlugins(); } + /** + * If the project has reload from disk pending, it reloads (and then updates graph as part of that) instead of just updating the graph + * @returns: true if set of files in the project stays the same and false - otherwise. + */ + updateGraph(): boolean { + if (this.pendingReload) { + this.pendingReload = false; + this.projectService.reloadConfiguredProject(this); + return true; + } + return super.updateGraph(); + } + + /*@internal*/ + getCachedDirectoryStructureHost() { + return this.directoryStructureHost as CachedDirectoryStructureHost; + } + getConfigFilePath() { - return this.getProjectName(); + return asNormalizedPath(this.getProjectName()); } enablePlugins() { const host = this.projectService.host; - const options = this.getCompilerOptions(); + const options = this.getCompilationSettings(); if (!host.require) { this.projectService.logger.info("Plugins were requested but not running in environment that supports 'require'. Nothing will be loaded"); @@ -1042,7 +1236,7 @@ namespace ts.server { config: configEntry, project: this, languageService: this.languageService, - languageServiceHost: this.lsHost, + languageServiceHost: this, serverHost: this.projectService.host }; @@ -1067,7 +1261,21 @@ namespace ts.server { return getDirectoryPath(this.getConfigFilePath()); } - setProjectErrors(projectErrors: ReadonlyArray) { + /** + * Get the errors that dont have any file name associated + */ + getGlobalProjectErrors(): ReadonlyArray { + return filter(this.projectErrors, diagnostic => !diagnostic.file); + } + + /** + * Get all the project errors + */ + getAllProjectErrors(): ReadonlyArray { + return this.projectErrors; + } + + setProjectErrors(projectErrors: Diagnostic[]) { this.projectErrors = projectErrors; } @@ -1094,80 +1302,33 @@ namespace ts.server { })); } - watchConfigFile(callback: (project: ConfiguredProject) => void) { - this.projectFileWatcher = this.projectService.host.watchFile(this.getConfigFilePath(), _ => callback(this)); + /*@internal*/ + watchWildcards(wildcardDirectories: Map) { + updateWatchingWildcardDirectories( + this.directoriesWatchedForWildcards || (this.directoriesWatchedForWildcards = createMap()), + wildcardDirectories, + // Create new directory watcher + (directory, flags) => this.projectService.watchWildcardDirectory(directory as Path, flags, this), + ); } - watchTypeRoots(callback: (project: ConfiguredProject, path: string) => void) { - const roots = this.getEffectiveTypeRoots(); - const watchers: FileWatcher[] = []; - for (const root of roots) { - this.projectService.logger.info(`Add type root watcher for: ${root}`); - watchers.push(this.projectService.host.watchDirectory(root, path => callback(this, path), /*recursive*/ false)); - } - this.typeRootsWatchers = watchers; - } - - watchConfigDirectory(callback: (project: ConfiguredProject, path: string) => void) { - if (this.directoryWatcher) { - return; - } - - const directoryToWatch = getDirectoryPath(this.getConfigFilePath()); - this.projectService.logger.info(`Add recursive watcher for: ${directoryToWatch}`); - this.directoryWatcher = this.projectService.host.watchDirectory(directoryToWatch, path => callback(this, path), /*recursive*/ true); - } - - watchWildcards(callback: (project: ConfiguredProject, path: string) => void) { - if (!this.wildcardDirectories) { - return; - } - const configDirectoryPath = getDirectoryPath(this.getConfigFilePath()); - - this.directoriesWatchedForWildcards = createMap(); - this.wildcardDirectories.forEach((flag, directory) => { - if (comparePaths(configDirectoryPath, directory, ".", !this.projectService.host.useCaseSensitiveFileNames) !== Comparison.EqualTo) { - const recursive = (flag & WatchDirectoryFlags.Recursive) !== 0; - this.projectService.logger.info(`Add ${recursive ? "recursive " : ""}watcher for: ${directory}`); - this.directoriesWatchedForWildcards.set(directory, this.projectService.host.watchDirectory( - directory, - path => callback(this, path), - recursive - )); - } - }); - } - - stopWatchingDirectory() { - if (this.directoryWatcher) { - this.directoryWatcher.close(); - this.directoryWatcher = undefined; + /*@internal*/ + stopWatchingWildCards() { + if (this.directoriesWatchedForWildcards) { + clearMap(this.directoriesWatchedForWildcards, closeFileWatcherOf); + this.directoriesWatchedForWildcards = undefined; } } close() { super.close(); - if (this.projectFileWatcher) { - this.projectFileWatcher.close(); - this.projectFileWatcher = undefined; - } - - if (this.typeRootsWatchers) { - for (const watcher of this.typeRootsWatchers) { - watcher.close(); - } - this.typeRootsWatchers = undefined; - } - - if (this.directoriesWatchedForWildcards) { - this.directoriesWatchedForWildcards.forEach(watcher => { - watcher.close(); - }); - this.directoriesWatchedForWildcards = undefined; + if (this.configFileWatcher) { + this.configFileWatcher.close(); + this.configFileWatcher = undefined; } - this.stopWatchingDirectory(); + this.stopWatchingWildCards(); } addOpenRef() { @@ -1179,8 +1340,22 @@ namespace ts.server { return this.openRefCount; } + hasOpenRef() { + return !!this.openRefCount; + } + getEffectiveTypeRoots() { - return getEffectiveTypeRoots(this.getCompilerOptions(), this.projectService.host) || []; + return getEffectiveTypeRoots(this.getCompilationSettings(), this.directoryStructureHost) || []; + } + + /*@internal*/ + updateErrorOnNoInputFiles(hasFileNames: boolean) { + if (hasFileNames) { + filterMutate(this.projectErrors, error => !isErrorNoInputFiles(error)); + } + else if (!this.configFileSpecs.filesSpecs && !some(this.projectErrors, isErrorNoInputFiles)) { + this.projectErrors.push(getErrorForNoInputFiles(this.configFileSpecs, this.getConfigFilePath())); + } } } @@ -1191,6 +1366,7 @@ namespace ts.server { export class ExternalProject extends Project { excludedFiles: ReadonlyArray = []; private typeAcquisition: TypeAcquisition; + /*@internal*/ constructor(public externalProjectName: string, projectService: ProjectService, documentRegistry: DocumentRegistry, @@ -1198,8 +1374,15 @@ namespace ts.server { languageServiceEnabled: boolean, public compileOnSaveEnabled: boolean, private readonly projectFilePath?: string) { - super(externalProjectName, ProjectKind.External, projectService, documentRegistry, /*hasExplicitListOfFiles*/ true, languageServiceEnabled, compilerOptions, compileOnSaveEnabled); - + super(externalProjectName, + ProjectKind.External, + projectService, + documentRegistry, + /*hasExplicitListOfFiles*/ true, + languageServiceEnabled, compilerOptions, + compileOnSaveEnabled, + projectService.host, + getDirectoryPath(projectFilePath || normalizeSlashes(externalProjectName))); } getExcludedFiles() { @@ -1220,10 +1403,6 @@ namespace ts.server { return this.typeAcquisition; } - setProjectErrors(projectErrors: ReadonlyArray) { - this.projectErrors = projectErrors; - } - setTypeAcquisition(newTypeAcquisition: TypeAcquisition): void { if (!newTypeAcquisition) { // set default typings options diff --git a/src/server/protocol.ts b/src/server/protocol.ts index 3fdbd8fd7f7d8..3d07392bbe69f 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -2040,6 +2040,19 @@ namespace ts.server.protocol { languageServiceEnabled: boolean; } + export type ProjectsUpdatedInBackgroundEventName = "projectsUpdatedInBackground"; + export interface ProjectsUpdatedInBackgroundEvent extends Event { + event: ProjectsUpdatedInBackgroundEventName; + body: ProjectsUpdatedInBackgroundEventBody; + } + + export interface ProjectsUpdatedInBackgroundEventBody { + /** + * Current set of open files + */ + openFiles: string[]; + } + /** * Arguments for reload request. */ diff --git a/src/server/scriptInfo.ts b/src/server/scriptInfo.ts index a8ba9f5d4c2ac..245b27dd58ae0 100644 --- a/src/server/scriptInfo.ts +++ b/src/server/scriptInfo.ts @@ -4,13 +4,38 @@ namespace ts.server { /* @internal */ export class TextStorage { + /** + * Generated only on demand (based on edits, or information requested) + * The property text is set to undefined when edits happen on the cache + */ private svc: ScriptVersionCache | undefined; private svcVersion = 0; + /** + * Stores the text when there are no changes to the script version cache + * The script version cache is generated on demand and text is still retained. + * Only on edits to the script version cache, the text will be set to undefined + */ private text: string; + /** + * Line map for the text when there is no script version cache present + */ private lineMap: number[]; private textVersion = 0; + /** + * True if the text is for the file thats open in the editor + */ + public isOpen: boolean; + /** + * True if the text present is the text from the file on the disk + */ + private ownFileText: boolean; + /** + * True when reloading contents of file from the disk is pending + */ + private pendingReloadFromDisk: boolean; + constructor(private readonly host: ServerHost, private readonly fileName: NormalizedPath) { } @@ -20,43 +45,75 @@ namespace ts.server { : `Text-${this.textVersion}`; } - public hasScriptVersionCache() { + public hasScriptVersionCache_TestOnly() { return this.svc !== undefined; } - public useScriptVersionCache(newText?: string) { - this.switchToScriptVersionCache(newText); + public useScriptVersionCache_TestOnly() { + this.switchToScriptVersionCache(); } public useText(newText?: string) { this.svc = undefined; - this.setText(newText); + this.text = newText; + this.lineMap = undefined; + this.textVersion++; } public edit(start: number, end: number, newText: string) { this.switchToScriptVersionCache().edit(start, end - start, newText); + this.ownFileText = false; + this.text = undefined; + this.lineMap = undefined; + } + + /** returns true if text changed */ + public reload(newText: string) { + Debug.assert(newText !== undefined); + + // Reload always has fresh content + this.pendingReloadFromDisk = false; + + // If text changed set the text + // This also ensures that if we had switched to version cache, + // we are switching back to text. + // The change to version cache will happen when needed + // Thus avoiding the computation if there are no changes + if (this.text !== newText) { + this.useText(newText); + // We cant guarantee new text is own file text + this.ownFileText = false; + return true; + } } - public reload(text: string) { - if (this.svc) { - this.svc.reload(text); - } - else { - this.setText(text); + /** returns true if text changed */ + public reloadFromDisk() { + let reloaded = false; + if (!this.pendingReloadFromDisk && !this.ownFileText) { + reloaded = this.reload(this.getFileText()); + this.ownFileText = true; } + return reloaded; } - public reloadFromFile(tempFileName?: string) { - if (this.svc || (tempFileName !== this.fileName)) { - this.reload(this.getFileText(tempFileName)); - } - else { - this.setText(undefined); + public delayReloadFromFileIntoText() { + this.pendingReloadFromDisk = true; + } + + /** returns true if text changed */ + public reloadFromFile(tempFileName: string) { + let reloaded = false; + // Reload if different file or we dont know if we are working with own file text + if (tempFileName !== this.fileName || !this.ownFileText) { + reloaded = this.reload(this.getFileText(tempFileName)); + this.ownFileText = !tempFileName || tempFileName === this.fileName; } + return reloaded; } public getSnapshot(): IScriptSnapshot { - return this.svc + return this.useScriptVersionCacheIfValidOrOpen() ? this.svc.getSnapshot() : ScriptSnapshot.fromString(this.getOrLoadText()); } @@ -68,7 +125,7 @@ namespace ts.server { * @param line 0 based index */ lineToTextSpan(line: number): TextSpan { - if (!this.svc) { + if (!this.useScriptVersionCacheIfValidOrOpen()) { const lineMap = this.getLineMap(); const start = lineMap[line]; // -1 since line is 1-based const end = line + 1 < lineMap.length ? lineMap[line + 1] : this.text.length; @@ -82,7 +139,7 @@ namespace ts.server { * @param offset 1 based index */ lineOffsetToPosition(line: number, offset: number): number { - if (!this.svc) { + if (!this.useScriptVersionCacheIfValidOrOpen()) { return computePositionOfLineAndCharacter(this.getLineMap(), line - 1, offset - 1, this.text); } @@ -91,7 +148,7 @@ namespace ts.server { } positionToLineOffset(position: number): protocol.Location { - if (!this.svc) { + if (!this.useScriptVersionCacheIfValidOrOpen()) { const { line, character } = computeLineAndCharacterOfPosition(this.getLineMap(), position); return { line: line + 1, offset: character + 1 }; } @@ -102,42 +159,43 @@ namespace ts.server { return this.host.readFile(tempFileName || this.fileName) || ""; } - private ensureNoScriptVersionCache() { - Debug.assert(!this.svc, "ScriptVersionCache should not be set"); - } - - private switchToScriptVersionCache(newText?: string): ScriptVersionCache { - if (!this.svc) { - this.svc = ScriptVersionCache.fromString(newText !== undefined ? newText : this.getOrLoadText()); + private switchToScriptVersionCache(): ScriptVersionCache { + if (!this.svc || this.pendingReloadFromDisk) { + this.svc = ScriptVersionCache.fromString(this.getOrLoadText()); this.svcVersion++; - this.text = undefined; } return this.svc; } + private useScriptVersionCacheIfValidOrOpen(): ScriptVersionCache | undefined { + // If this is open script, use the cache + if (this.isOpen) { + return this.switchToScriptVersionCache(); + } + + // Else if the svc is uptodate with the text, we are good + return !this.pendingReloadFromDisk && this.svc; + } + private getOrLoadText() { - this.ensureNoScriptVersionCache(); - if (this.text === undefined) { - this.setText(this.getFileText()); + if (this.text === undefined || this.pendingReloadFromDisk) { + Debug.assert(!this.svc || this.pendingReloadFromDisk, "ScriptVersionCache should not be set when reloading from disk"); + this.reload(this.getFileText()); + this.ownFileText = true; } return this.text; } private getLineMap() { - this.ensureNoScriptVersionCache(); + Debug.assert(!this.svc, "ScriptVersionCache should not be set"); return this.lineMap || (this.lineMap = computeLineStarts(this.getOrLoadText())); } - - private setText(newText: string) { - this.ensureNoScriptVersionCache(); - if (newText === undefined || this.text !== newText) { - this.text = newText; - this.lineMap = undefined; - this.textVersion++; - } - } } + /*@internal*/ + export function isDynamicFileName(fileName: NormalizedPath) { + return getBaseFileName(fileName)[0] === "^"; + } export class ScriptInfo { /** @@ -145,23 +203,24 @@ namespace ts.server { */ readonly containingProjects: Project[] = []; private formatCodeSettings: FormatCodeSettings; - readonly path: Path; - private fileWatcher: FileWatcher; + /* @internal */ + fileWatcher: FileWatcher; private textStorage: TextStorage; - private isOpen: boolean; + /*@internal*/ + readonly isDynamic: boolean; constructor( private readonly host: ServerHost, readonly fileName: NormalizedPath, readonly scriptKind: ScriptKind, - public hasMixedContent = false, - public isDynamic = false) { + public readonly hasMixedContent: boolean, + readonly path: Path) { + this.isDynamic = isDynamicFileName(fileName); - this.path = toPath(fileName, host.getCurrentDirectory(), createGetCanonicalFileName(host.useCaseSensitiveFileNames)); this.textStorage = new TextStorage(host, fileName); - if (hasMixedContent || isDynamic) { + if (hasMixedContent || this.isDynamic) { this.textStorage.reload(""); } this.scriptKind = scriptKind @@ -169,20 +228,34 @@ namespace ts.server { : getScriptKindFromFileName(fileName); } + /*@internal*/ + public isDynamicOrHasMixedContent() { + return this.hasMixedContent || this.isDynamic; + } + public isScriptOpen() { - return this.isOpen; + return this.textStorage.isOpen; } public open(newText: string) { - this.isOpen = true; - this.textStorage.useScriptVersionCache(newText); - this.markContainingProjectsAsDirty(); + this.textStorage.isOpen = true; + if (newText !== undefined && + this.textStorage.reload(newText)) { + // reload new contents only if the existing contents changed + this.markContainingProjectsAsDirty(); + } } public close() { - this.isOpen = false; - this.textStorage.useText(this.hasMixedContent || this.isDynamic ? "" : undefined); - this.markContainingProjectsAsDirty(); + this.textStorage.isOpen = false; + if (this.isDynamicOrHasMixedContent()) { + if (this.textStorage.reload("")) { + this.markContainingProjectsAsDirty(); + } + } + else if (this.textStorage.reloadFromDisk()) { + this.markContainingProjectsAsDirty(); + } } public getSnapshot() { @@ -237,8 +310,17 @@ namespace ts.server { detachAllProjects() { for (const p of this.containingProjects) { + if (p.projectKind === ProjectKind.Configured) { + (p.directoryStructureHost as CachedDirectoryStructureHost).addOrDeleteFile(this.fileName, this.path, FileWatcherEventKind.Deleted); + } + const isInfoRoot = p.isRoot(this); // detach is unnecessary since we'll clean the list of containing projects anyways - p.removeFile(this, /*detachFromProjects*/ false); + p.removeFile(this, /*fileExists*/ false, /*detachFromProjects*/ false); + // If the info was for the external or configured project's root, + // add missing file as the root + if (isInfoRoot && p.projectKind !== ProjectKind.Inferred) { + p.addMissingFileRoot(this.fileName); + } } clear(this.containingProjects); } @@ -281,42 +363,35 @@ namespace ts.server { } } - setWatcher(watcher: FileWatcher): void { - this.stopWatcher(); - this.fileWatcher = watcher; - } - - stopWatcher() { - if (this.fileWatcher) { - this.fileWatcher.close(); - this.fileWatcher = undefined; - } - } - getLatestVersion() { return this.textStorage.getVersion(); } - reload(script: string) { - this.textStorage.reload(script); - this.markContainingProjectsAsDirty(); - } - saveTo(fileName: string) { const snap = this.textStorage.getSnapshot(); this.host.writeFile(fileName, snap.getText(0, snap.getLength())); } + /*@internal*/ + delayReloadNonMixedContentFile() { + Debug.assert(!this.isDynamicOrHasMixedContent()); + this.textStorage.delayReloadFromFileIntoText(); + this.markContainingProjectsAsDirty(); + } + reloadFromFile(tempFileName?: NormalizedPath) { - if (this.hasMixedContent || this.isDynamic) { - this.reload(""); + if (this.isDynamicOrHasMixedContent()) { + this.textStorage.reload(""); + this.markContainingProjectsAsDirty(); } else { - this.textStorage.reloadFromFile(tempFileName); - this.markContainingProjectsAsDirty(); + if (this.textStorage.reloadFromFile(tempFileName)) { + this.markContainingProjectsAsDirty(); + } } } + /*@internal*/ getLineInfo(line: number): AbsolutePositionAndLineText { return this.textStorage.getLineInfo(line); } @@ -332,6 +407,10 @@ namespace ts.server { } } + isOrphan() { + return this.containingProjects.length === 0; + } + /** * @param line 1 based index */ diff --git a/src/server/scriptVersionCache.ts b/src/server/scriptVersionCache.ts index 451536a0caaaf..7c25e4cc3cb79 100644 --- a/src/server/scriptVersionCache.ts +++ b/src/server/scriptVersionCache.ts @@ -2,6 +2,7 @@ /// /// +/*@internal*/ namespace ts.server { const lineCollectionCapacity = 4; @@ -285,24 +286,6 @@ namespace ts.server { } } - // reload whole script, leaving no change history behind reload - reload(script: string) { - this.currentVersion++; - this.changes = []; // history wiped out by reload - const snap = new LineIndexSnapshot(this.currentVersion, this, new LineIndex()); - - // delete all versions - for (let i = 0; i < this.versions.length; i++) { - this.versions[i] = undefined; - } - - this.versions[this.currentVersionToIndex()] = snap; - const lm = LineIndex.linesFromText(script); - snap.index.load(lm.lines); - - this.minVersion = this.currentVersion; - } - getSnapshot(): IScriptSnapshot { return this._getSnapshot(); } private _getSnapshot(): LineIndexSnapshot { @@ -843,4 +826,4 @@ namespace ts.server { return 1; } } -} \ No newline at end of file +} diff --git a/src/server/server.ts b/src/server/server.ts index 8849b5f3a8d9e..6fab1a3d08ae4 100644 --- a/src/server/server.ts +++ b/src/server/server.ts @@ -613,7 +613,15 @@ namespace ts.server { fs.stat(watchedFile.fileName, (err: any, stats: any) => { if (err) { - watchedFile.callback(watchedFile.fileName, FileWatcherEventKind.Changed); + if (err.code === "ENOENT") { + if (watchedFile.mtime.getTime() !== 0) { + watchedFile.mtime = new Date(0); + watchedFile.callback(watchedFile.fileName, FileWatcherEventKind.Deleted); + } + } + else { + watchedFile.callback(watchedFile.fileName, FileWatcherEventKind.Changed); + } } else { const oldTime = watchedFile.mtime.getTime(); diff --git a/src/server/session.ts b/src/server/session.ts index 5d71d1b11f9c4..57dac7ec799a0 100644 --- a/src/server/session.ts +++ b/src/server/session.ts @@ -332,14 +332,18 @@ namespace ts.server { private defaultEventHandler(event: ProjectServiceEvent) { switch (event.eventName) { - case ContextEvent: - const { project, fileName } = event.data; - this.projectService.logger.info(`got context event, updating diagnostics for ${fileName}`); - this.errorCheck.startNew(next => this.updateErrorCheck(next, [{ fileName, project }], 100)); + case ProjectsUpdatedInBackgroundEvent: + const { openFiles } = event.data; + this.projectsUpdatedInBackgroundEvent(openFiles); break; case ConfigFileDiagEvent: - const { triggerFile, configFileName, diagnostics } = event.data; - this.configFileDiagnosticEvent(triggerFile, configFileName, diagnostics); + const { triggerFile, configFileName: configFile, diagnostics } = event.data; + const bakedDiags = map(diagnostics, diagnostic => formatConfigFileDiag(diagnostic, /*includeFileName*/ true)); + this.event({ + triggerFile, + configFile, + diagnostics: bakedDiags + }, "configFileDiag"); break; case ProjectLanguageServiceStateEvent: { const eventName: protocol.ProjectLanguageServiceStateEventName = "projectLanguageServiceState"; @@ -360,6 +364,22 @@ namespace ts.server { } } + private projectsUpdatedInBackgroundEvent(openFiles: string[]): void { + this.projectService.logger.info(`got projects updated in background, updating diagnostics for ${openFiles}`); + if (openFiles.length) { + const checkList = this.createCheckList(openFiles); + + // For now only queue error checking for open files. We can change this to include non open files as well + this.errorCheck.startNew(next => this.updateErrorCheck(next, checkList, 100, /*requireOpen*/ true)); + + + // Send project changed event + this.event({ + openFiles + }, "projectsUpdatedInBackground"); + } + } + public logError(err: Error, cmd: string) { let msg = "Exception on executing command " + cmd; if (err.message) { @@ -381,21 +401,6 @@ namespace ts.server { this.host.write(formatMessage(msg, this.logger, this.byteLength, this.host.newLine)); } - public configFileDiagnosticEvent(triggerFile: string, configFile: string, diagnostics: ReadonlyArray) { - const bakedDiags = map(diagnostics, diagnostic => formatConfigFileDiag(diagnostic, /*includeFileName*/ true)); - const ev: protocol.ConfigFileDiagnosticEvent = { - seq: 0, - type: "event", - event: "configFileDiag", - body: { - triggerFile, - configFile, - diagnostics: bakedDiags - } - }; - this.send(ev); - } - public event(info: T, eventName: string) { const ev: protocol.Event = { seq: 0, @@ -451,16 +456,6 @@ namespace ts.server { } } - private updateProjectStructure() { - const ms = 1500; - const seq = this.changeSeq; - this.host.setTimeout(() => { - if (this.changeSeq === seq) { - this.projectService.refreshInferredProjects(); - } - }, ms); - } - private updateErrorCheck(next: NextStep, checkList: PendingErrorCheck[], ms: number, requireOpen = true) { const seq = this.changeSeq; const followMs = Math.min(ms, 200); @@ -472,12 +467,14 @@ namespace ts.server { index++; if (checkSpec.project.containsFile(checkSpec.fileName, requireOpen)) { this.syntacticCheck(checkSpec.fileName, checkSpec.project); - next.immediate(() => { - this.semanticCheck(checkSpec.fileName, checkSpec.project); - if (checkList.length > index) { - next.delay(followMs, checkOne); - } - }); + if (this.changeSeq === seq) { + next.immediate(() => { + this.semanticCheck(checkSpec.fileName, checkSpec.project); + if (checkList.length > index) { + next.delay(followMs, checkOne); + } + }); + } } } }; @@ -499,7 +496,7 @@ namespace ts.server { private cleanup() { this.cleanProjects("inferred projects", this.projectService.inferredProjects); - this.cleanProjects("configured projects", this.projectService.configuredProjects); + this.cleanProjects("configured projects", arrayFrom(this.projectService.configuredProjects.values())); this.cleanProjects("external projects", this.projectService.externalProjects); if (this.host.gc) { this.logger.info(`host.gc()`); @@ -596,8 +593,7 @@ namespace ts.server { private getDefinition(args: protocol.FileLocationRequestArgs, simplifiedResult: boolean): ReadonlyArray | ReadonlyArray { const { file, project } = this.getFileAndProject(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); - const position = this.getPosition(args, scriptInfo); + const position = this.getPositionInFile(args, file); const definitions = project.getLanguageService().getDefinitionAtPosition(file, position); if (!definitions) { @@ -621,8 +617,7 @@ namespace ts.server { private getTypeDefinition(args: protocol.FileLocationRequestArgs): ReadonlyArray { const { file, project } = this.getFileAndProject(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); - const position = this.getPosition(args, scriptInfo); + const position = this.getPositionInFile(args, file); const definitions = project.getLanguageService().getTypeDefinitionAtPosition(file, position); if (!definitions) { @@ -641,7 +636,7 @@ namespace ts.server { private getImplementation(args: protocol.FileLocationRequestArgs, simplifiedResult: boolean): ReadonlyArray | ReadonlyArray { const { file, project } = this.getFileAndProject(args); - const position = this.getPosition(args, project.getScriptInfoForNormalizedPath(file)); + const position = this.getPositionInFile(args, file); const implementations = project.getLanguageService().getImplementationAtPosition(file, position); if (!implementations) { return emptyArray; @@ -663,8 +658,7 @@ namespace ts.server { private getOccurrences(args: protocol.FileLocationRequestArgs): ReadonlyArray { const { file, project } = this.getFileAndProject(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); - const position = this.getPosition(args, scriptInfo); + const position = this.getPositionInFile(args, file); const occurrences = project.getLanguageService().getOccurrencesAtPosition(file, position); @@ -711,8 +705,7 @@ namespace ts.server { private getDocumentHighlights(args: protocol.DocumentHighlightsRequestArgs, simplifiedResult: boolean): ReadonlyArray | ReadonlyArray { const { file, project } = this.getFileAndProject(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); - const position = this.getPosition(args, scriptInfo); + const position = this.getPositionInFile(args, file); const documentHighlights = project.getLanguageService().getDocumentHighlights(file, position, args.filesToSearch); if (!documentHighlights) { @@ -753,7 +746,8 @@ namespace ts.server { } private getProjectInfoWorker(uncheckedFileName: string, projectFileName: string, needFileNameList: boolean, excludeConfigFiles: boolean) { - const { project } = this.getFileAndProjectWorker(uncheckedFileName, projectFileName, /*refreshInferredProjects*/ true, /*errorOnMissingProject*/ true); + const { project } = this.getFileAndProjectWorker(uncheckedFileName, projectFileName); + project.updateGraph(); const projectInfo = { configFileName: project.getProjectName(), languageServiceDisabled: !project.languageServiceEnabled, @@ -764,8 +758,7 @@ namespace ts.server { private getRenameInfo(args: protocol.FileLocationRequestArgs) { const { file, project } = this.getFileAndProject(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); - const position = this.getPosition(args, scriptInfo); + const position = this.getPositionInFile(args, file); return project.getLanguageService().getRenameInfo(file, position); } @@ -802,8 +795,7 @@ namespace ts.server { private getRenameLocations(args: protocol.RenameRequestArgs, simplifiedResult: boolean): protocol.RenameResponseBody | ReadonlyArray { const file = toNormalizedPath(args.file); - const info = this.projectService.getScriptInfoForNormalizedPath(file); - const position = this.getPosition(args, info); + const position = this.getPositionInFile(args, file); const projects = this.getProjects(args); if (simplifiedResult) { @@ -908,14 +900,13 @@ namespace ts.server { const projects = this.getProjects(args); const defaultProject = this.getDefaultProject(args); - const scriptInfo = defaultProject.getScriptInfoForNormalizedPath(file); + const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file); const position = this.getPosition(args, scriptInfo); if (simplifiedResult) { const nameInfo = defaultProject.getLanguageService().getQuickInfoAtPosition(file, position); if (!nameInfo) { return undefined; } - const displayString = displayPartsToString(nameInfo.displayParts); const nameSpan = nameInfo.textSpan; const nameColStart = scriptInfo.positionToLineOffset(nameSpan.start).offset; @@ -991,26 +982,38 @@ namespace ts.server { return args.position !== undefined ? args.position : scriptInfo.lineOffsetToPosition(args.line, args.offset); } - private getFileAndProject(args: protocol.FileRequestArgs, errorOnMissingProject = true) { - return this.getFileAndProjectWorker(args.file, args.projectFileName, /*refreshInferredProjects*/ true, errorOnMissingProject); + private getPositionInFile(args: protocol.FileLocationRequestArgs, file: NormalizedPath): number { + const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file); + return this.getPosition(args, scriptInfo); } - private getFileAndProjectWithoutRefreshingInferredProjects(args: protocol.FileRequestArgs, errorOnMissingProject = true) { - return this.getFileAndProjectWorker(args.file, args.projectFileName, /*refreshInferredProjects*/ false, errorOnMissingProject); + private getFileAndProject(args: protocol.FileRequestArgs) { + return this.getFileAndProjectWorker(args.file, args.projectFileName); } - private getFileAndProjectWorker(uncheckedFileName: string, projectFileName: string, refreshInferredProjects: boolean, errorOnMissingProject: boolean) { - const file = toNormalizedPath(uncheckedFileName); - const project: Project = this.getProject(projectFileName) || this.projectService.getDefaultProjectForFile(file, refreshInferredProjects); - if (!project && errorOnMissingProject) { + private getFileAndLanguageServiceForSyntacticOperation(args: protocol.FileRequestArgs) { + // Since this is syntactic operation, there should always be project for the file + // we wouldnt have to ensure project but rather throw if we dont get project + const file = toNormalizedPath(args.file); + const project = this.getProject(args.projectFileName) || this.projectService.getDefaultProjectForFile(file, /*ensureProject*/ false); + if (!project) { return Errors.ThrowNoProject(); } + return { + file, + languageService: project.getLanguageService(/*ensureSynchronized*/ false) + }; + } + + private getFileAndProjectWorker(uncheckedFileName: string, projectFileName: string) { + const file = toNormalizedPath(uncheckedFileName); + const project: Project = this.getProject(projectFileName) || this.projectService.getDefaultProjectForFile(file, /*ensureProject*/ true); return { file, project }; } private getOutliningSpans(args: protocol.FileRequestArgs) { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); - return project.getLanguageService(/*ensureSynchronized*/ false).getOutliningSpans(file); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); + return languageService.getOutliningSpans(file); } private getTodoComments(args: protocol.TodoCommentRequestArgs) { @@ -1019,49 +1022,47 @@ namespace ts.server { } private getDocCommentTemplate(args: protocol.FileLocationRequestArgs) { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); - const position = this.getPosition(args, scriptInfo); - return project.getLanguageService(/*ensureSynchronized*/ false).getDocCommentTemplateAtPosition(file, position); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); + const position = this.getPositionInFile(args, file); + return languageService.getDocCommentTemplateAtPosition(file, position); } private getSpanOfEnclosingComment(args: protocol.SpanOfEnclosingCommentRequestArgs) { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); const onlyMultiLine = args.onlyMultiLine; - const position = this.getPosition(args, scriptInfo); - return project.getLanguageService(/*ensureSynchronized*/ false).getSpanOfEnclosingComment(file, position, onlyMultiLine); + const position = this.getPositionInFile(args, file); + return languageService.getSpanOfEnclosingComment(file, position, onlyMultiLine); } private getIndentation(args: protocol.IndentationRequestArgs) { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); - const position = this.getPosition(args, project.getScriptInfoForNormalizedPath(file)); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); + const position = this.getPositionInFile(args, file); const options = args.options ? convertFormatOptions(args.options) : this.projectService.getFormatCodeOptions(file); - const indentation = project.getLanguageService(/*ensureSynchronized*/ false).getIndentationAtPosition(file, position, options); + const indentation = languageService.getIndentationAtPosition(file, position, options); return { position, indentation }; } private getBreakpointStatement(args: protocol.FileLocationRequestArgs) { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); - const position = this.getPosition(args, project.getScriptInfoForNormalizedPath(file)); - return project.getLanguageService(/*ensureSynchronized*/ false).getBreakpointStatementAtPosition(file, position); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); + const position = this.getPositionInFile(args, file); + return languageService.getBreakpointStatementAtPosition(file, position); } private getNameOrDottedNameSpan(args: protocol.FileLocationRequestArgs) { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); - const position = this.getPosition(args, project.getScriptInfoForNormalizedPath(file)); - return project.getLanguageService(/*ensureSynchronized*/ false).getNameOrDottedNameSpan(file, position, position); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); + const position = this.getPositionInFile(args, file); + return languageService.getNameOrDottedNameSpan(file, position, position); } private isValidBraceCompletion(args: protocol.BraceCompletionRequestArgs) { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); - const position = this.getPosition(args, project.getScriptInfoForNormalizedPath(file)); - return project.getLanguageService(/*ensureSynchronized*/ false).isValidBraceCompletionAtPosition(file, position, args.openingBrace.charCodeAt(0)); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); + const position = this.getPositionInFile(args, file); + return languageService.isValidBraceCompletionAtPosition(file, position, args.openingBrace.charCodeAt(0)); } private getQuickInfoWorker(args: protocol.FileLocationRequestArgs, simplifiedResult: boolean): protocol.QuickInfoResponseBody | QuickInfo { const { file, project } = this.getFileAndProject(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); + const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file); const quickInfo = project.getLanguageService().getQuickInfoAtPosition(file, this.getPosition(args, scriptInfo)); if (!quickInfo) { return undefined; @@ -1087,14 +1088,14 @@ namespace ts.server { } private getFormattingEditsForRange(args: protocol.FormatRequestArgs): protocol.CodeEdit[] { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); + const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file); const startPosition = scriptInfo.lineOffsetToPosition(args.line, args.offset); const endPosition = scriptInfo.lineOffsetToPosition(args.endLine, args.endOffset); // TODO: avoid duplicate code (with formatonkey) - const edits = project.getLanguageService(/*ensureSynchronized*/ false).getFormattingEditsForRange(file, startPosition, endPosition, + const edits = languageService.getFormattingEditsForRange(file, startPosition, endPosition, this.projectService.getFormatCodeOptions(file)); if (!edits) { return undefined; @@ -1104,29 +1105,29 @@ namespace ts.server { } private getFormattingEditsForRangeFull(args: protocol.FormatRequestArgs) { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); const options = args.options ? convertFormatOptions(args.options) : this.projectService.getFormatCodeOptions(file); - return project.getLanguageService(/*ensureSynchronized*/ false).getFormattingEditsForRange(file, args.position, args.endPosition, options); + return languageService.getFormattingEditsForRange(file, args.position, args.endPosition, options); } private getFormattingEditsForDocumentFull(args: protocol.FormatRequestArgs) { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); const options = args.options ? convertFormatOptions(args.options) : this.projectService.getFormatCodeOptions(file); - return project.getLanguageService(/*ensureSynchronized*/ false).getFormattingEditsForDocument(file, options); + return languageService.getFormattingEditsForDocument(file, options); } private getFormattingEditsAfterKeystrokeFull(args: protocol.FormatOnKeyRequestArgs) { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); const options = args.options ? convertFormatOptions(args.options) : this.projectService.getFormatCodeOptions(file); - return project.getLanguageService(/*ensureSynchronized*/ false).getFormattingEditsAfterKeystroke(file, args.position, args.key, options); + return languageService.getFormattingEditsAfterKeystroke(file, args.position, args.key, options); } private getFormattingEditsAfterKeystroke(args: protocol.FormatOnKeyRequestArgs): protocol.CodeEdit[] { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); + const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file); const position = scriptInfo.lineOffsetToPosition(args.line, args.offset); const formatOptions = this.projectService.getFormatCodeOptions(file); - const edits = project.getLanguageService(/*ensureSynchronized*/ false).getFormattingEditsAfterKeystroke(file, position, args.key, + const edits = languageService.getFormattingEditsAfterKeystroke(file, position, args.key, formatOptions); // Check whether we should auto-indent. This will be when // the position is on a line containing only whitespace. @@ -1137,7 +1138,7 @@ namespace ts.server { if ((args.key === "\n") && ((!edits) || (edits.length === 0) || allEditsBeforePos(edits, position))) { const { lineText, absolutePosition } = scriptInfo.getLineInfo(args.line); if (lineText && lineText.search("\\S") < 0) { - const preferredIndent = project.getLanguageService(/*ensureSynchronized*/ false).getIndentationAtPosition(file, position, formatOptions); + const preferredIndent = languageService.getIndentationAtPosition(file, position, formatOptions); let hasIndent = 0; let i: number, len: number; for (i = 0, len = lineText.length; i < len; i++) { @@ -1178,8 +1179,7 @@ namespace ts.server { private getCompletions(args: protocol.CompletionsRequestArgs, simplifiedResult: boolean): ReadonlyArray | CompletionInfo | undefined { const prefix = args.prefix || ""; const { file, project } = this.getFileAndProject(args); - - const scriptInfo = project.getScriptInfoForNormalizedPath(file); + const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file); const position = this.getPosition(args, scriptInfo); const completions = project.getLanguageService().getCompletionsAtPosition(file, position); @@ -1199,21 +1199,20 @@ namespace ts.server { private getCompletionEntryDetails(args: protocol.CompletionDetailsRequestArgs): ReadonlyArray { const { file, project } = this.getFileAndProject(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); - const position = this.getPosition(args, scriptInfo); + const position = this.getPositionInFile(args, file); return mapDefined(args.entryNames, entryName => project.getLanguageService().getCompletionEntryDetails(file, position, entryName)); } private getCompileOnSaveAffectedFileList(args: protocol.FileRequestArgs): ReadonlyArray { - const info = this.projectService.getScriptInfo(args.file); - const result: protocol.CompileOnSaveAffectedFileListSingleProject[] = []; - + const info = this.projectService.getScriptInfoEnsuringProjectsUptoDate(args.file); if (!info) { return emptyArray; } + const result: protocol.CompileOnSaveAffectedFileListSingleProject[] = []; + // if specified a project, we only return affected file list in this project const projectsToSearch = args.projectFileName ? [this.projectService.findProject(args.projectFileName)] : info.containingProjects; for (const project of projectsToSearch) { @@ -1221,7 +1220,7 @@ namespace ts.server { result.push({ projectFileName: project.getProjectName(), fileNames: project.getCompileOnSaveAffectedFileList(info), - projectUsesOutFile: !!project.getCompilerOptions().outFile || !!project.getCompilerOptions().out + projectUsesOutFile: !!project.getCompilationSettings().outFile || !!project.getCompilationSettings().out }); } } @@ -1237,12 +1236,12 @@ namespace ts.server { return false; } const scriptInfo = project.getScriptInfo(file); - return project.builder.emitFile(scriptInfo, (path, data, writeByteOrderMark) => this.host.writeFile(path, data, writeByteOrderMark)); + return project.emitFile(scriptInfo, (path, data, writeByteOrderMark) => this.host.writeFile(path, data, writeByteOrderMark)); } private getSignatureHelpItems(args: protocol.SignatureHelpRequestArgs, simplifiedResult: boolean): protocol.SignatureHelpItems | SignatureHelpItems { const { file, project } = this.getFileAndProject(args); - const scriptInfo = project.getScriptInfoForNormalizedPath(file); + const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file); const position = this.getPosition(args, scriptInfo); const helpItems = project.getLanguageService().getSignatureHelpItems(file, position); if (!helpItems) { @@ -1267,42 +1266,43 @@ namespace ts.server { } } - private getDiagnostics(next: NextStep, delay: number, fileNames: string[]): void { - const checkList = mapDefined(fileNames, uncheckedFileName => { + private createCheckList(fileNames: string[], defaultProject?: Project): PendingErrorCheck[] { + return mapDefined(fileNames, uncheckedFileName => { const fileName = toNormalizedPath(uncheckedFileName); - const project = this.projectService.getDefaultProjectForFile(fileName, /*refreshInferredProjects*/ true); + const project = defaultProject || this.projectService.getDefaultProjectForFile(fileName, /*ensureProject*/ false); return project && { fileName, project }; }); + } + private getDiagnostics(next: NextStep, delay: number, fileNames: string[]): void { + const checkList = this.createCheckList(fileNames); if (checkList.length > 0) { this.updateErrorCheck(next, checkList, delay); } } private change(args: protocol.ChangeRequestArgs) { - const { file, project } = this.getFileAndProject(args, /*errorOnMissingProject*/ false); - if (project) { - const scriptInfo = project.getScriptInfoForNormalizedPath(file); - const start = scriptInfo.lineOffsetToPosition(args.line, args.offset); - const end = scriptInfo.lineOffsetToPosition(args.endLine, args.endOffset); - if (start >= 0) { - scriptInfo.editContent(start, end, args.insertString); - this.changeSeq++; - } - this.updateProjectStructure(); + const scriptInfo = this.projectService.getScriptInfo(args.file); + Debug.assert(!!scriptInfo); + const start = scriptInfo.lineOffsetToPosition(args.line, args.offset); + const end = scriptInfo.lineOffsetToPosition(args.endLine, args.endOffset); + if (start >= 0) { + this.changeSeq++; + this.projectService.applyChangesToFile(scriptInfo, [{ + span: { start, length: end - start }, + newText: args.insertString + }]); } } private reload(args: protocol.ReloadRequestArgs, reqSeq: number) { const file = toNormalizedPath(args.file); const tempFileName = args.tmpfile && toNormalizedPath(args.tmpfile); - const project = this.projectService.getDefaultProjectForFile(file, /*refreshInferredProjects*/ true); - if (project) { - this.changeSeq++; - // make sure no changes happen before this one is finished - if (project.reloadScript(file, tempFileName)) { - this.output(undefined, CommandNames.Reload, reqSeq); - } + const project = this.projectService.getDefaultProjectForFile(file, /*ensureProject*/ true); + this.changeSeq++; + // make sure no changes happen before this one is finished + if (project.reloadScript(file, tempFileName)) { + this.output(undefined, CommandNames.Reload, reqSeq); } } @@ -1333,12 +1333,12 @@ namespace ts.server { } private getNavigationBarItems(args: protocol.FileRequestArgs, simplifiedResult: boolean): protocol.NavigationBarItem[] | NavigationBarItem[] { - const { file, project } = this.getFileAndProject(args); - const items = project.getLanguageService(/*ensureSynchronized*/ false).getNavigationBarItems(file); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); + const items = languageService.getNavigationBarItems(file); return !items ? undefined : simplifiedResult - ? this.decorateNavigationBarItems(items, project.getScriptInfoForNormalizedPath(file)) + ? this.decorateNavigationBarItems(items, this.projectService.getScriptInfoForNormalizedPath(file)) : items; } @@ -1360,12 +1360,12 @@ namespace ts.server { } private getNavigationTree(args: protocol.FileRequestArgs, simplifiedResult: boolean): protocol.NavigationTree | NavigationTree { - const { file, project } = this.getFileAndProject(args); - const tree = project.getLanguageService(/*ensureSynchronized*/ false).getNavigationTree(file); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); + const tree = languageService.getNavigationTree(file); return !tree ? undefined : simplifiedResult - ? this.decorateNavigationTree(tree, project.getScriptInfoForNormalizedPath(file)) + ? this.decorateNavigationTree(tree, this.projectService.getScriptInfoForNormalizedPath(file)) : tree; } @@ -1475,14 +1475,14 @@ namespace ts.server { } private getApplicableRefactors(args: protocol.GetApplicableRefactorsRequestArgs): protocol.ApplicableRefactorInfo[] { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); + const { file, project } = this.getFileAndProject(args); const scriptInfo = project.getScriptInfoForNormalizedPath(file); const { position, textRange } = this.extractPositionAndRange(args, scriptInfo); return project.getLanguageService().getApplicableRefactors(file, position || textRange); } private getEditsForRefactor(args: protocol.GetEditsForRefactorRequestArgs, simplifiedResult: boolean): RefactorEditInfo | protocol.RefactorEditInfo { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); + const { file, project } = this.getFileAndProject(args); const scriptInfo = project.getScriptInfoForNormalizedPath(file); const { position, textRange } = this.extractPositionAndRange(args, scriptInfo); @@ -1522,7 +1522,7 @@ namespace ts.server { if (args.errorCodes.length === 0) { return undefined; } - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); + const { file, project } = this.getFileAndProject(args); const scriptInfo = project.getScriptInfoForNormalizedPath(file); const { startPosition, endPosition } = this.getStartAndEndPosition(args, scriptInfo); @@ -1589,12 +1589,11 @@ namespace ts.server { } private getBraceMatching(args: protocol.FileLocationRequestArgs, simplifiedResult: boolean): protocol.TextSpan[] | TextSpan[] { - const { file, project } = this.getFileAndProjectWithoutRefreshingInferredProjects(args); - - const scriptInfo = project.getScriptInfoForNormalizedPath(file); + const { file, languageService } = this.getFileAndLanguageServiceForSyntacticOperation(args); + const scriptInfo = this.projectService.getScriptInfoForNormalizedPath(file); const position = this.getPosition(args, scriptInfo); - const spans = project.getLanguageService(/*ensureSynchronized*/ false).getBraceMatchingAtPosition(file, position); + const spans = languageService.getBraceMatchingAtPosition(file, position); return !spans ? undefined : simplifiedResult @@ -1620,7 +1619,7 @@ namespace ts.server { const lowPriorityFiles: NormalizedPath[] = []; const veryLowPriorityFiles: NormalizedPath[] = []; const normalizedFileName = toNormalizedPath(fileName); - const project = this.projectService.getDefaultProjectForFile(normalizedFileName, /*refreshInferredProjects*/ true); + const project = this.projectService.getDefaultProjectForFile(normalizedFileName, /*ensureProject*/ true); for (const fileNameInProject of fileNamesInProject) { if (this.getCanonicalFileName(fileNameInProject) === this.getCanonicalFileName(fileName)) { highPriorityFiles.push(fileNameInProject); @@ -1699,8 +1698,8 @@ namespace ts.server { return this.requiredResponse(converted); }, [CommandNames.ApplyChangedToOpenFiles]: (request: protocol.ApplyChangedToOpenFilesRequest) => { - this.projectService.applyChangesInOpenFiles(request.arguments.openFiles, request.arguments.changedFiles, request.arguments.closedFiles); this.changeSeq++; + this.projectService.applyChangesInOpenFiles(request.arguments.openFiles, request.arguments.changedFiles, request.arguments.closedFiles); // TODO: report errors return this.requiredResponse(/*response*/ true); }, diff --git a/src/server/tsconfig.json b/src/server/tsconfig.json index b0eb4965ea687..c838a7209d9a5 100644 --- a/src/server/tsconfig.json +++ b/src/server/tsconfig.json @@ -15,7 +15,6 @@ "utilities.ts", "scriptVersionCache.ts", "scriptInfo.ts", - "lsHost.ts", "typingsCache.ts", "project.ts", "editorServices.ts", diff --git a/src/server/tsconfig.library.json b/src/server/tsconfig.library.json index b59395fda275a..aa3148d58a105 100644 --- a/src/server/tsconfig.library.json +++ b/src/server/tsconfig.library.json @@ -16,7 +16,6 @@ }, "files": [ "editorServices.ts", - "lsHost.ts", "project.ts", "protocol.ts", "scriptInfo.ts", diff --git a/src/server/typingsCache.ts b/src/server/typingsCache.ts index 3ef37685da2c9..207824616a980 100644 --- a/src/server/typingsCache.ts +++ b/src/server/typingsCache.ts @@ -89,12 +89,12 @@ namespace ts.server { if (forceRefresh || !entry || typeAcquisitionChanged(typeAcquisition, entry.typeAcquisition) || - compilerOptionsChanged(project.getCompilerOptions(), entry.compilerOptions) || + compilerOptionsChanged(project.getCompilationSettings(), entry.compilerOptions) || unresolvedImportsChanged(unresolvedImports, entry.unresolvedImports)) { // Note: entry is now poisoned since it does not really contain typings for a given combination of compiler options\typings options. // instead it acts as a placeholder to prevent issuing multiple requests this.perProjectCache.set(project.getProjectName(), { - compilerOptions: project.getCompilerOptions(), + compilerOptions: project.getCompilationSettings(), typeAcquisition, typings: result, unresolvedImports, @@ -125,4 +125,4 @@ namespace ts.server { this.installer.onProjectClosed(project); } } -} \ No newline at end of file +} diff --git a/src/server/utilities.ts b/src/server/utilities.ts index cde6329bf0764..6832e7ba34714 100644 --- a/src/server/utilities.ts +++ b/src/server/utilities.ts @@ -50,7 +50,7 @@ namespace ts.server { return { projectName: project.getProjectName(), fileNames: project.getFileNames(/*excludeFilesFromExternalLibraries*/ true, /*excludeConfigFiles*/ true).concat(project.getExcludedFiles() as NormalizedPath[]), - compilerOptions: project.getCompilerOptions(), + compilerOptions: project.getCompilationSettings(), typeAcquisition, unresolvedImports, projectRootPath: getProjectRootPath(project), @@ -173,10 +173,15 @@ namespace ts.server { export function createSortedArray(): SortedArray { return [] as SortedArray; } +} +/* @internal */ +namespace ts.server { export class ThrottledOperations { - private pendingTimeouts: Map = createMap(); - constructor(private readonly host: ServerHost) { + private readonly pendingTimeouts: Map = createMap(); + private readonly logger?: Logger | undefined; + constructor(private readonly host: ServerHost, logger: Logger) { + this.logger = logger.hasLevel(LogLevel.verbose) && logger; } /** @@ -193,10 +198,16 @@ namespace ts.server { } // schedule new operation, pass arguments this.pendingTimeouts.set(operationId, this.host.setTimeout(ThrottledOperations.run, delay, this, operationId, cb)); + if (this.logger) { + this.logger.info(`Scheduled: ${operationId}${pendingTimeout ? ", Cancelled earlier one" : ""}`); + } } private static run(self: ThrottledOperations, operationId: string, cb: () => void) { self.pendingTimeouts.delete(operationId); + if (self.logger) { + self.logger.info(`Running: ${operationId}`); + } cb(); } } @@ -227,10 +238,12 @@ namespace ts.server { } } } -} -/* @internal */ -namespace ts.server { + export function getBaseConfigFileName(configFilePath: NormalizedPath): "tsconfig.json" | "jsconfig.json" | undefined { + const base = getBaseFileName(configFilePath); + return base === "tsconfig.json" || base === "jsconfig.json" ? base : undefined; + } + export function insertSorted(array: SortedArray, insert: T, compare: Comparer): void { if (array.length === 0) { array.push(insert); diff --git a/src/services/jsTyping.ts b/src/services/jsTyping.ts index c775a995fd666..f1859a90b98fa 100644 --- a/src/services/jsTyping.ts +++ b/src/services/jsTyping.ts @@ -207,7 +207,7 @@ namespace ts.JsTyping { } // depth of 2, so we access `node_modules/foo` but not `node_modules/foo/bar` - const fileNames = host.readDirectory(packagesFolderPath, [".json"], /*excludes*/ undefined, /*includes*/ undefined, /*depth*/ 2); + const fileNames = host.readDirectory(packagesFolderPath, [Extension.Json], /*excludes*/ undefined, /*includes*/ undefined, /*depth*/ 2); if (log) log(`Searching for typing names in ${packagesFolderPath}; all files: ${JSON.stringify(fileNames)}`); const packageNames: string[] = []; for (const fileName of fileNames) { diff --git a/src/services/refactorProvider.ts b/src/services/refactorProvider.ts index c8dc1cf360a16..e956a4121c791 100644 --- a/src/services/refactorProvider.ts +++ b/src/services/refactorProvider.ts @@ -35,7 +35,7 @@ namespace ts { export function getApplicableRefactors(context: RefactorContext): ApplicableRefactorInfo[] { return flatMapIter(refactors.values(), refactor => - context.cancellationToken && context.cancellationToken.isCancellationRequested() ? [] : refactor.getAvailableActions(context)); + context.cancellationToken && context.cancellationToken.isCancellationRequested() ? undefined : refactor.getAvailableActions(context)); } export function getEditsForRefactor(context: RefactorContext, refactorName: string, actionName: string): RefactorEditInfo | undefined { diff --git a/src/services/services.ts b/src/services/services.ts index 0dfbd56f8d857..bc733a4e2feb8 100644 --- a/src/services/services.ts +++ b/src/services/services.ts @@ -820,18 +820,21 @@ namespace ts { return codefix.getSupportedErrorCodes(); } + // Either it will be file name if host doesnt have file or it will be the host's file information + type CachedHostFileInformation = HostFileInformation | string; + // Cache host information about script Should be refreshed // at each language service public entry point, since we don't know when // the set of scripts handled by the host changes. class HostCache { - private fileNameToEntry: Map; + private fileNameToEntry: Map; private _compilationSettings: CompilerOptions; private currentDirectory: string; constructor(private host: LanguageServiceHost, getCanonicalFileName: (fileName: string) => string) { // script id => script index this.currentDirectory = host.getCurrentDirectory(); - this.fileNameToEntry = createMap(); + this.fileNameToEntry = createMap(); // Initialize the list with the root file names const rootFileNames = host.getScriptFileNames(); @@ -848,7 +851,7 @@ namespace ts { } private createEntry(fileName: string, path: Path) { - let entry: HostFileInformation; + let entry: CachedHostFileInformation; const scriptSnapshot = this.host.getScriptSnapshot(fileName); if (scriptSnapshot) { entry = { @@ -858,44 +861,41 @@ namespace ts { scriptKind: getScriptKind(fileName, this.host) }; } + else { + entry = fileName; + } this.fileNameToEntry.set(path, entry); return entry; } - public getEntryByPath(path: Path): HostFileInformation { + public getEntryByPath(path: Path): CachedHostFileInformation | undefined { return this.fileNameToEntry.get(path); } - public containsEntryByPath(path: Path): boolean { - return this.fileNameToEntry.has(path); + public getHostFileInformation(path: Path): HostFileInformation | undefined { + const entry = this.fileNameToEntry.get(path); + return !isString(entry) ? entry : undefined; } public getOrCreateEntryByPath(fileName: string, path: Path): HostFileInformation { - return this.containsEntryByPath(path) - ? this.getEntryByPath(path) - : this.createEntry(fileName, path); + const info = this.getEntryByPath(path) || this.createEntry(fileName, path); + return isString(info) ? undefined : info; } public getRootFileNames(): string[] { - const fileNames: string[] = []; - - this.fileNameToEntry.forEach(value => { - if (value) { - fileNames.push(value.hostFileName); - } + return arrayFrom(this.fileNameToEntry.values(), entry => { + return isString(entry) ? entry : entry.hostFileName; }); - - return fileNames; } public getVersion(path: Path): string { - const file = this.getEntryByPath(path); + const file = this.getHostFileInformation(path); return file && file.version; } public getScriptSnapshot(path: Path): IScriptSnapshot { - const file = this.getEntryByPath(path); + const file = this.getHostFileInformation(path); return file && file.scriptSnapshot; } } @@ -1105,7 +1105,7 @@ namespace ts { if (host.getProjectVersion) { const hostProjectVersion = host.getProjectVersion(); if (hostProjectVersion) { - if (lastProjectVersion === hostProjectVersion) { + if (lastProjectVersion === hostProjectVersion && !host.hasChangedAutomaticTypeDirectiveNames) { return; } @@ -1122,9 +1122,12 @@ namespace ts { // Get a fresh cache of the host information let hostCache = new HostCache(host, getCanonicalFileName); + const rootFileNames = hostCache.getRootFileNames(); + + const hasInvalidatedResolution: HasInvalidatedResolution = host.hasInvalidatedResolution || returnFalse; // If the program is already up-to-date, we can reuse it - if (programUpToDate()) { + if (isProgramUptoDate(program, rootFileNames, hostCache.compilationSettings(), path => hostCache.getVersion(path), fileExists, hasInvalidatedResolution, host.hasChangedAutomaticTypeDirectiveNames)) { return; } @@ -1134,18 +1137,7 @@ namespace ts { // the program points to old source files that have been invalidated because of // incremental parsing. - const oldSettings = program && program.getCompilerOptions(); const newSettings = hostCache.compilationSettings(); - const shouldCreateNewSourceFiles = oldSettings && - (oldSettings.target !== newSettings.target || - oldSettings.module !== newSettings.module || - oldSettings.moduleResolution !== newSettings.moduleResolution || - oldSettings.noResolve !== newSettings.noResolve || - oldSettings.jsx !== newSettings.jsx || - oldSettings.allowJs !== newSettings.allowJs || - oldSettings.disableSizeLimit !== newSettings.disableSizeLimit || - oldSettings.baseUrl !== newSettings.baseUrl || - !equalOwnProperties(oldSettings.paths, newSettings.paths)); // Now create a new compiler const compilerHost: CompilerHost = { @@ -1158,19 +1150,13 @@ namespace ts { getDefaultLibFileName: (options) => host.getDefaultLibFileName(options), writeFile: noop, getCurrentDirectory: () => currentDirectory, - fileExists: (fileName): boolean => { - // stub missing host functionality - const path = toPath(fileName, currentDirectory, getCanonicalFileName); - return hostCache.containsEntryByPath(path) ? - !!hostCache.getEntryByPath(path) : - (host.fileExists && host.fileExists(fileName)); - }, + fileExists, readFile(fileName) { // stub missing host functionality const path = toPath(fileName, currentDirectory, getCanonicalFileName); - if (hostCache.containsEntryByPath(path)) { - const entry = hostCache.getEntryByPath(path); - return entry && entry.scriptSnapshot.getText(0, entry.scriptSnapshot.getLength()); + const entry = hostCache.getEntryByPath(path); + if (entry) { + return isString(entry) ? undefined : entry.scriptSnapshot.getText(0, entry.scriptSnapshot.getLength()); } return host.readFile && host.readFile(fileName); }, @@ -1179,14 +1165,17 @@ namespace ts { }, getDirectories: path => { return host.getDirectories ? host.getDirectories(path) : []; - } + }, + onReleaseOldSourceFile, + hasInvalidatedResolution, + hasChangedAutomaticTypeDirectiveNames: host.hasChangedAutomaticTypeDirectiveNames }; if (host.trace) { compilerHost.trace = message => host.trace(message); } if (host.resolveModuleNames) { - compilerHost.resolveModuleNames = (moduleNames, containingFile) => host.resolveModuleNames(moduleNames, containingFile); + compilerHost.resolveModuleNames = (moduleNames, containingFile, reusedNames) => host.resolveModuleNames(moduleNames, containingFile, reusedNames); } if (host.resolveTypeReferenceDirectives) { compilerHost.resolveTypeReferenceDirectives = (typeReferenceDirectiveNames, containingFile) => { @@ -1195,36 +1184,37 @@ namespace ts { } const documentRegistryBucketKey = documentRegistry.getKeyForCompilationSettings(newSettings); - const newProgram = createProgram(hostCache.getRootFileNames(), newSettings, compilerHost, program); - - // Release any files we have acquired in the old program but are - // not part of the new program. - if (program) { - const oldSourceFiles = program.getSourceFiles(); - const oldSettingsKey = documentRegistry.getKeyForCompilationSettings(oldSettings); - for (const oldSourceFile of oldSourceFiles) { - if (!newProgram.getSourceFile(oldSourceFile.fileName) || shouldCreateNewSourceFiles) { - documentRegistry.releaseDocumentWithKey(oldSourceFile.path, oldSettingsKey); - } - } - } + program = createProgram(rootFileNames, newSettings, compilerHost, program); // hostCache is captured in the closure for 'getOrCreateSourceFile' but it should not be used past this point. // It needs to be cleared to allow all collected snapshots to be released hostCache = undefined; - program = newProgram; - // Make sure all the nodes in the program are both bound, and have their parent // pointers set property. program.getTypeChecker(); return; - function getOrCreateSourceFile(fileName: string): SourceFile { - return getOrCreateSourceFileByPath(fileName, toPath(fileName, currentDirectory, getCanonicalFileName)); + function fileExists(fileName: string) { + const path = toPath(fileName, currentDirectory, getCanonicalFileName); + const entry = hostCache.getEntryByPath(path); + return entry ? + !isString(entry) : + (host.fileExists && host.fileExists(fileName)); + } + + // Release any files we have acquired in the old program but are + // not part of the new program. + function onReleaseOldSourceFile(oldSourceFile: SourceFile, oldOptions: CompilerOptions) { + const oldSettingsKey = documentRegistry.getKeyForCompilationSettings(oldOptions); + documentRegistry.releaseDocumentWithKey(oldSourceFile.path, oldSettingsKey); } - function getOrCreateSourceFileByPath(fileName: string, path: Path): SourceFile { + function getOrCreateSourceFile(fileName: string, languageVersion: ScriptTarget, onError?: (message: string) => void, shouldCreateNewSourceFile?: boolean): SourceFile { + return getOrCreateSourceFileByPath(fileName, toPath(fileName, currentDirectory, getCanonicalFileName), languageVersion, onError, shouldCreateNewSourceFile); + } + + function getOrCreateSourceFileByPath(fileName: string, path: Path, _languageVersion: ScriptTarget, _onError?: (message: string) => void, shouldCreateNewSourceFile?: boolean): SourceFile { Debug.assert(hostCache !== undefined); // The program is asking for this file, check first if the host can locate it. // If the host can not locate the file, then it does not exist. return undefined @@ -1237,7 +1227,7 @@ namespace ts { // Check if the language version has changed since we last created a program; if they are the same, // it is safe to reuse the sourceFiles; if not, then the shape of the AST can change, and the oldSourceFile // can not be reused. we have to dump all syntax trees and create new ones. - if (!shouldCreateNewSourceFiles) { + if (!shouldCreateNewSourceFile) { // Check if the old program had this file already const oldSourceFile = program && program.getSourceFileByPath(path); if (oldSourceFile) { @@ -1277,49 +1267,6 @@ namespace ts { // Could not find this file in the old program, create a new SourceFile for it. return documentRegistry.acquireDocumentWithKey(fileName, path, newSettings, documentRegistryBucketKey, hostFileInformation.scriptSnapshot, hostFileInformation.version, hostFileInformation.scriptKind); } - - function sourceFileUpToDate(sourceFile: SourceFile): boolean { - if (!sourceFile) { - return false; - } - const path = sourceFile.path || toPath(sourceFile.fileName, currentDirectory, getCanonicalFileName); - return sourceFile.version === hostCache.getVersion(path); - } - - function programUpToDate(): boolean { - // If we haven't create a program yet, then it is not up-to-date - if (!program) { - return false; - } - - // If number of files in the program do not match, it is not up-to-date - const rootFileNames = hostCache.getRootFileNames(); - if (program.getSourceFiles().length !== rootFileNames.length) { - return false; - } - - // If any file is not up-to-date, then the whole program is not up-to-date - for (const fileName of rootFileNames) { - if (!sourceFileUpToDate(program.getSourceFile(fileName))) { - return false; - } - } - - const currentOptions = program.getCompilerOptions(); - const newOptions = hostCache.compilationSettings(); - // If the compilation settings do no match, then the program is not up-to-date - if (!compareDataObjects(currentOptions, newOptions)) { - return false; - } - - // If everything matches but the text of config file is changed, - // error locations can change for program options, so update the program - if (currentOptions.configFile && newOptions.configFile) { - return currentOptions.configFile.text === newOptions.configFile.text; - } - - return true; - } } function getProgram(): Program { @@ -1562,23 +1509,12 @@ namespace ts { return ts.NavigateTo.getNavigateToItems(sourceFiles, program.getTypeChecker(), cancellationToken, searchValue, maxResultCount, excludeDtsFiles); } - function getEmitOutput(fileName: string, emitOnlyDtsFiles?: boolean): EmitOutput { + function getEmitOutput(fileName: string, emitOnlyDtsFiles?: boolean, isDetailed?: boolean) { synchronizeHostData(); const sourceFile = getValidSourceFile(fileName); - const outputFiles: OutputFile[] = []; - - function writeFile(fileName: string, text: string, writeByteOrderMark: boolean) { - outputFiles.push({ name: fileName, writeByteOrderMark, text }); - } - const customTransformers = host.getCustomTransformers && host.getCustomTransformers(); - const emitOutput = program.emit(sourceFile, writeFile, cancellationToken, emitOnlyDtsFiles, customTransformers); - - return { - outputFiles, - emitSkipped: emitOutput.emitSkipped - }; + return getFileEmitOutput(program, sourceFile, emitOnlyDtsFiles, isDetailed, cancellationToken, customTransformers); } // Signature help diff --git a/src/services/shims.ts b/src/services/shims.ts index e3103364f7346..737db44b83f34 100644 --- a/src/services/shims.ts +++ b/src/services/shims.ts @@ -527,7 +527,7 @@ namespace ts { if (logPerformance) { const end = timestamp(); logger.log(`${actionDescription} completed in ${end - start} msec`); - if (typeof result === "string") { + if (isString(result)) { let str = result; if (str.length > 128) { str = str.substring(0, 128) + "..."; diff --git a/src/services/transpile.ts b/src/services/transpile.ts index 79a69b886d99c..000bd124fa753 100644 --- a/src/services/transpile.ts +++ b/src/services/transpile.ts @@ -139,7 +139,7 @@ namespace ts { const value = options[opt.name]; // Value should be a key of opt.type - if (typeof value === "string") { + if (isString(value)) { // If value is not a string, this will fail options[opt.name] = parseCustomTypeOption(opt, value, diagnostics); } diff --git a/src/services/types.ts b/src/services/types.ts index a971995f0503e..4c77cd14d9c9a 100644 --- a/src/services/types.ts +++ b/src/services/types.ts @@ -183,8 +183,10 @@ namespace ts { * if implementation is omitted then language service will use built-in module resolution logic and get answers to * host specific questions using 'getScriptSnapshot'. */ - resolveModuleNames?(moduleNames: string[], containingFile: string): ResolvedModule[]; + resolveModuleNames?(moduleNames: string[], containingFile: string, reusedNames?: string[]): ResolvedModule[]; resolveTypeReferenceDirectives?(typeDirectiveNames: string[], containingFile: string): ResolvedTypeReferenceDirective[]; + hasInvalidatedResolution?: HasInvalidatedResolution; + hasChangedAutomaticTypeDirectiveNames?: boolean; directoryExists?(directoryName: string): boolean; /* @@ -277,6 +279,7 @@ namespace ts { getEditsForRefactor(fileName: string, formatOptions: FormatCodeSettings, positionOrRange: number | TextRange, refactorName: string, actionName: string): RefactorEditInfo | undefined; getEmitOutput(fileName: string, emitOnlyDtsFiles?: boolean): EmitOutput; + getEmitOutput(fileName: string, emitOnlyDtsFiles?: boolean, isDetailed?: boolean): EmitOutput | EmitOutputDetailed; getProgram(): Program; @@ -693,23 +696,12 @@ namespace ts { autoCollapse: boolean; } - export interface EmitOutput { - outputFiles: OutputFile[]; - emitSkipped: boolean; - } - export const enum OutputFileType { JavaScript, SourceMap, Declaration } - export interface OutputFile { - name: string; - writeByteOrderMark: boolean; - text: string; - } - export const enum EndOfLineState { None, InMultiLineCommentTrivia, diff --git a/src/services/utilities.ts b/src/services/utilities.ts index f367c48ac6fb2..43d5c5ce7bafe 100644 --- a/src/services/utilities.ts +++ b/src/services/utilities.ts @@ -999,26 +999,6 @@ namespace ts { return result; } - export function compareDataObjects(dst: any, src: any): boolean { - if (!dst || !src || Object.keys(dst).length !== Object.keys(src).length) { - return false; - } - - for (const e in dst) { - if (typeof dst[e] === "object") { - if (!compareDataObjects(dst[e], src[e])) { - return false; - } - } - else if (typeof dst[e] !== "function") { - if (dst[e] !== src[e]) { - return false; - } - } - } - return true; - } - export function isArrayLiteralOrObjectLiteralDestructuringPattern(node: Node) { if (node.kind === SyntaxKind.ArrayLiteralExpression || node.kind === SyntaxKind.ObjectLiteralExpression) {