diff --git a/src/compiler/commandLineParser.ts b/src/compiler/commandLineParser.ts index 025218e62fdbb..251eeb58b1569 100644 --- a/src/compiler/commandLineParser.ts +++ b/src/compiler/commandLineParser.ts @@ -841,7 +841,7 @@ namespace ts { * @param basePath A root directory to resolve relative path entries in the config * file to. e.g. outDir */ - export function parseJsonConfigFileContent(json: any, host: ParseConfigHost, basePath: string, existingOptions: CompilerOptions = {}, configFileName?: string, resolutionStack: Path[] = []): ParsedCommandLine { + export function parseJsonConfigFileContent(json: any, host: ParseConfigHost, basePath: string, existingOptions: CompilerOptions = {}, configFileName?: string, resolutionStack: Path[] = [], extraFileExtensions: FileExtensionInfo[] = []): ParsedCommandLine { const errors: Diagnostic[] = []; const getCanonicalFileName = createGetCanonicalFileName(host.useCaseSensitiveFileNames); const resolvedPath = toPath(configFileName || "", basePath, getCanonicalFileName); @@ -981,7 +981,7 @@ namespace ts { includeSpecs = ["**/*"]; } - const result = matchFileNames(fileNames, includeSpecs, excludeSpecs, basePath, options, host, errors); + const result = matchFileNames(fileNames, includeSpecs, excludeSpecs, basePath, options, host, errors, extraFileExtensions); if (result.fileNames.length === 0 && !hasProperty(json, "files") && resolutionStack.length === 0) { errors.push( @@ -1185,7 +1185,7 @@ namespace ts { * @param host The host used to resolve files and directories. * @param errors An array for diagnostic reporting. */ - function matchFileNames(fileNames: string[], include: string[], exclude: string[], basePath: string, options: CompilerOptions, host: ParseConfigHost, errors: Diagnostic[]): ExpandResult { + function matchFileNames(fileNames: string[], include: string[], exclude: string[], basePath: string, options: CompilerOptions, host: ParseConfigHost, errors: Diagnostic[], extraFileExtensions: FileExtensionInfo[]): ExpandResult { basePath = normalizePath(basePath); // The exclude spec list is converted into a regular expression, which allows us to quickly @@ -1219,7 +1219,7 @@ namespace ts { // Rather than requery this for each file and filespec, we query the supported extensions // once and store it on the expansion context. - const supportedExtensions = getSupportedExtensions(options); + const supportedExtensions = getSupportedExtensions(options, extraFileExtensions); // Literal files are always included verbatim. An "include" or "exclude" specification cannot // remove a literal file. diff --git a/src/compiler/core.ts b/src/compiler/core.ts index dec6b7cc48568..ceff135957940 100644 --- a/src/compiler/core.ts +++ b/src/compiler/core.ts @@ -1924,8 +1924,18 @@ namespace ts { export const supportedJavascriptExtensions = [".js", ".jsx"]; const allSupportedExtensions = supportedTypeScriptExtensions.concat(supportedJavascriptExtensions); - export function getSupportedExtensions(options?: CompilerOptions): string[] { - return options && options.allowJs ? allSupportedExtensions : supportedTypeScriptExtensions; + export function getSupportedExtensions(options?: CompilerOptions, extraFileExtensions?: FileExtensionInfo[]): string[] { + const needAllExtensions = options && options.allowJs; + if (!extraFileExtensions || extraFileExtensions.length === 0) { + return needAllExtensions ? allSupportedExtensions : supportedTypeScriptExtensions; + } + const extensions = (needAllExtensions ? allSupportedExtensions : supportedTypeScriptExtensions).slice(0); + for (const extInfo of extraFileExtensions) { + if (needAllExtensions || extInfo.scriptKind === ScriptKind.TS) { + extensions.push(extInfo.extension); + } + } + return extensions; } export function hasJavaScriptFileExtension(fileName: string) { @@ -1936,10 +1946,10 @@ namespace ts { return forEach(supportedTypeScriptExtensions, extension => fileExtensionIs(fileName, extension)); } - export function isSupportedSourceFileName(fileName: string, compilerOptions?: CompilerOptions) { + export function isSupportedSourceFileName(fileName: string, compilerOptions?: CompilerOptions, extraFileExtensions?: FileExtensionInfo[]) { if (!fileName) { return false; } - for (const extension of getSupportedExtensions(compilerOptions)) { + for (const extension of getSupportedExtensions(compilerOptions, extraFileExtensions)) { if (fileExtensionIs(fileName, extension)) { return true; } diff --git a/src/compiler/types.ts b/src/compiler/types.ts index c1592d30eaeac..da9d91e2acf9e 100644 --- a/src/compiler/types.ts +++ b/src/compiler/types.ts @@ -3086,6 +3086,12 @@ namespace ts { ThisProperty } + export interface FileExtensionInfo { + extension: string; + scriptKind: ScriptKind; + isMixedContent: boolean; + } + export interface DiagnosticMessage { key: string; category: DiagnosticCategory; diff --git a/src/harness/unittests/tsserverProjectSystem.ts b/src/harness/unittests/tsserverProjectSystem.ts index a69a226becfa2..59652f91d6702 100644 --- a/src/harness/unittests/tsserverProjectSystem.ts +++ b/src/harness/unittests/tsserverProjectSystem.ts @@ -1591,6 +1591,67 @@ namespace ts.projectSystem { checkProjectActualFiles(projectService.inferredProjects[1], [file2.path]); }); + it("tsconfig script block support", () => { + const file1 = { + path: "/a/b/f1.ts", + content: ` ` + }; + const file2 = { + path: "/a/b/f2.html", + content: `var hello = "hello";` + }; + const config = { + path: "/a/b/tsconfig.json", + content: JSON.stringify({ compilerOptions: { allowJs: true } }) + }; + const host = createServerHost([file1, file2, config]); + const session = createSession(host); + openFilesForSession([file1], session); + const projectService = session.getProjectService(); + + // HTML file will not be included in any projects yet + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + checkProjectActualFiles(projectService.configuredProjects[0], [file1.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 + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + checkProjectActualFiles(projectService.configuredProjects[0], [file1.path]); + + // Open HTML file + projectService.applyChangesInOpenFiles( + /*openFiles*/[{ fileName: file2.path, hasMixedContent: true, scriptKind: ScriptKind.JS, content: `var hello = "hello";` }], + /*changedFiles*/undefined, + /*closedFiles*/undefined); + + // Now HTML file is included in the project + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, file2.path]); + + // Check identifiers defined in HTML content are available in .ts file + const project = projectService.configuredProjects[0]; + let completions = project.getLanguageService().getCompletionsAtPosition(file1.path, 1); + assert(completions && completions.entries[0].name === "hello", `expected entry hello to be in completion list`); + + // Close HTML file + projectService.applyChangesInOpenFiles( + /*openFiles*/undefined, + /*changedFiles*/undefined, + /*closedFiles*/[file2.path]); + + // HTML file is still included in project + checkNumberOfProjects(projectService, { configuredProjects: 1 }); + checkProjectActualFiles(projectService.configuredProjects[0], [file1.path, file2.path]); + + // Check identifiers defined in HTML content are not available in .ts file + completions = project.getLanguageService().getCompletionsAtPosition(file1.path, 5); + assert(completions && completions.entries[0].name !== "hello", `unexpected hello entry in completion list`); + }); + it("project structure update is deferred if files are not added\removed", () => { const file1 = { path: "/a/b/f1.ts", diff --git a/src/server/editorServices.ts b/src/server/editorServices.ts index 7ed24ea71f8e6..a70b16a5b1e5d 100644 --- a/src/server/editorServices.ts +++ b/src/server/editorServices.ts @@ -108,6 +108,7 @@ namespace ts.server { export interface HostConfiguration { formatCodeOptions: FormatCodeSettings; hostInfo: string; + extraFileExtensions?: FileExtensionInfo[]; } interface ConfigFileConversionResult { @@ -132,13 +133,16 @@ namespace ts.server { interface FilePropertyReader { getFileName(f: T): string; getScriptKind(f: T): ScriptKind; - hasMixedContent(f: T): boolean; + hasMixedContent(f: T, extraFileExtensions: FileExtensionInfo[]): boolean; } const fileNamePropertyReader: FilePropertyReader = { getFileName: x => x, getScriptKind: _ => undefined, - hasMixedContent: _ => false + hasMixedContent: (fileName, extraFileExtensions) => { + const mixedContentExtensions = ts.map(ts.filter(extraFileExtensions, item => item.isMixedContent), item => item.extension); + return forEach(mixedContentExtensions, extension => fileExtensionIs(fileName, extension)) + } }; const externalFilePropertyReader: FilePropertyReader = { @@ -282,7 +286,8 @@ namespace ts.server { this.hostConfiguration = { formatCodeOptions: getDefaultFormatCodeSettings(this.host), - hostInfo: "Unknown host" + hostInfo: "Unknown host", + extraFileExtensions: [] }; this.documentRegistry = createDocumentRegistry(host.useCaseSensitiveFileNames, host.getCurrentDirectory()); @@ -486,7 +491,7 @@ namespace ts.server { // 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 && !ts.isSupportedSourceFileName(fileName, project.getCompilerOptions())) { + if (fileName && !ts.isSupportedSourceFileName(fileName, project.getCompilerOptions(), this.hostConfiguration.extraFileExtensions)) { return; } @@ -642,6 +647,9 @@ namespace ts.server { let projectsToRemove: Project[]; for (const p of info.containingProjects) { if (p.projectKind === ProjectKind.Configured) { + if (info.hasMixedContent) { + info.registerFileUpdate(); + } // last open file in configured project - close it if ((p).deleteOpenRef() === 0) { (projectsToRemove || (projectsToRemove = [])).push(p); @@ -810,7 +818,9 @@ namespace ts.server { this.host, getDirectoryPath(configFilename), /*existingOptions*/ {}, - configFilename); + configFilename, + /*resolutionStack*/ [], + this.hostConfiguration.extraFileExtensions); if (parsedCommandLine.errors.length) { errors = concatenate(errors, parsedCommandLine.errors); @@ -914,7 +924,7 @@ namespace ts.server { for (const f of files) { const rootFilename = propertyReader.getFileName(f); const scriptKind = propertyReader.getScriptKind(f); - const hasMixedContent = propertyReader.hasMixedContent(f); + const hasMixedContent = propertyReader.hasMixedContent(f, this.hostConfiguration.extraFileExtensions); if (this.host.fileExists(rootFilename)) { const info = this.getOrCreateScriptInfoForNormalizedPath(toNormalizedPath(rootFilename), /*openedByClient*/ clientFileName == rootFilename, /*fileContent*/ undefined, scriptKind, hasMixedContent); project.addRoot(info); @@ -960,7 +970,7 @@ namespace ts.server { rootFilesChanged = true; if (!scriptInfo) { const scriptKind = propertyReader.getScriptKind(f); - const hasMixedContent = propertyReader.hasMixedContent(f); + const hasMixedContent = propertyReader.hasMixedContent(f, this.hostConfiguration.extraFileExtensions); scriptInfo = this.getOrCreateScriptInfoForNormalizedPath(normalizedPath, /*openedByClient*/ false, /*fileContent*/ undefined, scriptKind, hasMixedContent); } } @@ -1110,6 +1120,9 @@ namespace ts.server { if (info) { if (openedByClient && !info.isScriptOpen()) { info.open(fileContent); + if (hasMixedContent) { + info.registerFileUpdate(); + } } else if (fileContent !== undefined) { info.reload(fileContent); @@ -1144,6 +1157,10 @@ namespace ts.server { mergeMaps(this.hostConfiguration.formatCodeOptions, convertFormatOptions(args.formatOptions)); this.logger.info("Format host information updated"); } + if (args.extraFileExtensions) { + this.hostConfiguration.extraFileExtensions = args.extraFileExtensions; + this.logger.info("Host file extension mappings updated"); + } } } diff --git a/src/server/project.ts b/src/server/project.ts index ca117fb97cfbd..392008a9fd6dc 100644 --- a/src/server/project.ts +++ b/src/server/project.ts @@ -1,4 +1,4 @@ -/// +/// /// /// /// @@ -187,6 +187,10 @@ namespace ts.server { public languageServiceEnabled = true; builder: Builder; + /** + * Set of files names that were updated since the last call to getChangesSinceVersion. + */ + private updatedFileNames: Map; /** * Set of files that was returned from the last call to getChangesSinceVersion. */ @@ -480,6 +484,10 @@ namespace ts.server { this.markAsDirty(); } + registerFileUpdate(fileName: string) { + (this.updatedFileNames || (this.updatedFileNames = createMap()))[fileName] = fileName; + } + markAsDirty() { this.projectStateVersion++; } @@ -667,10 +675,12 @@ namespace ts.server { isInferred: this.projectKind === ProjectKind.Inferred, options: this.getCompilerOptions() }; + const updatedFileNames = this.updatedFileNames; + this.updatedFileNames = undefined; // check if requested version is the same that we have reported last time if (this.lastReportedFileNames && lastKnownVersion === this.lastReportedVersion) { - // if current structure version is the same - return info witout any changes - if (this.projectStructureVersion == this.lastReportedVersion) { + // if current structure version is the same - return info without any changes + if (this.projectStructureVersion == this.lastReportedVersion && !updatedFileNames) { return { info, projectErrors: this.projectErrors }; } // compute and return the difference @@ -679,6 +689,7 @@ namespace ts.server { const added: string[] = []; const removed: string[] = []; + const updated: string[] = getOwnKeys(updatedFileNames); for (const id in currentFiles) { if (!hasProperty(lastReportedFileNames, id)) { added.push(id); @@ -691,7 +702,7 @@ namespace ts.server { } this.lastReportedFileNames = currentFiles; this.lastReportedVersion = this.projectStructureVersion; - return { info, changes: { added, removed }, projectErrors: this.projectErrors }; + return { info, changes: { added, removed, updated }, projectErrors: this.projectErrors }; } else { // unknown version - return everything diff --git a/src/server/protocol.ts b/src/server/protocol.ts index 1e29b0291040e..27faf41728241 100644 --- a/src/server/protocol.ts +++ b/src/server/protocol.ts @@ -1,4 +1,4 @@ -/** +/** * Declaration module describing the TypeScript Server protocol */ namespace ts.server.protocol { @@ -918,6 +918,10 @@ namespace ts.server.protocol { * List of removed files */ removed: string[]; + /** + * List of updated files + */ + updated: string[]; } /** @@ -990,6 +994,11 @@ namespace ts.server.protocol { * The format options to use during formatting and other code editing features. */ formatOptions?: FormatCodeSettings; + + /** + * The host's additional supported file extensions + */ + extraFileExtensions?: FileExtensionInfo[]; } /** diff --git a/src/server/scriptInfo.ts b/src/server/scriptInfo.ts index b8d22c5c56df8..0acd45d0287eb 100644 --- a/src/server/scriptInfo.ts +++ b/src/server/scriptInfo.ts @@ -170,7 +170,6 @@ namespace ts.server { private isOpen: boolean; - // TODO: allow to update hasMixedContent from the outside constructor( private readonly host: ServerHost, readonly fileName: NormalizedPath, @@ -268,6 +267,12 @@ namespace ts.server { return this.containingProjects[0]; } + registerFileUpdate(): void { + for (const p of this.containingProjects) { + p.registerFileUpdate(this.path); + } + } + setFormatOptions(formatSettings: FormatCodeSettings): void { if (formatSettings) { if (!this.formatCodeSettings) { diff --git a/src/server/utilities.ts b/src/server/utilities.ts index fd370da7fa15f..839e79268fad8 100644 --- a/src/server/utilities.ts +++ b/src/server/utilities.ts @@ -1,4 +1,4 @@ -/// +/// /// namespace ts.server {