TypeScript/src/server/editorServices.ts
Andrew Branch 266d8de64a
Proposal: importModuleSpecifierPreference: project-relative (#40637)
* Add new importModuleSpecifierPreference value

* Add second test

* Update API baselines

* Clean up and add some comments

* Rename option value
2020-11-11 11:48:32 -08:00

3975 lines
208 KiB
TypeScript

namespace ts.server {
export const maxProgramSizeForNonTsFiles = 20 * 1024 * 1024;
/*@internal*/
export const maxFileSize = 4 * 1024 * 1024;
export const ProjectsUpdatedInBackgroundEvent = "projectsUpdatedInBackground";
export const ProjectLoadingStartEvent = "projectLoadingStart";
export const ProjectLoadingFinishEvent = "projectLoadingFinish";
export const LargeFileReferencedEvent = "largeFileReferenced";
export const ConfigFileDiagEvent = "configFileDiag";
export const ProjectLanguageServiceStateEvent = "projectLanguageServiceState";
export const ProjectInfoTelemetryEvent = "projectInfo";
export const OpenFileInfoTelemetryEvent = "openFileInfo";
const ensureProjectForOpenFileSchedule = "*ensureProjectForOpenFiles*";
export interface ProjectsUpdatedInBackgroundEvent {
eventName: typeof ProjectsUpdatedInBackgroundEvent;
data: { openFiles: string[]; };
}
export interface ProjectLoadingStartEvent {
eventName: typeof ProjectLoadingStartEvent;
data: { project: Project; reason: string; };
}
export interface ProjectLoadingFinishEvent {
eventName: typeof ProjectLoadingFinishEvent;
data: { project: Project; };
}
export interface LargeFileReferencedEvent {
eventName: typeof LargeFileReferencedEvent;
data: { file: string; fileSize: number; maxFileSize: number; };
}
export interface ConfigFileDiagEvent {
eventName: typeof ConfigFileDiagEvent;
data: { triggerFile: string, configFileName: string, diagnostics: readonly Diagnostic[] };
}
export interface ProjectLanguageServiceStateEvent {
eventName: typeof ProjectLanguageServiceStateEvent;
data: { project: Project, languageServiceEnabled: boolean };
}
/** This will be converted to the payload of a protocol.TelemetryEvent in session.defaultEventHandler. */
export interface ProjectInfoTelemetryEvent {
readonly eventName: typeof ProjectInfoTelemetryEvent;
readonly data: ProjectInfoTelemetryEventData;
}
/*
* __GDPR__
* "projectInfo" : {
* "${include}": ["${TypeScriptCommonProperties}"],
* "projectId": { "classification": "EndUserPseudonymizedInformation", "purpose": "FeatureInsight", "endpoint": "ProjectId" },
* "fileStats": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
* "compilerOptions": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
* "extends": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
* "files": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
* "include": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
* "exclude": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
* "compileOnSave": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
* "typeAcquisition": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
* "configFileName": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
* "projectType": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
* "languageServiceEnabled": { "classification": "SystemMetaData", "purpose": "FeatureInsight" },
* "version": { "classification": "SystemMetaData", "purpose": "FeatureInsight" }
* }
*/
export interface ProjectInfoTelemetryEventData {
/** Cryptographically secure hash of project file location. */
readonly projectId: string;
/** Count of file extensions seen in the project. */
readonly fileStats: FileStats;
/**
* Any compiler options that might contain paths will be taken out.
* Enum compiler options will be converted to strings.
*/
readonly compilerOptions: CompilerOptions;
// "extends", "files", "include", or "exclude" will be undefined if an external config is used.
// Otherwise, we will use "true" if the property is present and "false" if it is missing.
readonly extends: boolean | undefined;
readonly files: boolean | undefined;
readonly include: boolean | undefined;
readonly exclude: boolean | undefined;
readonly compileOnSave: boolean;
readonly typeAcquisition: ProjectInfoTypeAcquisitionData;
readonly configFileName: "tsconfig.json" | "jsconfig.json" | "other";
readonly projectType: "external" | "configured";
readonly languageServiceEnabled: boolean;
/** TypeScript version used by the server. */
readonly version: string;
}
/**
* Info that we may send about a file that was just opened.
* Info about a file will only be sent once per session, even if the file changes in ways that might affect the info.
* Currently this is only sent for '.js' files.
*/
export interface OpenFileInfoTelemetryEvent {
readonly eventName: typeof OpenFileInfoTelemetryEvent;
readonly data: OpenFileInfoTelemetryEventData;
}
export interface OpenFileInfoTelemetryEventData {
readonly info: OpenFileInfo;
}
export interface ProjectInfoTypeAcquisitionData {
readonly enable: boolean | undefined;
// Actual values of include/exclude entries are scrubbed.
readonly include: boolean;
readonly exclude: boolean;
}
export interface FileStats {
readonly js: number;
readonly jsSize?: number;
readonly jsx: number;
readonly jsxSize?: number;
readonly ts: number;
readonly tsSize?: number;
readonly tsx: number;
readonly tsxSize?: number;
readonly dts: number;
readonly dtsSize?: number;
readonly deferred: number;
readonly deferredSize?: number;
}
export interface OpenFileInfo {
readonly checkJs: boolean;
}
export type ProjectServiceEvent =
LargeFileReferencedEvent
| ProjectsUpdatedInBackgroundEvent
| ProjectLoadingStartEvent
| ProjectLoadingFinishEvent
| ConfigFileDiagEvent
| ProjectLanguageServiceStateEvent
| ProjectInfoTelemetryEvent
| OpenFileInfoTelemetryEvent;
export type ProjectServiceEventHandler = (event: ProjectServiceEvent) => void;
/*@internal*/
export type PerformanceEventHandler = (event: PerformanceEvent) => void;
export interface SafeList {
[name: string]: { match: RegExp, exclude?: (string | number)[][], types?: string[] };
}
function prepareConvertersForEnumLikeCompilerOptions(commandLineOptions: CommandLineOption[]): ESMap<string, ESMap<string, number>> {
const map = new Map<string, ESMap<string, number>>();
for (const option of commandLineOptions) {
if (typeof option.type === "object") {
const optionMap = <ESMap<string, number>>option.type;
// verify that map contains only numbers
optionMap.forEach(value => {
Debug.assert(typeof value === "number");
});
map.set(option.name, optionMap);
}
}
return map;
}
const compilerOptionConverters = prepareConvertersForEnumLikeCompilerOptions(optionDeclarations);
const watchOptionsConverters = prepareConvertersForEnumLikeCompilerOptions(optionsForWatch);
const indentStyle = new Map(getEntries({
none: IndentStyle.None,
block: IndentStyle.Block,
smart: IndentStyle.Smart
}));
export interface TypesMapFile {
typesMap: SafeList;
simpleMap: { [libName: string]: string };
}
/**
* How to understand this block:
* * The 'match' property is a regexp that matches a filename.
* * If 'match' is successful, then:
* * All files from 'exclude' are removed from the project. See below.
* * All 'types' are included in ATA
* * What the heck is 'exclude' ?
* * An array of an array of strings and numbers
* * Each array is:
* * An array of strings and numbers
* * The strings are literals
* * The numbers refer to capture group indices from the 'match' regexp
* * Remember that '1' is the first group
* * These are concatenated together to form a new regexp
* * Filenames matching these regexps are excluded from the project
* This default value is tested in tsserverProjectSystem.ts; add tests there
* if you are changing this so that you can be sure your regexp works!
*/
const defaultTypeSafeList: SafeList = {
"jquery": {
// jquery files can have names like "jquery-1.10.2.min.js" (or "jquery.intellisense.js")
match: /jquery(-(\.?\d+)+)?(\.intellisense)?(\.min)?\.js$/i,
types: ["jquery"]
},
"WinJS": {
// e.g. c:/temp/UWApp1/lib/winjs-4.0.1/js/base.js
match: /^(.*\/winjs-[.\d]+)\/js\/base\.js$/i, // If the winjs/base.js file is found..
exclude: [["^", 1, "/.*"]], // ..then exclude all files under the winjs folder
types: ["winjs"] // And fetch the @types package for WinJS
},
"Kendo": {
// e.g. /Kendo3/wwwroot/lib/kendo/kendo.all.min.js
match: /^(.*\/kendo(-ui)?)\/kendo\.all(\.min)?\.js$/i,
exclude: [["^", 1, "/.*"]],
types: ["kendo-ui"]
},
"Office Nuget": {
// e.g. /scripts/Office/1/excel-15.debug.js
match: /^(.*\/office\/1)\/excel-\d+\.debug\.js$/i, // Office NuGet package is installed under a "1/office" folder
exclude: [["^", 1, "/.*"]], // Exclude that whole folder if the file indicated above is found in it
types: ["office"] // @types package to fetch instead
},
"References": {
match: /^(.*\/_references\.js)$/i,
exclude: [["^", 1, "$"]]
}
};
export function convertFormatOptions(protocolOptions: protocol.FormatCodeSettings): FormatCodeSettings {
if (isString(protocolOptions.indentStyle)) {
protocolOptions.indentStyle = indentStyle.get(protocolOptions.indentStyle.toLowerCase());
Debug.assert(protocolOptions.indentStyle !== undefined);
}
return <any>protocolOptions;
}
export function convertCompilerOptions(protocolOptions: protocol.ExternalProjectCompilerOptions): CompilerOptions & protocol.CompileOnSaveMixin {
compilerOptionConverters.forEach((mappedValues, id) => {
const propertyValue = protocolOptions[id];
if (isString(propertyValue)) {
protocolOptions[id] = mappedValues.get(propertyValue.toLowerCase());
}
});
return <any>protocolOptions;
}
export function convertWatchOptions(protocolOptions: protocol.ExternalProjectCompilerOptions, currentDirectory?: string): WatchOptionsAndErrors | undefined {
let watchOptions: WatchOptions | undefined;
let errors: Diagnostic[] | undefined;
optionsForWatch.forEach(option => {
const propertyValue = protocolOptions[option.name];
if (propertyValue === undefined) return;
const mappedValues = watchOptionsConverters.get(option.name);
(watchOptions || (watchOptions = {}))[option.name] = mappedValues ?
isString(propertyValue) ? mappedValues.get(propertyValue.toLowerCase()) : propertyValue :
convertJsonOption(option, propertyValue, currentDirectory || "", errors || (errors = []));
});
return watchOptions && { watchOptions, errors };
}
export function convertTypeAcquisition(protocolOptions: protocol.InferredProjectCompilerOptions): TypeAcquisition | undefined {
let result: TypeAcquisition | undefined;
typeAcquisitionDeclarations.forEach((option) => {
const propertyValue = protocolOptions[option.name];
if (propertyValue === undefined) return;
(result || (result = {}))[option.name] = propertyValue;
});
return result;
}
export function tryConvertScriptKindName(scriptKindName: protocol.ScriptKindName | ScriptKind): ScriptKind {
return isString(scriptKindName) ? convertScriptKindName(scriptKindName) : scriptKindName;
}
export function convertScriptKindName(scriptKindName: protocol.ScriptKindName) {
switch (scriptKindName) {
case "JS":
return ScriptKind.JS;
case "JSX":
return ScriptKind.JSX;
case "TS":
return ScriptKind.TS;
case "TSX":
return ScriptKind.TSX;
default:
return ScriptKind.Unknown;
}
}
/*@internal*/
export function convertUserPreferences(preferences: protocol.UserPreferences): UserPreferences {
const { lazyConfiguredProjectsFromExternalProject, ...userPreferences } = preferences;
return userPreferences;
}
export interface HostConfiguration {
formatCodeOptions: FormatCodeSettings;
preferences: protocol.UserPreferences;
hostInfo: string;
extraFileExtensions?: FileExtensionInfo[];
watchOptions?: WatchOptions;
}
export interface OpenConfiguredProjectResult {
configFileName?: NormalizedPath;
configFileErrors?: readonly Diagnostic[];
}
interface AssignProjectResult extends OpenConfiguredProjectResult {
retainProjects: readonly ConfiguredProject[] | ConfiguredProject | undefined;
}
interface FilePropertyReader<T> {
getFileName(f: T): string;
getScriptKind(f: T, extraFileExtensions?: FileExtensionInfo[]): ScriptKind;
hasMixedContent(f: T, extraFileExtensions: FileExtensionInfo[] | undefined): boolean;
}
const fileNamePropertyReader: FilePropertyReader<string> = {
getFileName: x => x,
getScriptKind: (fileName, extraFileExtensions) => {
let result: ScriptKind | undefined;
if (extraFileExtensions) {
const fileExtension = getAnyExtensionFromPath(fileName);
if (fileExtension) {
some(extraFileExtensions, info => {
if (info.extension === fileExtension) {
result = info.scriptKind;
return true;
}
return false;
});
}
}
return result!; // TODO: GH#18217
},
hasMixedContent: (fileName, extraFileExtensions) => some(extraFileExtensions, ext => ext.isMixedContent && fileExtensionIs(fileName, ext.extension)),
};
const externalFilePropertyReader: FilePropertyReader<protocol.ExternalFile> = {
getFileName: x => x.fileName,
getScriptKind: x => tryConvertScriptKindName(x.scriptKind!), // TODO: GH#18217
hasMixedContent: x => !!x.hasMixedContent,
};
function findProjectByName<T extends Project>(projectName: string, projects: T[]): T | undefined {
for (const proj of projects) {
if (proj.getProjectName() === projectName) {
return proj;
}
}
}
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",
}
/*@internal*/
interface ConfigFileExistenceInfo {
/**
* 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)
*/
exists: boolean;
/**
* 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 existence of
* this config file but it is not the root of inferred project
*/
openFilesImpactedByConfigFile: ESMap<Path, boolean>;
/**
* 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 {
host: ServerHost;
logger: Logger;
cancellationToken: HostCancellationToken;
useSingleInferredProject: boolean;
useInferredProjectPerProjectRoot: boolean;
typingsInstaller: ITypingsInstaller;
eventHandler?: ProjectServiceEventHandler;
suppressDiagnosticEvents?: boolean;
throttleWaitMilliseconds?: number;
globalPlugins?: readonly string[];
pluginProbeLocations?: readonly string[];
allowLocalPluginLoads?: boolean;
typesMapLocation?: string;
/** @deprecated use serverMode instead */
syntaxOnly?: boolean;
serverMode?: LanguageServiceMode;
}
interface OriginalFileInfo { fileName: NormalizedPath; path: Path; }
interface AncestorConfigFileInfo {
/** config file name */
fileName: string;
/** path of open file so we can look at correct root */
path: Path;
configFileInfo: true;
}
type OpenScriptInfoOrClosedFileInfo = ScriptInfo | OriginalFileInfo;
type OpenScriptInfoOrClosedOrConfigFileInfo = OpenScriptInfoOrClosedFileInfo | AncestorConfigFileInfo;
function isOpenScriptInfo(infoOrFileNameOrConfig: OpenScriptInfoOrClosedOrConfigFileInfo): infoOrFileNameOrConfig is ScriptInfo {
return !!(infoOrFileNameOrConfig as ScriptInfo).containingProjects;
}
function isAncestorConfigFileInfo(infoOrFileNameOrConfig: OpenScriptInfoOrClosedOrConfigFileInfo): infoOrFileNameOrConfig is AncestorConfigFileInfo {
return !!(infoOrFileNameOrConfig as AncestorConfigFileInfo).configFileInfo;
}
/*@internal*/
/** Kind of operation to perform to get project reference project */
export enum ProjectReferenceProjectLoadKind {
/** Find existing project for project reference */
Find,
/** Find existing project or create one for the project reference */
FindCreate,
/** Find existing project or create and load it for the project reference */
FindCreateLoad
}
/*@internal*/
export function forEachResolvedProjectReferenceProject<T>(
project: ConfiguredProject,
fileName: string | undefined,
cb: (child: ConfiguredProject) => T | undefined,
projectReferenceProjectLoadKind: ProjectReferenceProjectLoadKind.Find | ProjectReferenceProjectLoadKind.FindCreate,
): T | undefined;
/*@internal*/
export function forEachResolvedProjectReferenceProject<T>(
project: ConfiguredProject,
fileName: string | undefined,
cb: (child: ConfiguredProject) => T | undefined,
projectReferenceProjectLoadKind: ProjectReferenceProjectLoadKind,
reason: string
): T | undefined;
export function forEachResolvedProjectReferenceProject<T>(
project: ConfiguredProject,
fileName: string | undefined,
cb: (child: ConfiguredProject) => T | undefined,
projectReferenceProjectLoadKind: ProjectReferenceProjectLoadKind,
reason?: string
): T | undefined {
const resolvedRefs = project.getCurrentProgram()?.getResolvedProjectReferences();
if (!resolvedRefs) return undefined;
let seenResolvedRefs: ESMap<string, ProjectReferenceProjectLoadKind> | undefined;
const possibleDefaultRef = fileName ? project.getResolvedProjectReferenceToRedirect(fileName) : undefined;
if (possibleDefaultRef) {
// Try to find the name of the file directly through resolved project references
const configFileName = toNormalizedPath(possibleDefaultRef.sourceFile.fileName);
const child = project.projectService.findConfiguredProjectByProjectName(configFileName);
if (child) {
const result = cb(child);
if (result) return result;
}
else if (projectReferenceProjectLoadKind !== ProjectReferenceProjectLoadKind.Find) {
seenResolvedRefs = new Map();
// Try to see if this project can be loaded
const result = forEachResolvedProjectReferenceProjectWorker(
resolvedRefs,
project.getCompilerOptions(),
(ref, loadKind) => possibleDefaultRef === ref ? callback(ref, loadKind) : undefined,
projectReferenceProjectLoadKind,
project.projectService,
seenResolvedRefs
);
if (result) return result;
// Cleanup seenResolvedRefs
seenResolvedRefs.clear();
}
}
return forEachResolvedProjectReferenceProjectWorker(
resolvedRefs,
project.getCompilerOptions(),
(ref, loadKind) => possibleDefaultRef !== ref ? callback(ref, loadKind) : undefined,
projectReferenceProjectLoadKind,
project.projectService,
seenResolvedRefs
);
function callback(ref: ResolvedProjectReference, loadKind: ProjectReferenceProjectLoadKind) {
const configFileName = toNormalizedPath(ref.sourceFile.fileName);
const child = project.projectService.findConfiguredProjectByProjectName(configFileName) || (
loadKind === ProjectReferenceProjectLoadKind.Find ?
undefined :
loadKind === ProjectReferenceProjectLoadKind.FindCreate ?
project.projectService.createConfiguredProject(configFileName) :
loadKind === ProjectReferenceProjectLoadKind.FindCreateLoad ?
project.projectService.createAndLoadConfiguredProject(configFileName, reason!) :
Debug.assertNever(loadKind)
);
return child && cb(child);
}
}
function forEachResolvedProjectReferenceProjectWorker<T>(
resolvedProjectReferences: readonly (ResolvedProjectReference | undefined)[],
parentOptions: CompilerOptions,
cb: (resolvedRef: ResolvedProjectReference, loadKind: ProjectReferenceProjectLoadKind) => T | undefined,
projectReferenceProjectLoadKind: ProjectReferenceProjectLoadKind,
projectService: ProjectService,
seenResolvedRefs: ESMap<string, ProjectReferenceProjectLoadKind> | undefined,
): T | undefined {
const loadKind = parentOptions.disableReferencedProjectLoad ? ProjectReferenceProjectLoadKind.Find : projectReferenceProjectLoadKind;
return forEach(resolvedProjectReferences, ref => {
if (!ref) return undefined;
const configFileName = toNormalizedPath(ref.sourceFile.fileName);
const canonicalPath = projectService.toCanonicalFileName(configFileName);
const seenValue = seenResolvedRefs?.get(canonicalPath);
if (seenValue !== undefined && seenValue >= loadKind) {
return undefined;
}
const result = cb(ref, loadKind);
if (result) {
return result;
}
(seenResolvedRefs || (seenResolvedRefs = new Map())).set(canonicalPath, loadKind);
return ref.references && forEachResolvedProjectReferenceProjectWorker(ref.references, ref.commandLine.options, cb, loadKind, projectService, seenResolvedRefs);
});
}
function forEachPotentialProjectReference<T>(
project: ConfiguredProject,
cb: (potentialProjectReference: Path) => T | undefined
): T | undefined {
return project.potentialProjectReferences &&
forEachKey(project.potentialProjectReferences, cb);
}
function forEachAnyProjectReferenceKind<T>(
project: ConfiguredProject,
cb: (resolvedProjectReference: ResolvedProjectReference) => T | undefined,
cbProjectRef: (projectReference: ProjectReference) => T | undefined,
cbPotentialProjectRef: (potentialProjectReference: Path) => T | undefined
): T | undefined {
return project.getCurrentProgram() ?
project.forEachResolvedProjectReference(cb) :
project.isInitialLoadPending() ?
forEachPotentialProjectReference(project, cbPotentialProjectRef) :
forEach(project.getProjectReferences(), cbProjectRef);
}
function callbackRefProject<T>(
project: ConfiguredProject,
cb: (refProj: ConfiguredProject) => T | undefined,
refPath: Path | undefined
) {
const refProject = refPath && project.projectService.configuredProjects.get(refPath);
return refProject && cb(refProject);
}
function forEachReferencedProject<T>(
project: ConfiguredProject,
cb: (refProj: ConfiguredProject) => T | undefined
): T | undefined {
return forEachAnyProjectReferenceKind(
project,
resolvedRef => callbackRefProject(project, cb, resolvedRef.sourceFile.path),
projectRef => callbackRefProject(project, cb, project.toPath(resolveProjectReferencePath(projectRef))),
potentialProjectRef => callbackRefProject(project, cb, potentialProjectRef)
);
}
interface ScriptInfoInNodeModulesWatcher extends FileWatcher {
refCount: number;
}
function getDetailWatchInfo(watchType: WatchType, project: Project | undefined) {
return `Project: ${project ? project.getProjectName() : ""} WatchType: ${watchType}`;
}
function isScriptInfoWatchedFromNodeModules(info: ScriptInfo) {
return !info.isScriptOpen() && info.mTime !== undefined;
}
/*@internal*/
/** true if script info is part of project and is not in project because it is referenced from project reference source */
export function projectContainsInfoDirectly(project: Project, info: ScriptInfo) {
return project.containsScriptInfo(info) &&
!project.isSourceOfProjectReferenceRedirect(info.path);
}
/*@internal*/
export function updateProjectIfDirty(project: Project) {
project.invalidateResolutionsOfFailedLookupLocations();
return project.dirty && project.updateGraph();
}
function setProjectOptionsUsed(project: ConfiguredProject | ExternalProject) {
if (isConfiguredProject(project)) {
project.projectOptions = true;
}
}
/*@internal*/
export interface OpenFileArguments {
fileName: string;
content?: string;
scriptKind?: protocol.ScriptKindName | ScriptKind;
hasMixedContent?: boolean;
projectRootPath?: string;
}
/*@internal*/
export interface ChangeFileArguments {
fileName: string;
changes: Iterator<TextChange>;
}
export interface WatchOptionsAndErrors {
watchOptions: WatchOptions;
errors: Diagnostic[] | undefined;
}
export class ProjectService {
/*@internal*/
readonly typingsCache: TypingsCache;
/*@internal*/
readonly documentRegistry: DocumentRegistry;
/**
* Container of all known scripts
*/
/*@internal*/
readonly filenameToScriptInfo = new Map<string, ScriptInfo>();
private readonly scriptInfoInNodeModulesWatchers = new Map<string, ScriptInfoInNodeModulesWatcher>();
/**
* Contains all the deleted script info's version information so that
* it does not reset when creating script info again
* (and could have potentially collided with version where contents mismatch)
*/
private readonly filenameToScriptInfoVersion = new Map<string, ScriptInfoVersion>();
// Set of all '.js' files ever opened.
private readonly allJsFilesForOpenFileTelemetry = new Map<string, true>();
/**
* Map to the real path of the infos
*/
/* @internal */
readonly realpathToScriptInfos: MultiMap<Path, ScriptInfo> | undefined;
/**
* maps external project file name to list of config files that were the part of this project
*/
private readonly externalProjectToConfiguredProjectMap = new Map<string, NormalizedPath[]>();
/**
* external projects (configuration and list of root files is not controlled by tsserver)
*/
readonly externalProjects: ExternalProject[] = [];
/**
* projects built from openFileRoots
*/
readonly inferredProjects: InferredProject[] = [];
/**
* projects specified by a tsconfig.json file
*/
readonly configuredProjects: Map<ConfiguredProject> = new Map<string, ConfiguredProject>();
/**
* Open files: with value being project root path, and key being Path of the file that is open
*/
readonly openFiles: Map<NormalizedPath | undefined> = new Map<Path, NormalizedPath | undefined>();
/* @internal */
readonly configFileForOpenFiles: ESMap<Path, NormalizedPath | false> = new Map();
/**
* Map of open files that are opened without complete path but have projectRoot as current directory
*/
private readonly openFilesWithNonRootedDiskPath = new Map<string, ScriptInfo>();
private compilerOptionsForInferredProjects: CompilerOptions | undefined;
private compilerOptionsForInferredProjectsPerProjectRoot = new Map<string, CompilerOptions>();
private watchOptionsForInferredProjects: WatchOptionsAndErrors | undefined;
private watchOptionsForInferredProjectsPerProjectRoot = new Map<string, WatchOptionsAndErrors | false>();
private typeAcquisitionForInferredProjects: TypeAcquisition | undefined;
private typeAcquisitionForInferredProjectsPerProjectRoot = new Map<string, TypeAcquisition | undefined>();
/**
* Project size for configured or external projects
*/
private readonly projectToSizeMap = new Map<string, number>();
/**
* This is a map of config file paths existence 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 = new Map<string, ConfigFileExistenceInfo>();
/*@internal*/ readonly throttledOperations: ThrottledOperations;
private readonly hostConfiguration: HostConfiguration;
private safelist: SafeList = defaultTypeSafeList;
private readonly legacySafelist = new Map<string, string>();
private pendingProjectUpdates = new Map<string, Project>();
/* @internal */
pendingEnsureProjectForOpenFiles = false;
readonly currentDirectory: NormalizedPath;
readonly toCanonicalFileName: (f: string) => string;
public readonly host: ServerHost;
public readonly logger: Logger;
public readonly cancellationToken: HostCancellationToken;
public readonly useSingleInferredProject: boolean;
public readonly useInferredProjectPerProjectRoot: boolean;
public readonly typingsInstaller: ITypingsInstaller;
private readonly globalCacheLocationDirectoryPath: Path | undefined;
public readonly throttleWaitMilliseconds?: number;
private readonly eventHandler?: ProjectServiceEventHandler;
private readonly suppressDiagnosticEvents?: boolean;
public readonly globalPlugins: readonly string[];
public readonly pluginProbeLocations: readonly string[];
public readonly allowLocalPluginLoads: boolean;
private currentPluginConfigOverrides: ESMap<string, any> | undefined;
public readonly typesMapLocation: string | undefined;
/** @deprecated use serverMode instead */
public readonly syntaxOnly: boolean;
public readonly serverMode: LanguageServiceMode;
/** Tracks projects that we have already sent telemetry for. */
private readonly seenProjects = new Map<string, true>();
/*@internal*/
readonly watchFactory: WatchFactory<WatchType, Project>;
/*@internal*/
readonly packageJsonCache: PackageJsonCache;
/*@internal*/
private packageJsonFilesMap: ESMap<Path, FileWatcher> | undefined;
private performanceEventHandler?: PerformanceEventHandler;
constructor(opts: ProjectServiceOptions) {
this.host = opts.host;
this.logger = opts.logger;
this.cancellationToken = opts.cancellationToken;
this.useSingleInferredProject = opts.useSingleInferredProject;
this.useInferredProjectPerProjectRoot = opts.useInferredProjectPerProjectRoot;
this.typingsInstaller = opts.typingsInstaller || nullTypingsInstaller;
this.throttleWaitMilliseconds = opts.throttleWaitMilliseconds;
this.eventHandler = opts.eventHandler;
this.suppressDiagnosticEvents = opts.suppressDiagnosticEvents;
this.globalPlugins = opts.globalPlugins || emptyArray;
this.pluginProbeLocations = opts.pluginProbeLocations || emptyArray;
this.allowLocalPluginLoads = !!opts.allowLocalPluginLoads;
this.typesMapLocation = (opts.typesMapLocation === undefined) ? combinePaths(getDirectoryPath(this.getExecutingFilePath()), "typesMap.json") : opts.typesMapLocation;
if (opts.serverMode !== undefined) {
this.serverMode = opts.serverMode;
this.syntaxOnly = this.serverMode === LanguageServiceMode.Syntactic;
}
else if (opts.syntaxOnly) {
this.serverMode = LanguageServiceMode.Syntactic;
this.syntaxOnly = true;
}
else {
this.serverMode = LanguageServiceMode.Semantic;
this.syntaxOnly = false;
}
if (this.host.realpath) {
this.realpathToScriptInfos = createMultiMap();
}
this.currentDirectory = toNormalizedPath(this.host.getCurrentDirectory());
this.toCanonicalFileName = createGetCanonicalFileName(this.host.useCaseSensitiveFileNames);
this.globalCacheLocationDirectoryPath = this.typingsInstaller.globalTypingsCacheLocation
? ensureTrailingDirectorySeparator(this.toPath(this.typingsInstaller.globalTypingsCacheLocation))
: undefined;
this.throttledOperations = new ThrottledOperations(this.host, this.logger);
if (this.typesMapLocation) {
this.loadTypesMap();
}
else {
this.logger.info("No types map provided; using the default");
}
this.typingsInstaller.attach(this);
this.typingsCache = new TypingsCache(this.typingsInstaller);
this.hostConfiguration = {
formatCodeOptions: getDefaultFormatCodeSettings(this.host.newLine),
preferences: emptyOptions,
hostInfo: "Unknown host",
extraFileExtensions: [],
};
this.documentRegistry = createDocumentRegistryInternal(this.host.useCaseSensitiveFileNames, this.currentDirectory, this);
const watchLogLevel = this.logger.hasLevel(LogLevel.verbose) ? WatchLogLevel.Verbose :
this.logger.loggingEnabled() ? WatchLogLevel.TriggerOnly : WatchLogLevel.None;
const log: (s: string) => void = watchLogLevel !== WatchLogLevel.None ? (s => this.logger.info(s)) : noop;
this.packageJsonCache = createPackageJsonCache(this);
this.watchFactory = this.serverMode !== LanguageServiceMode.Semantic ?
{
watchFile: returnNoopFileWatcher,
watchDirectory: returnNoopFileWatcher,
} :
getWatchFactory(this.host, watchLogLevel, log, getDetailWatchInfo);
}
toPath(fileName: string) {
return toPath(fileName, this.currentDirectory, this.toCanonicalFileName);
}
/*@internal*/
getExecutingFilePath() {
return this.getNormalizedAbsolutePath(this.host.getExecutingFilePath());
}
/*@internal*/
getNormalizedAbsolutePath(fileName: string) {
return getNormalizedAbsolutePath(fileName, this.host.getCurrentDirectory());
}
/*@internal*/
setDocument(key: DocumentRegistryBucketKey, path: Path, sourceFile: SourceFile) {
const info = Debug.checkDefined(this.getScriptInfoForPath(path));
info.cacheSourceFile = { key, sourceFile };
}
/*@internal*/
getDocument(key: DocumentRegistryBucketKey, path: Path): SourceFile | undefined {
const info = this.getScriptInfoForPath(path);
return info && info.cacheSourceFile && info.cacheSourceFile.key === key ? info.cacheSourceFile.sourceFile : undefined;
}
/* @internal */
ensureInferredProjectsUpToDate_TestOnly() {
this.ensureProjectStructuresUptoDate();
}
/* @internal */
getCompilerOptionsForInferredProjects() {
return this.compilerOptionsForInferredProjects;
}
/* @internal */
onUpdateLanguageServiceStateForProject(project: Project, languageServiceEnabled: boolean) {
if (!this.eventHandler) {
return;
}
const event: ProjectLanguageServiceStateEvent = {
eventName: ProjectLanguageServiceStateEvent,
data: { project, languageServiceEnabled }
};
this.eventHandler(event);
}
private loadTypesMap() {
try {
const fileContent = this.host.readFile(this.typesMapLocation!); // TODO: GH#18217
if (fileContent === undefined) {
this.logger.info(`Provided types map file "${this.typesMapLocation}" doesn't exist`);
return;
}
const raw: TypesMapFile = JSON.parse(fileContent);
// Parse the regexps
for (const k of Object.keys(raw.typesMap)) {
raw.typesMap[k].match = new RegExp(raw.typesMap[k].match as {} as string, "i");
}
// raw is now fixed and ready
this.safelist = raw.typesMap;
for (const key in raw.simpleMap) {
if (raw.simpleMap.hasOwnProperty(key)) {
this.legacySafelist.set(key, raw.simpleMap[key].toLowerCase());
}
}
}
catch (e) {
this.logger.info(`Error loading types map: ${e}`);
this.safelist = defaultTypeSafeList;
this.legacySafelist.clear();
}
}
updateTypingsForProject(response: SetTypings | InvalidateCachedTypings | PackageInstalledResponse): void;
/** @internal */
updateTypingsForProject(response: SetTypings | InvalidateCachedTypings | PackageInstalledResponse | BeginInstallTypes | EndInstallTypes): void; // eslint-disable-line @typescript-eslint/unified-signatures
updateTypingsForProject(response: SetTypings | InvalidateCachedTypings | PackageInstalledResponse | BeginInstallTypes | EndInstallTypes): void {
const project = this.findProject(response.projectName);
if (!project) {
return;
}
switch (response.kind) {
case ActionSet:
// Update the typing files and update the project
project.updateTypingFiles(this.typingsCache.updateTypingsForProject(response.projectName, response.compilerOptions, response.typeAcquisition, response.unresolvedImports, response.typings));
break;
case ActionInvalidate:
// Do not clear resolution cache, there was changes detected in typings, so enque typing request and let it get us correct results
this.typingsCache.enqueueInstallTypingsForProject(project, project.lastCachedUnresolvedImportsList, /*forceRefresh*/ true);
return;
}
this.delayUpdateProjectGraphAndEnsureProjectStructureForOpenFiles(project);
}
/*@internal*/
delayEnsureProjectForOpenFiles() {
this.pendingEnsureProjectForOpenFiles = true;
this.throttledOperations.schedule(ensureProjectForOpenFileSchedule, /*delay*/ 2500, () => {
if (this.pendingProjectUpdates.size !== 0) {
this.delayEnsureProjectForOpenFiles();
}
else {
if (this.pendingEnsureProjectForOpenFiles) {
this.ensureProjectForOpenFiles();
// Send the event to notify that there were background project updates
// send current list of open files
this.sendProjectsUpdatedInBackgroundEvent();
}
}
});
}
private delayUpdateProjectGraph(project: Project) {
project.markAsDirty();
const projectName = project.getProjectName();
this.pendingProjectUpdates.set(projectName, project);
this.throttledOperations.schedule(projectName, /*delay*/ 250, () => {
if (this.pendingProjectUpdates.delete(projectName)) {
updateProjectIfDirty(project);
}
});
}
/*@internal*/
hasPendingProjectUpdate(project: Project) {
return this.pendingProjectUpdates.has(project.getProjectName());
}
/* @internal */
sendProjectsUpdatedInBackgroundEvent() {
if (!this.eventHandler) {
return;
}
const event: ProjectsUpdatedInBackgroundEvent = {
eventName: ProjectsUpdatedInBackgroundEvent,
data: {
openFiles: arrayFrom(this.openFiles.keys(), path => this.getScriptInfoForPath(path as Path)!.fileName)
}
};
this.eventHandler(event);
}
/* @internal */
sendLargeFileReferencedEvent(file: string, fileSize: number) {
if (!this.eventHandler) {
return;
}
const event: LargeFileReferencedEvent = {
eventName: LargeFileReferencedEvent,
data: { file, fileSize, maxFileSize }
};
this.eventHandler(event);
}
/* @internal */
sendProjectLoadingStartEvent(project: ConfiguredProject, reason: string) {
if (!this.eventHandler) {
return;
}
project.sendLoadingProjectFinish = true;
const event: ProjectLoadingStartEvent = {
eventName: ProjectLoadingStartEvent,
data: { project, reason }
};
this.eventHandler(event);
}
/* @internal */
sendProjectLoadingFinishEvent(project: ConfiguredProject) {
if (!this.eventHandler || !project.sendLoadingProjectFinish) {
return;
}
project.sendLoadingProjectFinish = false;
const event: ProjectLoadingFinishEvent = {
eventName: ProjectLoadingFinishEvent,
data: { project }
};
this.eventHandler(event);
}
/* @internal */
sendPerformanceEvent(kind: PerformanceEvent["kind"], durationMs: number) {
if (this.performanceEventHandler) {
this.performanceEventHandler({ kind, durationMs });
}
}
/* @internal */
delayUpdateProjectGraphAndEnsureProjectStructureForOpenFiles(project: Project) {
this.delayUpdateProjectGraph(project);
this.delayEnsureProjectForOpenFiles();
}
private delayUpdateProjectGraphs(projects: readonly Project[], clearSourceMapperCache: boolean) {
if (projects.length) {
for (const project of projects) {
// Even if program doesnt change, clear the source mapper cache
if (clearSourceMapperCache) project.clearSourceMapperCache();
this.delayUpdateProjectGraph(project);
}
this.delayEnsureProjectForOpenFiles();
}
}
setCompilerOptionsForInferredProjects(projectCompilerOptions: protocol.InferredProjectCompilerOptions, projectRootPath?: string): void {
Debug.assert(projectRootPath === undefined || this.useInferredProjectPerProjectRoot, "Setting compiler options per project root path is only supported when useInferredProjectPerProjectRoot is enabled");
const compilerOptions = convertCompilerOptions(projectCompilerOptions);
const watchOptions = convertWatchOptions(projectCompilerOptions, projectRootPath);
const typeAcquisition = convertTypeAcquisition(projectCompilerOptions);
// always set 'allowNonTsExtensions' for inferred projects since user cannot configure it from the outside
// previously we did not expose a way for user to change these settings and this option was enabled by default
compilerOptions.allowNonTsExtensions = true;
const canonicalProjectRootPath = projectRootPath && this.toCanonicalFileName(projectRootPath);
if (canonicalProjectRootPath) {
this.compilerOptionsForInferredProjectsPerProjectRoot.set(canonicalProjectRootPath, compilerOptions);
this.watchOptionsForInferredProjectsPerProjectRoot.set(canonicalProjectRootPath, watchOptions || false);
this.typeAcquisitionForInferredProjectsPerProjectRoot.set(canonicalProjectRootPath, typeAcquisition);
}
else {
this.compilerOptionsForInferredProjects = compilerOptions;
this.watchOptionsForInferredProjects = watchOptions;
this.typeAcquisitionForInferredProjects = typeAcquisition;
}
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
// a workspace root
// - Inferred projects with a projectRootPath, if the new options do not apply to a
// workspace root and there is no more specific set of options for that project's
// root path
// - Inferred projects with a projectRootPath, if the new options apply to that
// project root path.
if (canonicalProjectRootPath ?
project.projectRootPath === canonicalProjectRootPath :
!project.projectRootPath || !this.compilerOptionsForInferredProjectsPerProjectRoot.has(project.projectRootPath)) {
project.setCompilerOptions(compilerOptions);
project.setTypeAcquisition(typeAcquisition);
project.setWatchOptions(watchOptions?.watchOptions);
project.setProjectErrors(watchOptions?.errors);
project.compileOnSaveEnabled = compilerOptions.compileOnSave!;
project.markAsDirty();
this.delayUpdateProjectGraph(project);
}
}
this.delayEnsureProjectForOpenFiles();
}
findProject(projectName: string): Project | undefined {
if (projectName === undefined) {
return undefined;
}
if (isInferredProjectName(projectName)) {
return findProjectByName(projectName, this.inferredProjects);
}
return this.findExternalProjectByProjectName(projectName) || this.findConfiguredProjectByProjectName(toNormalizedPath(projectName));
}
/* @internal */
private forEachProject(cb: (project: Project) => void) {
this.externalProjects.forEach(cb);
this.configuredProjects.forEach(cb);
this.inferredProjects.forEach(cb);
}
/* @internal */
forEachEnabledProject(cb: (project: Project) => void) {
this.forEachProject(project => {
if (!project.isOrphan() && project.languageServiceEnabled) {
cb(project);
}
});
}
getDefaultProjectForFile(fileName: NormalizedPath, ensureProject: boolean): Project | undefined {
return ensureProject ? this.ensureDefaultProjectForFile(fileName) : this.tryGetDefaultProjectForFile(fileName);
}
/* @internal */
tryGetDefaultProjectForFile(fileName: NormalizedPath): Project | undefined {
const scriptInfo = this.getScriptInfoForNormalizedPath(fileName);
return scriptInfo && !scriptInfo.isOrphan() ? scriptInfo.getDefaultProject() : undefined;
}
/* @internal */
ensureDefaultProjectForFile(fileName: NormalizedPath): Project {
return this.tryGetDefaultProjectForFile(fileName) || this.doEnsureDefaultProjectForFile(fileName);
}
private doEnsureDefaultProjectForFile(fileName: NormalizedPath): Project {
this.ensureProjectStructuresUptoDate();
const scriptInfo = this.getScriptInfoForNormalizedPath(fileName);
return scriptInfo ? scriptInfo.getDefaultProject() : (this.logErrorForScriptInfoNotFound(fileName), Errors.ThrowNoProject());
}
getScriptInfoEnsuringProjectsUptoDate(uncheckedFileName: string) {
this.ensureProjectStructuresUptoDate();
return this.getScriptInfo(uncheckedFileName);
}
/**
* Ensures the project structures are upto date
* This means,
* - we go through all the projects and update them if they are dirty
* - if updates reflect some change in structure or there was pending request to ensure projects for open files
* ensure that each open script info has project
*/
private ensureProjectStructuresUptoDate() {
let hasChanges = this.pendingEnsureProjectForOpenFiles;
this.pendingProjectUpdates.clear();
const updateGraph = (project: Project) => {
hasChanges = updateProjectIfDirty(project) || hasChanges;
};
this.externalProjects.forEach(updateGraph);
this.configuredProjects.forEach(updateGraph);
this.inferredProjects.forEach(updateGraph);
if (hasChanges) {
this.ensureProjectForOpenFiles();
}
}
getFormatCodeOptions(file: NormalizedPath) {
const info = this.getScriptInfoForNormalizedPath(file);
return info && info.getFormatCodeSettings() || this.hostConfiguration.formatCodeOptions;
}
getPreferences(file: NormalizedPath): protocol.UserPreferences {
const info = this.getScriptInfoForNormalizedPath(file);
return { ...this.hostConfiguration.preferences, ...info && info.getPreferences() };
}
getHostFormatCodeOptions(): FormatCodeSettings {
return this.hostConfiguration.formatCodeOptions;
}
getHostPreferences(): protocol.UserPreferences {
return this.hostConfiguration.preferences;
}
private onSourceFileChanged(info: ScriptInfo, eventKind: FileWatcherEventKind) {
if (info.containingProjects) {
info.containingProjects.forEach(project => project.resolutionCache.removeResolutionsFromProjectReferenceRedirects(info.path));
}
if (eventKind === FileWatcherEventKind.Deleted) {
// File was deleted
this.handleDeletedFile(info);
}
else if (!info.isScriptOpen()) {
// 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, /*clearSourceMapperCache*/ false);
this.handleSourceMapProjects(info);
}
}
private handleSourceMapProjects(info: ScriptInfo) {
// Change in d.ts, update source projects as well
if (info.sourceMapFilePath) {
if (isString(info.sourceMapFilePath)) {
const sourceMapFileInfo = this.getScriptInfoForPath(info.sourceMapFilePath);
this.delayUpdateSourceInfoProjects(sourceMapFileInfo && sourceMapFileInfo.sourceInfos);
}
else {
this.delayUpdateSourceInfoProjects(info.sourceMapFilePath.sourceInfos);
}
}
// Change in mapInfo, update declarationProjects and source projects
this.delayUpdateSourceInfoProjects(info.sourceInfos);
if (info.declarationInfoPath) {
this.delayUpdateProjectsOfScriptInfoPath(info.declarationInfoPath);
}
}
private delayUpdateSourceInfoProjects(sourceInfos: Set<Path> | undefined) {
if (sourceInfos) {
sourceInfos.forEach((_value, path) => this.delayUpdateProjectsOfScriptInfoPath(path));
}
}
private delayUpdateProjectsOfScriptInfoPath(path: Path) {
const info = this.getScriptInfoForPath(path);
if (info) {
this.delayUpdateProjectGraphs(info.containingProjects, /*clearSourceMapperCache*/ true);
}
}
private handleDeletedFile(info: ScriptInfo) {
this.stopWatchingScriptInfo(info);
if (!info.isScriptOpen()) {
this.deleteScriptInfo(info);
// capture list of projects since detachAllProjects will wipe out original list
const containingProjects = info.containingProjects.slice();
info.detachAllProjects();
// update projects to make sure that set of referenced files is correct
this.delayUpdateProjectGraphs(containingProjects, /*clearSourceMapperCache*/ false);
this.handleSourceMapProjects(info);
info.closeSourceMapFileWatcher();
// need to recalculate source map from declaration file
if (info.declarationInfoPath) {
const declarationInfo = this.getScriptInfoForPath(info.declarationInfoPath);
if (declarationInfo) {
declarationInfo.sourceMapFilePath = undefined;
}
}
}
}
/**
* This is to watch whenever files are added or removed to the wildcard directories
*/
/*@internal*/
watchWildcardDirectory(directory: Path, flags: WatchDirectoryFlags, project: ConfiguredProject) {
const watchOptions = this.getWatchOptions(project);
return this.watchFactory.watchDirectory(
directory,
fileOrDirectory => {
const fileOrDirectoryPath = this.toPath(fileOrDirectory);
const fsResult = project.getCachedDirectoryStructureHost().addOrDeleteFileOrDirectory(fileOrDirectory, fileOrDirectoryPath);
const configFileName = project.getConfigFilePath();
if (getBaseFileName(fileOrDirectoryPath) === "package.json" && !isInsideNodeModules(fileOrDirectoryPath) &&
(fsResult && fsResult.fileExists || !fsResult && this.host.fileExists(fileOrDirectoryPath))
) {
this.logger.info(`Project: ${configFileName} Detected new package.json: ${fileOrDirectory}`);
this.onAddPackageJson(fileOrDirectoryPath);
}
if (isIgnoredFileFromWildCardWatching({
watchedDirPath: directory,
fileOrDirectory,
fileOrDirectoryPath,
configFileName,
configFileSpecs: project.configFileSpecs!,
extraFileExtensions: this.hostConfiguration.extraFileExtensions,
currentDirectory: this.currentDirectory,
options: project.getCompilationSettings(),
program: project.getCurrentProgram(),
useCaseSensitiveFileNames: this.host.useCaseSensitiveFileNames,
writeLog: s => this.logger.info(s)
})) return;
// don't trigger callback on open, existing files
if (project.fileIsOpen(fileOrDirectoryPath)) {
if (project.pendingReload !== ConfigFileProgramReloadLevel.Full) {
const info = Debug.checkDefined(this.getScriptInfoForPath(fileOrDirectoryPath));
if (info.isAttached(project)) {
project.openFileWatchTriggered.set(fileOrDirectoryPath, true);
}
else {
project.pendingReload = ConfigFileProgramReloadLevel.Partial;
this.delayUpdateProjectGraphAndEnsureProjectStructureForOpenFiles(project);
}
}
return;
}
// Reload is pending, do the reload
if (project.pendingReload !== ConfigFileProgramReloadLevel.Full) {
project.pendingReload = ConfigFileProgramReloadLevel.Partial;
this.delayUpdateProjectGraphAndEnsureProjectStructureForOpenFiles(project);
}
},
flags,
watchOptions,
WatchType.WildcardDirectory,
project
);
}
/** Gets the config file existence info for the configured project */
/*@internal*/
getConfigFileExistenceInfo(project: ConfiguredProject) {
return this.configFileExistenceInfoCache.get(project.canonicalConfigFilePath)!;
}
/*@internal*/
onConfigChangedForConfiguredProject(project: ConfiguredProject, eventKind: FileWatcherEventKind) {
const configFileExistenceInfo = this.getConfigFileExistenceInfo(project);
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);
// Reload the configured projects for the open files in the map as they are affected 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);
// Skip refresh if project is not yet loaded
if (project.isInitialLoadPending()) return;
project.pendingReload = ConfigFileProgramReloadLevel.Full;
project.pendingReloadReason = "Change in config file detected";
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);
}
}
/**
* 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 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);
// 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.print(/*writeProjectFileNames*/ true);
project.close();
if (Debug.shouldAssert(AssertionLevel.Normal)) {
this.filenameToScriptInfo.forEach(info => Debug.assert(
!info.isAttached(project),
"Found script Info still attached to project",
() => `${project.projectName}: ScriptInfos still attached: ${JSON.stringify(
arrayFrom(
mapDefinedIterator(
this.filenameToScriptInfo.values(),
info => info.isAttached(project) ?
{
fileName: info.fileName,
projects: info.containingProjects.map(p => p.projectName),
hasMixedContent: info.hasMixedContent
} : undefined
)
),
/*replacer*/ undefined,
" "
)}`));
}
// Remove the project from pending project updates
this.pendingProjectUpdates.delete(project.getProjectName());
switch (project.projectKind) {
case ProjectKind.External:
unorderedRemoveItem(this.externalProjects, <ExternalProject>project);
this.projectToSizeMap.delete(project.getProjectName());
break;
case ProjectKind.Configured:
this.configuredProjects.delete((<ConfiguredProject>project).canonicalConfigFilePath);
this.projectToSizeMap.delete((project as ConfiguredProject).canonicalConfigFilePath);
this.setConfigFileExistenceInfoByClosedConfiguredProject(<ConfiguredProject>project);
break;
case ProjectKind.Inferred:
unorderedRemoveItem(this.inferredProjects, <InferredProject>project);
break;
}
}
/*@internal*/
assignOrphanScriptInfoToInferredProject(info: ScriptInfo, projectRootPath: NormalizedPath | undefined) {
Debug.assert(info.isOrphan());
const project = this.getOrCreateInferredProjectForProjectRootPathIfEnabled(info, projectRootPath) ||
this.getOrCreateSingleInferredProjectIfEnabled() ||
this.getOrCreateSingleInferredWithoutProjectRoot(
info.isDynamic ?
projectRootPath || this.currentDirectory :
getDirectoryPath(
isRootedDiskPath(info.fileName) ?
info.fileName :
getNormalizedAbsolutePath(
info.fileName,
projectRootPath ?
this.getNormalizedAbsolutePath(projectRootPath) :
this.currentDirectory
)
)
);
project.addRoot(info);
if (info.containingProjects[0] !== project) {
// Ensure this is first project, we could be in this scenario because info could be part of orphan project
info.detachFromProject(project);
info.containingProjects.unshift(project);
}
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) {
if (inferredProject === project || inferredProject.isOrphan()) {
continue;
}
// 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 && forEach(roots[0].containingProjects, p => p !== roots[0].containingProjects[0] && !p.isOrphan())) {
inferredProject.removeFile(roots[0], /*fileExists*/ true, /*detachFromProject*/ true);
}
}
}
return project;
}
private assignOrphanScriptInfosToInferredProject() {
// collect orphaned files and assign them to inferred project just like we treat open of a file
this.openFiles.forEach((projectRootPath, path) => {
const info = this.getScriptInfoForPath(path as Path)!;
// collect all orphaned script infos from open files
if (info.isOrphan()) {
this.assignOrphanScriptInfoToInferredProject(info, projectRootPath);
}
});
}
/**
* Remove this file from the set of open, non-configured files.
* @param info The file that has been closed or newly configured
*/
private closeOpenFile(info: ScriptInfo, skipAssignOrphanScriptInfosToInferredProject?: true) {
// Closing file should trigger re-reading the file content from disk. This is
// 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.
const fileExists = info.isDynamic ? false : this.host.fileExists(info.fileName);
info.close(fileExists);
this.stopWatchingConfigFilesForClosedScriptInfo(info);
const canonicalFileName = this.toCanonicalFileName(info.fileName);
if (this.openFilesWithNonRootedDiskPath.get(canonicalFileName) === info) {
this.openFilesWithNonRootedDiskPath.delete(canonicalFileName);
}
// collect all projects that should be removed
let ensureProjectsForOpenFiles = false;
for (const p of info.containingProjects) {
if (isConfiguredProject(p)) {
if (info.hasMixedContent) {
info.registerFileUpdate();
}
// Do not remove the project so that we can reuse this project
// if it would need to be re-created with next file open
// If project had open file affecting
// Reload the root Files from config if its not already scheduled
if (p.openFileWatchTriggered.has(info.path)) {
p.openFileWatchTriggered.delete(info.path);
if (!p.pendingReload) {
p.pendingReload = ConfigFileProgramReloadLevel.Partial;
p.markFileAsDirty(info.path);
}
}
}
else if (isInferredProject(p) && p.isRoot(info)) {
// If this was the last open root file of inferred project
if (p.isProjectWithSingleRoot()) {
ensureProjectsForOpenFiles = true;
}
p.removeFile(info, fileExists, /*detachFromProject*/ true);
// Do not remove the project even if this was last root of the inferred project
// so that we can reuse this project, if it would need to be re-created with next file open
}
if (!p.languageServiceEnabled) {
// if project language service is disabled then we create a program only for open files.
// this means that project should be marked as dirty to force rebuilding of the program
// on the next request
p.markAsDirty();
}
}
this.openFiles.delete(info.path);
this.configFileForOpenFiles.delete(info.path);
if (!skipAssignOrphanScriptInfosToInferredProject && ensureProjectsForOpenFiles) {
this.assignOrphanScriptInfosToInferredProject();
}
// 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 (fileExists) {
this.watchClosedScriptInfo(info);
}
else {
this.handleDeletedFile(info);
}
return ensureProjectsForOpenFiles;
}
private deleteScriptInfo(info: ScriptInfo) {
this.filenameToScriptInfo.delete(info.path);
this.filenameToScriptInfoVersion.set(info.path, info.getVersion());
const realpath = info.getRealpathIfDifferent();
if (realpath) {
this.realpathToScriptInfos!.remove(realpath, info); // TODO: GH#18217
}
}
private configFileExists(configFileName: NormalizedPath, canonicalConfigFilePath: string, info: OpenScriptInfoOrClosedOrConfigFileInfo) {
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 (isOpenScriptInfo(info) && !configFileExistenceInfo.openFilesImpactedByConfigFile.has(info.path)) {
configFileExistenceInfo.openFilesImpactedByConfigFile.set(info.path, false);
this.logConfigFileWatchUpdate(configFileName, canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.OpenFilesImpactedByConfigFileAdd);
}
return configFileExistenceInfo.exists;
}
// 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)
// 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 exists = this.host.fileExists(configFileName);
const openFilesImpactedByConfigFile = new Map<Path, boolean>();
if (isOpenScriptInfo(info)) {
openFilesImpactedByConfigFile.set(info.path, false);
}
configFileExistenceInfo = { exists, openFilesImpactedByConfigFile };
this.configFileExistenceInfoCache.set(canonicalConfigFilePath, configFileExistenceInfo);
this.logConfigFileWatchUpdate(configFileName, canonicalConfigFilePath, configFileExistenceInfo, ConfigFileWatcherStatus.OpenFilesImpactedByConfigFileAdd);
return exists;
}
private setConfigFileExistenceByNewConfiguredProject(project: ConfiguredProject) {
const configFileExistenceInfo = this.getConfigFileExistenceInfo(project);
if (configFileExistenceInfo) {
// The existence might not be set if the file watcher is not invoked by the time config project is created by external project
configFileExistenceInfo.exists = true;
// 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: new Map<Path, boolean>()
});
}
}
/**
* 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) => isRootOfInferredProject);
}
private setConfigFileExistenceInfoByClosedConfiguredProject(closedProject: ConfiguredProject) {
const configFileExistenceInfo = this.getConfigFileExistenceInfo(closedProject);
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 {
// There is not a single file open thats tracking the status of this config file. Remove from cache
this.configFileExistenceInfoCache.delete(closedProject.canonicalConfigFilePath);
}
}
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)!;
(isRootOfInferredProject ? inferredRoots : otherFiles).push(info.fileName);
});
const watches: WatchType[] = [];
if (configFileExistenceInfo.configFileWatcherForRootOfInferredProject) {
watches.push(
configFileExistenceInfo.configFileWatcherForRootOfInferredProject === noopFileWatcher ?
WatchType.NoopConfigFileForInferredRoot :
WatchType.ConfigFileForInferredRoot
);
}
if (this.configuredProjects.has(canonicalConfigFilePath)) {
watches.push(WatchType.ConfigFile);
}
this.logger.info(`ConfigFilePresence:: Current Watches: ${watches}:: File: ${configFileName} Currently impacted open files: RootsOfInferredProjects: ${inferredRoots} OtherOpenFiles: ${otherFiles} Status: ${status}`);
}
/**
* Create the watcher for the configFileExistenceInfo
*/
private createConfigFileWatcherOfConfigFileExistence(
configFileName: NormalizedPath,
canonicalConfigFilePath: string,
configFileExistenceInfo: ConfigFileExistenceInfo
) {
configFileExistenceInfo.configFileWatcherForRootOfInferredProject =
canWatchDirectory(getDirectoryPath(canonicalConfigFilePath) as Path) ?
this.watchFactory.watchFile(
configFileName,
(_filename, eventKind) => this.onConfigFileChangeForOpenScriptInfo(configFileName, eventKind),
PollingInterval.High,
this.hostConfiguration.watchOptions,
WatchType.ConfigFileForInferredRoot
) :
noopFileWatcher;
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: new Map<Path, boolean>()
};
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: OpenScriptInfoOrClosedOrConfigFileInfo, action: (configFileName: NormalizedPath, canonicalConfigFilePath: string) => boolean | void) {
if (this.serverMode !== LanguageServiceMode.Semantic) {
return undefined;
}
Debug.assert(!isOpenScriptInfo(info) || this.openFiles.has(info.path));
const projectRootPath = this.openFiles.get(info.path);
const scriptInfo = Debug.checkDefined(this.getScriptInfo(info.path));
if (scriptInfo.isDynamic) return undefined;
let searchPath = asNormalizedPath(getDirectoryPath(info.fileName));
const isSearchPathInProjectRoot = () => containsPath(projectRootPath!, searchPath, this.currentDirectory, !this.host.useCaseSensitiveFileNames);
// If projectRootPath doesn't contain info.path, then do normal search for config file
const anySearchPathOk = !projectRootPath || !isSearchPathInProjectRoot();
// For ancestor of config file always ignore its own directory since its going to result in itself
let searchInDirectory = !isAncestorConfigFileInfo(info);
do {
if (searchInDirectory) {
const canonicalSearchPath = normalizedPathToPath(searchPath, this.currentDirectory, this.toCanonicalFileName);
const tsconfigFileName = asNormalizedPath(combinePaths(searchPath, "tsconfig.json"));
let result = action(tsconfigFileName, combinePaths(canonicalSearchPath, "tsconfig.json"));
if (result) return tsconfigFileName;
const jsconfigFileName = asNormalizedPath(combinePaths(searchPath, "jsconfig.json"));
result = action(jsconfigFileName, combinePaths(canonicalSearchPath, "jsconfig.json"));
if (result) return jsconfigFileName;
// If we started within node_modules, don't look outside node_modules.
// Otherwise, we might pick up a very large project and pull in the world,
// causing an editor delay.
if (isNodeModulesDirectory(canonicalSearchPath)) {
break;
}
}
const parentPath = asNormalizedPath(getDirectoryPath(searchPath));
if (parentPath === searchPath) break;
searchPath = parentPath;
searchInDirectory = true;
} while (anySearchPathOk || isSearchPathInProjectRoot());
return undefined;
}
/*@internal*/
findDefaultConfiguredProject(info: ScriptInfo) {
if (!info.isScriptOpen()) return undefined;
const configFileName = this.getConfigFileNameForFile(info);
const project = configFileName &&
this.findConfiguredProjectByProjectName(configFileName);
return project && projectContainsInfoDirectly(project, info) ?
project :
project?.getDefaultChildProjectFromProjectWithReferences(info);
}
/**
* 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.
* If script info is passed in, it is asserted to be open script info
* otherwise just file name
*/
private getConfigFileNameForFile(info: OpenScriptInfoOrClosedOrConfigFileInfo) {
if (isOpenScriptInfo(info)) {
Debug.assert(info.isScriptOpen());
const result = this.configFileForOpenFiles.get(info.path);
if (result !== undefined) return result || undefined;
}
this.logger.info(`Search path: ${getDirectoryPath(info.fileName)}`);
const configFileName = this.forEachConfigFileLocation(info, (configFileName, canonicalConfigFilePath) =>
this.configFileExists(configFileName, canonicalConfigFilePath, info));
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.`);
}
if (isOpenScriptInfo(info)) {
this.configFileForOpenFiles.set(info.path, configFileName || false);
}
return configFileName;
}
private printProjects() {
if (!this.logger.hasLevel(LogLevel.normal)) {
return;
}
this.logger.startGroup();
this.externalProjects.forEach(printProjectWithoutFileNames);
this.configuredProjects.forEach(printProjectWithoutFileNames);
this.inferredProjects.forEach(printProjectWithoutFileNames);
this.logger.info("Open files: ");
this.openFiles.forEach((projectRootPath, path) => {
const info = this.getScriptInfoForPath(path as Path)!;
this.logger.info(`\tFileName: ${info.fileName} ProjectRootPath: ${projectRootPath}`);
this.logger.info(`\t\tProjects: ${info.containingProjects.map(p => p.getProjectName())}`);
});
this.logger.endGroup();
}
/*@internal*/
findConfiguredProjectByProjectName(configFileName: NormalizedPath): ConfiguredProject | undefined {
// make sure that casing of config file name is consistent
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);
}
/** Get a filename if the language service exceeds the maximum allowed program size; otherwise returns undefined. */
private getFilenameForExceededTotalSizeLimitForNonTsFiles<T>(name: string, options: CompilerOptions | undefined, fileNames: T[], propertyReader: FilePropertyReader<T>): string | undefined {
if (options && options.disableSizeLimit || !this.host.getFileSize) {
return;
}
let availableSpace = maxProgramSizeForNonTsFiles;
this.projectToSizeMap.set(name, 0);
this.projectToSizeMap.forEach(val => (availableSpace -= (val || 0)));
let totalNonTsFileSize = 0;
for (const f of fileNames) {
const fileName = propertyReader.getFileName(f);
if (hasTSFileExtension(fileName)) {
continue;
}
totalNonTsFileSize += this.host.getFileSize(fileName);
if (totalNonTsFileSize > maxProgramSizeForNonTsFiles || totalNonTsFileSize > availableSpace) {
this.logger.info(getExceedLimitMessage({ propertyReader, hasTSFileExtension: ts.hasTSFileExtension, host: this.host }, totalNonTsFileSize)); // eslint-disable-line @typescript-eslint/no-unnecessary-qualifier
// Keep the size as zero since it's disabled
return fileName;
}
}
this.projectToSizeMap.set(name, totalNonTsFileSize);
return;
function getExceedLimitMessage(context: { propertyReader: FilePropertyReader<any>, hasTSFileExtension: (filename: string) => boolean, host: ServerHost }, totalNonTsFileSize: number) {
const files = getTop5LargestFiles(context);
return `Non TS file size exceeded limit (${totalNonTsFileSize}). Largest files: ${files.map(file => `${file.name}:${file.size}`).join(", ")}`;
}
function getTop5LargestFiles({ propertyReader, hasTSFileExtension, host }: { propertyReader: FilePropertyReader<any>, hasTSFileExtension: (filename: string) => boolean, host: ServerHost }) {
return fileNames.map(f => propertyReader.getFileName(f))
.filter(name => hasTSFileExtension(name))
.map(name => ({ name, size: host.getFileSize!(name) })) // TODO: GH#18217
.sort((a, b) => b.size - a.size)
.slice(0, 5);
}
}
private createExternalProject(projectFileName: string, files: protocol.ExternalFile[], options: protocol.ExternalProjectCompilerOptions, typeAcquisition: TypeAcquisition, excludedFiles: NormalizedPath[]) {
const compilerOptions = convertCompilerOptions(options);
const watchOptionsAndErrors = convertWatchOptions(options, getDirectoryPath(normalizeSlashes(projectFileName)));
const project = new ExternalProject(
projectFileName,
this,
this.documentRegistry,
compilerOptions,
/*lastFileExceededProgramSize*/ this.getFilenameForExceededTotalSizeLimitForNonTsFiles(projectFileName, compilerOptions, files, externalFilePropertyReader),
options.compileOnSave === undefined ? true : options.compileOnSave,
/*projectFilePath*/ undefined,
this.currentPluginConfigOverrides,
watchOptionsAndErrors?.watchOptions
);
project.setProjectErrors(watchOptionsAndErrors?.errors);
project.excludedFiles = excludedFiles;
this.addFilesToNonInferredProject(project, files, externalFilePropertyReader, typeAcquisition);
this.externalProjects.push(project);
return project;
}
/*@internal*/
sendProjectTelemetry(project: ExternalProject | ConfiguredProject): void {
if (this.seenProjects.has(project.projectName)) {
setProjectOptionsUsed(project);
return;
}
this.seenProjects.set(project.projectName, true);
if (!this.eventHandler || !this.host.createSHA256Hash) {
setProjectOptionsUsed(project);
return;
}
const projectOptions = isConfiguredProject(project) ? project.projectOptions as ProjectOptions : undefined;
setProjectOptionsUsed(project);
const data: ProjectInfoTelemetryEventData = {
projectId: this.host.createSHA256Hash(project.projectName),
fileStats: countEachFileTypes(project.getScriptInfos(), /*includeSizes*/ true),
compilerOptions: convertCompilerOptionsForTelemetry(project.getCompilationSettings()),
typeAcquisition: convertTypeAcquisition(project.getTypeAcquisition()),
extends: projectOptions && projectOptions.configHasExtendsProperty,
files: projectOptions && projectOptions.configHasFilesProperty,
include: projectOptions && projectOptions.configHasIncludeProperty,
exclude: projectOptions && projectOptions.configHasExcludeProperty,
compileOnSave: project.compileOnSaveEnabled,
configFileName: configFileName(),
projectType: project instanceof ExternalProject ? "external" : "configured",
languageServiceEnabled: project.languageServiceEnabled,
version: ts.version, // eslint-disable-line @typescript-eslint/no-unnecessary-qualifier
};
this.eventHandler({ eventName: ProjectInfoTelemetryEvent, data });
function configFileName(): ProjectInfoTelemetryEventData["configFileName"] {
if (!isConfiguredProject(project)) {
return "other";
}
return getBaseConfigFileName(project.getConfigFilePath()) || "other";
}
function convertTypeAcquisition({ enable, include, exclude }: TypeAcquisition): ProjectInfoTypeAcquisitionData {
return {
enable,
include: include !== undefined && include.length !== 0,
exclude: exclude !== undefined && exclude.length !== 0,
};
}
}
private addFilesToNonInferredProject<T>(project: ConfiguredProject | ExternalProject, files: T[], propertyReader: FilePropertyReader<T>, typeAcquisition: TypeAcquisition): void {
this.updateNonInferredProjectFiles(project, files, propertyReader);
project.setTypeAcquisition(typeAcquisition);
}
/* @internal */
createConfiguredProject(configFileName: NormalizedPath) {
const cachedDirectoryStructureHost = createCachedDirectoryStructureHost(this.host, this.host.getCurrentDirectory(), this.host.useCaseSensitiveFileNames)!; // TODO: GH#18217
this.logger.info(`Opened configuration file ${configFileName}`);
const project = new ConfiguredProject(
configFileName,
this,
this.documentRegistry,
cachedDirectoryStructureHost);
// TODO: We probably should also watch the configFiles that are extended
project.createConfigFileWatcher();
this.configuredProjects.set(project.canonicalConfigFilePath, project);
this.setConfigFileExistenceByNewConfiguredProject(project);
return project;
}
/* @internal */
private createConfiguredProjectWithDelayLoad(configFileName: NormalizedPath, reason: string) {
const project = this.createConfiguredProject(configFileName);
project.pendingReload = ConfigFileProgramReloadLevel.Full;
project.pendingReloadReason = reason;
return project;
}
/* @internal */
createAndLoadConfiguredProject(configFileName: NormalizedPath, reason: string) {
const project = this.createConfiguredProject(configFileName);
this.loadConfiguredProject(project, reason);
return project;
}
/* @internal */
private createLoadAndUpdateConfiguredProject(configFileName: NormalizedPath, reason: string) {
const project = this.createAndLoadConfiguredProject(configFileName, reason);
project.updateGraph();
return project;
}
/**
* Read the config file of the project, and update the project root file names.
*/
/* @internal */
private loadConfiguredProject(project: ConfiguredProject, reason: string) {
this.sendProjectLoadingStartEvent(project, reason);
// Read updated contents from disk
const configFilename = normalizePath(project.getConfigFilePath());
const configFileContent = tryReadFile(configFilename, fileName => this.host.readFile(fileName));
const result = parseJsonText(configFilename, isString(configFileContent) ? configFileContent : "");
const configFileErrors = result.parseDiagnostics as Diagnostic[];
if (!isString(configFileContent)) configFileErrors.push(configFileContent);
const parsedCommandLine = parseJsonSourceFileConfigFileContent(
result,
project.getCachedDirectoryStructureHost(),
getDirectoryPath(configFilename),
/*existingOptions*/ {},
configFilename,
/*resolutionStack*/[],
this.hostConfiguration.extraFileExtensions,
/*extendedConfigCache*/ undefined,
);
if (parsedCommandLine.errors.length) {
configFileErrors.push(...parsedCommandLine.errors);
}
this.logger.info(`Config: ${configFilename} : ${JSON.stringify({
rootNames: parsedCommandLine.fileNames,
options: parsedCommandLine.options,
projectReferences: parsedCommandLine.projectReferences
}, /*replacer*/ undefined, " ")}`);
Debug.assert(!!parsedCommandLine.fileNames);
const compilerOptions = parsedCommandLine.options;
// Update the project
if (!project.projectOptions) {
project.projectOptions = {
configHasExtendsProperty: parsedCommandLine.raw.extends !== undefined,
configHasFilesProperty: parsedCommandLine.raw.files !== undefined,
configHasIncludeProperty: parsedCommandLine.raw.include !== undefined,
configHasExcludeProperty: parsedCommandLine.raw.exclude !== undefined
};
}
project.configFileSpecs = parsedCommandLine.configFileSpecs;
project.canConfigFileJsonReportNoInputFiles = canJsonReportNoInputFiles(parsedCommandLine.raw);
project.setProjectErrors(configFileErrors);
project.updateReferences(parsedCommandLine.projectReferences);
const lastFileExceededProgramSize = this.getFilenameForExceededTotalSizeLimitForNonTsFiles(project.canonicalConfigFilePath, compilerOptions, parsedCommandLine.fileNames, fileNamePropertyReader);
if (lastFileExceededProgramSize) {
project.disableLanguageService(lastFileExceededProgramSize);
project.stopWatchingWildCards();
}
else {
project.setCompilerOptions(compilerOptions);
project.setWatchOptions(parsedCommandLine.watchOptions);
project.enableLanguageService();
project.watchWildcards(new Map(getEntries(parsedCommandLine.wildcardDirectories!))); // TODO: GH#18217
}
project.enablePluginsWithOptions(compilerOptions, this.currentPluginConfigOverrides);
const filesToAdd = parsedCommandLine.fileNames.concat(project.getExternalFiles());
this.updateRootAndOptionsOfNonInferredProject(project, filesToAdd, fileNamePropertyReader, compilerOptions, parsedCommandLine.typeAcquisition!, parsedCommandLine.compileOnSave, parsedCommandLine.watchOptions);
}
private updateNonInferredProjectFiles<T>(project: ExternalProject | ConfiguredProject | AutoImportProviderProject, files: T[], propertyReader: FilePropertyReader<T>) {
const projectRootFilesMap = project.getRootFilesMap();
const newRootScriptInfoMap = new Map<string, true>();
for (const f of files) {
const newRootFile = propertyReader.getFileName(f);
const fileName = toNormalizedPath(newRootFile);
const isDynamic = isDynamicFileName(fileName);
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(fileName, this.currentDirectory, this.toCanonicalFileName);
const existingValue = projectRootFilesMap.get(path);
if (existingValue) {
if (existingValue.info) {
project.removeFile(existingValue.info, /*fileExists*/ false, /*detachFromProject*/ true);
existingValue.info = undefined;
}
existingValue.fileName = fileName;
}
else {
projectRootFilesMap.set(path, { fileName });
}
}
else {
const scriptKind = propertyReader.getScriptKind(f, this.hostConfiguration.extraFileExtensions);
const hasMixedContent = propertyReader.hasMixedContent(f, this.hostConfiguration.extraFileExtensions);
const scriptInfo = Debug.checkDefined(this.getOrCreateScriptInfoNotOpenedByClientForNormalizedPath(
fileName,
project.currentDirectory,
scriptKind,
hasMixedContent,
project.directoryStructureHost
));
path = scriptInfo.path;
const existingValue = projectRootFilesMap.get(path);
// If this script info is not already a root add it
if (!existingValue || existingValue.info !== scriptInfo) {
project.addRoot(scriptInfo, fileName);
if (scriptInfo.isScriptOpen()) {
// if file is already root in some inferred project
// - remove the file from that project and delete the project if necessary
this.removeRootOfInferredProjectIfNowPartOfOtherProject(scriptInfo);
}
}
else {
// Already root update the fileName
existingValue.fileName = fileName;
}
}
newRootScriptInfoMap.set(path, true);
}
// 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 (value.info) {
project.removeFile(value.info, 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 updateRootAndOptionsOfNonInferredProject<T>(project: ExternalProject | ConfiguredProject, newUncheckedFiles: T[], propertyReader: FilePropertyReader<T>, newOptions: CompilerOptions, newTypeAcquisition: TypeAcquisition, compileOnSave: boolean | undefined, watchOptions: WatchOptions | undefined) {
project.setCompilerOptions(newOptions);
project.setWatchOptions(watchOptions);
// 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;
}
this.addFilesToNonInferredProject(project, newUncheckedFiles, propertyReader, newTypeAcquisition);
}
/**
* Reload the file names from config file specs and update the project graph
*/
/*@internal*/
reloadFileNamesOfConfiguredProject(project: ConfiguredProject) {
const configFileSpecs = project.configFileSpecs!; // TODO: GH#18217
const configFileName = project.getConfigFilePath();
const fileNamesResult = getFileNamesFromConfigSpecs(configFileSpecs, getDirectoryPath(configFileName), project.getCompilationSettings(), project.getCachedDirectoryStructureHost(), this.hostConfiguration.extraFileExtensions);
project.updateErrorOnNoInputFiles(fileNamesResult);
this.updateNonInferredProjectFiles(project, fileNamesResult.fileNames.concat(project.getExternalFiles()), fileNamePropertyReader);
return project.updateGraph();
}
/*@internal*/
setFileNamesOfAutoImportProviderProject(project: AutoImportProviderProject, fileNames: string[]) {
this.updateNonInferredProjectFiles(project, fileNames, fileNamePropertyReader);
}
/**
* Read the config file of the project again by clearing the cache and update the project graph
*/
/* @internal */
reloadConfiguredProject(project: ConfiguredProject, reason: string, isInitialLoad: boolean, clearSemanticCache: boolean) {
// At this point, there is no reason to not have configFile in the host
const host = project.getCachedDirectoryStructureHost();
if (clearSemanticCache) this.clearSemanticCache(project);
// Clear the cache since we are reloading the project from disk
host.clearCache();
const configFileName = project.getConfigFilePath();
this.logger.info(`${isInitialLoad ? "Loading" : "Reloading"} configured project ${configFileName}`);
// Load project from the disk
this.loadConfiguredProject(project, reason);
project.updateGraph();
this.sendConfigFileDiagEvent(project, configFileName);
}
/* @internal */
private clearSemanticCache(project: Project) {
project.resolutionCache.clear();
project.getLanguageService(/*ensureSynchronized*/ false).cleanupSemanticCache();
project.markAsDirty();
}
private sendConfigFileDiagEvent(project: ConfiguredProject, triggerFile: NormalizedPath) {
if (!this.eventHandler || this.suppressDiagnosticEvents) {
return;
}
const diagnostics = project.getLanguageService().getCompilerOptionsDiagnostics();
diagnostics.push(...project.getAllProjectErrors());
this.eventHandler(<ConfigFileDiagEvent>{
eventName: ConfigFileDiagEvent,
data: { configFileName: project.getConfigFilePath(), diagnostics, triggerFile }
});
}
private getOrCreateInferredProjectForProjectRootPathIfEnabled(info: ScriptInfo, projectRootPath: NormalizedPath | undefined): InferredProject | undefined {
if (!this.useInferredProjectPerProjectRoot ||
// Its a dynamic info opened without project root
(info.isDynamic && projectRootPath === undefined)) {
return undefined;
}
if (projectRootPath) {
const canonicalProjectRootPath = this.toCanonicalFileName(projectRootPath);
// if we have an explicit project root path, find (or create) the matching inferred project.
for (const project of this.inferredProjects) {
if (project.projectRootPath === canonicalProjectRootPath) {
return project;
}
}
return this.createInferredProject(projectRootPath, /*isSingleInferredProject*/ false, projectRootPath);
}
// we don't have an explicit root path, so we should try to find an inferred project
// that more closely contains the file.
let bestMatch: InferredProject | undefined;
for (const project of this.inferredProjects) {
// 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, 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;
bestMatch = project;
}
return bestMatch;
}
private getOrCreateSingleInferredProjectIfEnabled(): InferredProject | undefined {
if (!this.useSingleInferredProject) {
return undefined;
}
// If `useInferredProjectPerProjectRoot` is not enabled, then there will only be one
// inferred project for all files. If `useInferredProjectPerProjectRoot` is enabled
// then we want to put all files that are not opened with a `projectRootPath` into
// the same inferred project.
//
// To avoid the cost of searching through the array and to optimize for the case where
// `useInferredProjectPerProjectRoot` is not enabled, we will always put the inferred
// project for non-rooted files at the front of the array.
if (this.inferredProjects.length > 0 && this.inferredProjects[0].projectRootPath === undefined) {
return this.inferredProjects[0];
}
// Single inferred project does not have a project root and hence no current directory
return this.createInferredProject(/*currentDirectory*/ undefined, /*isSingleInferredProject*/ true);
}
private getOrCreateSingleInferredWithoutProjectRoot(currentDirectory: string | undefined): InferredProject {
Debug.assert(!this.useSingleInferredProject);
const expectedCurrentDirectory = this.toCanonicalFileName(this.getNormalizedAbsolutePath(currentDirectory || ""));
// Reuse the project with same current directory but no roots
for (const inferredProject of this.inferredProjects) {
if (!inferredProject.projectRootPath &&
inferredProject.isOrphan() &&
inferredProject.canonicalCurrentDirectory === expectedCurrentDirectory) {
return inferredProject;
}
}
return this.createInferredProject(currentDirectory);
}
private createInferredProject(currentDirectory: string | undefined, isSingleInferredProject?: boolean, projectRootPath?: NormalizedPath): InferredProject {
const compilerOptions = projectRootPath && this.compilerOptionsForInferredProjectsPerProjectRoot.get(projectRootPath) || this.compilerOptionsForInferredProjects!; // TODO: GH#18217
let watchOptionsAndErrors: WatchOptionsAndErrors | false | undefined;
let typeAcquisition: TypeAcquisition | undefined;
if (projectRootPath) {
watchOptionsAndErrors = this.watchOptionsForInferredProjectsPerProjectRoot.get(projectRootPath);
typeAcquisition = this.typeAcquisitionForInferredProjectsPerProjectRoot.get(projectRootPath);
}
if (watchOptionsAndErrors === undefined) {
watchOptionsAndErrors = this.watchOptionsForInferredProjects;
}
if (typeAcquisition === undefined) {
typeAcquisition = this.typeAcquisitionForInferredProjects;
}
watchOptionsAndErrors = watchOptionsAndErrors || undefined;
const project = new InferredProject(this, this.documentRegistry, compilerOptions, watchOptionsAndErrors?.watchOptions, projectRootPath, currentDirectory, this.currentPluginConfigOverrides, typeAcquisition);
project.setProjectErrors(watchOptionsAndErrors?.errors);
if (isSingleInferredProject) {
this.inferredProjects.unshift(project);
}
else {
this.inferredProjects.push(project);
}
return project;
}
/*@internal*/
getOrCreateScriptInfoNotOpenedByClient(uncheckedFileName: string, currentDirectory: string, hostToQueryFileExistsOn: DirectoryStructureHost) {
return this.getOrCreateScriptInfoNotOpenedByClientForNormalizedPath(
toNormalizedPath(uncheckedFileName), currentDirectory, /*scriptKind*/ undefined,
/*hasMixedContent*/ undefined, hostToQueryFileExistsOn
);
}
getScriptInfo(uncheckedFileName: string) {
return this.getScriptInfoForNormalizedPath(toNormalizedPath(uncheckedFileName));
}
/* @internal */
getScriptInfoOrConfig(uncheckedFileName: string): ScriptInfoOrConfig | undefined {
const path = toNormalizedPath(uncheckedFileName);
const info = this.getScriptInfoForNormalizedPath(path);
if (info) return info;
const configProject = this.configuredProjects.get(this.toPath(uncheckedFileName));
return configProject && configProject.getCompilerOptions().configFile;
}
/* @internal */
logErrorForScriptInfoNotFound(fileName: string): void {
const names = arrayFrom(this.filenameToScriptInfo.entries()).map(([path, scriptInfo]) => ({ path, fileName: scriptInfo.fileName }));
this.logger.msg(`Could not find file ${JSON.stringify(fileName)}.\nAll files are: ${JSON.stringify(names)}`, Msg.Err);
}
/**
* Returns the projects that contain script info through SymLink
* Note that this does not return projects in info.containingProjects
*/
/*@internal*/
getSymlinkedProjects(info: ScriptInfo): MultiMap<Path, Project> | undefined {
let projects: MultiMap<Path, Project> | undefined;
if (this.realpathToScriptInfos) {
const realpath = info.getRealpathIfDifferent();
if (realpath) {
forEach(this.realpathToScriptInfos.get(realpath), combineProjects);
}
forEach(this.realpathToScriptInfos.get(info.path), combineProjects);
}
return projects;
function combineProjects(toAddInfo: ScriptInfo) {
if (toAddInfo !== info) {
for (const project of toAddInfo.containingProjects) {
// Add the projects only if they can use symLink targets and not already in the list
if (project.languageServiceEnabled &&
!project.isOrphan() &&
!project.getCompilerOptions().preserveSymlinks &&
!info.isAttached(project)) {
if (!projects) {
projects = createMultiMap();
projects.add(toAddInfo.path, project);
}
else if (!forEachEntry(projects, (projs, path) => path === toAddInfo.path ? false : contains(projs, project))) {
projects.add(toAddInfo.path, project);
}
}
}
}
}
}
private watchClosedScriptInfo(info: ScriptInfo) {
Debug.assert(!info.fileWatcher);
// do not watch files with mixed content - server doesn't know how to interpret it
// do not watch files in the global cache location
if (!info.isDynamicOrHasMixedContent() &&
(!this.globalCacheLocationDirectoryPath ||
!startsWith(info.path, this.globalCacheLocationDirectoryPath))) {
const indexOfNodeModules = info.path.indexOf("/node_modules/");
if (!this.host.getModifiedTime || indexOfNodeModules === -1) {
info.fileWatcher = this.watchFactory.watchFile(
info.fileName,
(_fileName, eventKind) => this.onSourceFileChanged(info, eventKind),
PollingInterval.Medium,
this.hostConfiguration.watchOptions,
WatchType.ClosedScriptInfo
);
}
else {
info.mTime = this.getModifiedTime(info);
info.fileWatcher = this.watchClosedScriptInfoInNodeModules(info.path.substr(0, indexOfNodeModules) as Path);
}
}
}
private watchClosedScriptInfoInNodeModules(dir: Path): ScriptInfoInNodeModulesWatcher {
// Watch only directory
const existing = this.scriptInfoInNodeModulesWatchers.get(dir);
if (existing) {
existing.refCount++;
return existing;
}
const watchDir = dir + "/node_modules" as Path;
const watcher = this.watchFactory.watchDirectory(
watchDir,
fileOrDirectory => {
const fileOrDirectoryPath = removeIgnoredPath(this.toPath(fileOrDirectory));
if (!fileOrDirectoryPath) return;
// Has extension
Debug.assert(result.refCount > 0);
if (watchDir === fileOrDirectoryPath) {
this.refreshScriptInfosInDirectory(watchDir);
}
else {
const info = this.getScriptInfoForPath(fileOrDirectoryPath);
if (info) {
if (isScriptInfoWatchedFromNodeModules(info)) {
this.refreshScriptInfo(info);
}
}
// Folder
else if (!hasExtension(fileOrDirectoryPath)) {
this.refreshScriptInfosInDirectory(fileOrDirectoryPath);
}
}
},
WatchDirectoryFlags.Recursive,
this.hostConfiguration.watchOptions,
WatchType.NodeModulesForClosedScriptInfo
);
const result: ScriptInfoInNodeModulesWatcher = {
close: () => {
if (result.refCount === 1) {
watcher.close();
this.scriptInfoInNodeModulesWatchers.delete(dir);
}
else {
result.refCount--;
}
},
refCount: 1
};
this.scriptInfoInNodeModulesWatchers.set(dir, result);
return result;
}
private getModifiedTime(info: ScriptInfo) {
return (this.host.getModifiedTime!(info.path) || missingFileModifiedTime).getTime();
}
private refreshScriptInfo(info: ScriptInfo) {
const mTime = this.getModifiedTime(info);
if (mTime !== info.mTime) {
const eventKind = getFileWatcherEventKind(info.mTime!, mTime);
info.mTime = mTime;
this.onSourceFileChanged(info, eventKind);
}
}
private refreshScriptInfosInDirectory(dir: Path) {
dir = dir + directorySeparator as Path;
this.filenameToScriptInfo.forEach(info => {
if (isScriptInfoWatchedFromNodeModules(info) && startsWith(info.path, dir)) {
this.refreshScriptInfo(info);
}
});
}
private stopWatchingScriptInfo(info: ScriptInfo) {
if (info.fileWatcher) {
info.fileWatcher.close();
info.fileWatcher = undefined;
}
}
private getOrCreateScriptInfoNotOpenedByClientForNormalizedPath(fileName: NormalizedPath, currentDirectory: string, scriptKind: ScriptKind | undefined, hasMixedContent: boolean | undefined, hostToQueryFileExistsOn: DirectoryStructureHost | undefined) {
if (isRootedDiskPath(fileName) || isDynamicFileName(fileName)) {
return this.getOrCreateScriptInfoWorker(fileName, currentDirectory, /*openedByClient*/ false, /*fileContent*/ undefined, scriptKind, hasMixedContent, hostToQueryFileExistsOn);
}
// This is non rooted path with different current directory than project service current directory
// Only paths recognized are open relative file paths
const info = this.openFilesWithNonRootedDiskPath.get(this.toCanonicalFileName(fileName));
if (info) {
return info;
}
// This means triple slash references wont be resolved in dynamic and unsaved files
// which is intentional since we dont know what it means to be relative to non disk files
return undefined;
}
private getOrCreateScriptInfoOpenedByClientForNormalizedPath(fileName: NormalizedPath, currentDirectory: string, fileContent: string | undefined, scriptKind: ScriptKind | undefined, hasMixedContent: boolean | undefined) {
return this.getOrCreateScriptInfoWorker(fileName, currentDirectory, /*openedByClient*/ true, fileContent, scriptKind, hasMixedContent);
}
getOrCreateScriptInfoForNormalizedPath(fileName: NormalizedPath, openedByClient: boolean, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, hostToQueryFileExistsOn?: { fileExists(path: string): boolean; }) {
return this.getOrCreateScriptInfoWorker(fileName, this.currentDirectory, openedByClient, fileContent, scriptKind, hasMixedContent, hostToQueryFileExistsOn);
}
private getOrCreateScriptInfoWorker(fileName: NormalizedPath, currentDirectory: string, openedByClient: boolean, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, hostToQueryFileExistsOn?: { fileExists(path: string): boolean; }) {
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, currentDirectory, this.toCanonicalFileName);
let info = this.getScriptInfoForPath(path);
if (!info) {
const isDynamic = isDynamicFileName(fileName);
Debug.assert(isRootedDiskPath(fileName) || isDynamic || openedByClient, "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nScript info with non-dynamic relative file name can only be open script info or in context of host currentDirectory`);
Debug.assert(!isRootedDiskPath(fileName) || this.currentDirectory === currentDirectory || !this.openFilesWithNonRootedDiskPath.has(this.toCanonicalFileName(fileName)), "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nOpen script files with non rooted disk path opened with current directory context cannot have same canonical names`);
Debug.assert(!isDynamic || this.currentDirectory === currentDirectory || this.useInferredProjectPerProjectRoot, "", () => `${JSON.stringify({ fileName, currentDirectory, hostCurrentDirectory: this.currentDirectory, openKeys: arrayFrom(this.openFilesWithNonRootedDiskPath.keys()) })}\nDynamic files must always be opened with service's current directory or service should support inferred project per projectRootPath.`);
// 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;
}
info = new ScriptInfo(this.host, fileName, scriptKind!, !!hasMixedContent, path, this.filenameToScriptInfoVersion.get(path)); // TODO: GH#18217
this.filenameToScriptInfo.set(info.path, info);
this.filenameToScriptInfoVersion.delete(info.path);
if (!openedByClient) {
this.watchClosedScriptInfo(info);
}
else if (!isRootedDiskPath(fileName) && (!isDynamic || this.currentDirectory !== currentDirectory)) {
// File that is opened by user but isn't rooted disk path
this.openFilesWithNonRootedDiskPath.set(this.toCanonicalFileName(fileName), info);
}
}
if (openedByClient) {
// 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();
}
}
return info;
}
/**
* This gets the script info for the normalized path. If the path is not rooted disk path then the open script info with project root context is preferred
*/
getScriptInfoForNormalizedPath(fileName: NormalizedPath) {
return !isRootedDiskPath(fileName) && this.openFilesWithNonRootedDiskPath.get(this.toCanonicalFileName(fileName)) ||
this.getScriptInfoForPath(normalizedPathToPath(fileName, this.currentDirectory, this.toCanonicalFileName));
}
getScriptInfoForPath(fileName: Path) {
return this.filenameToScriptInfo.get(fileName);
}
/*@internal*/
getDocumentPositionMapper(project: Project, generatedFileName: string, sourceFileName?: string): DocumentPositionMapper | undefined {
// Since declaration info and map file watches arent updating project's directory structure host (which can cache file structure) use host
const declarationInfo = this.getOrCreateScriptInfoNotOpenedByClient(generatedFileName, project.currentDirectory, this.host);
if (!declarationInfo) {
if (sourceFileName) {
// Project contains source file and it generates the generated file name
project.addGeneratedFileWatch(generatedFileName, sourceFileName);
}
return undefined;
}
// Try to get from cache
declarationInfo.getSnapshot(); // Ensure synchronized
if (isString(declarationInfo.sourceMapFilePath)) {
// Ensure mapper is synchronized
const sourceMapFileInfo = this.getScriptInfoForPath(declarationInfo.sourceMapFilePath);
if (sourceMapFileInfo) {
sourceMapFileInfo.getSnapshot();
if (sourceMapFileInfo.documentPositionMapper !== undefined) {
sourceMapFileInfo.sourceInfos = this.addSourceInfoToSourceMap(sourceFileName, project, sourceMapFileInfo.sourceInfos);
return sourceMapFileInfo.documentPositionMapper ? sourceMapFileInfo.documentPositionMapper : undefined;
}
}
declarationInfo.sourceMapFilePath = undefined;
}
else if (declarationInfo.sourceMapFilePath) {
declarationInfo.sourceMapFilePath.sourceInfos = this.addSourceInfoToSourceMap(sourceFileName, project, declarationInfo.sourceMapFilePath.sourceInfos);
return undefined;
}
else if (declarationInfo.sourceMapFilePath !== undefined) {
// Doesnt have sourceMap
return undefined;
}
// Create the mapper
let sourceMapFileInfo: ScriptInfo | undefined;
let mapFileNameFromDeclarationInfo: string | undefined;
let readMapFile: ReadMapFile | undefined = (mapFileName, mapFileNameFromDts) => {
const mapInfo = this.getOrCreateScriptInfoNotOpenedByClient(mapFileName, project.currentDirectory, this.host);
if (!mapInfo) {
mapFileNameFromDeclarationInfo = mapFileNameFromDts;
return undefined;
}
sourceMapFileInfo = mapInfo;
const snap = mapInfo.getSnapshot();
if (mapInfo.documentPositionMapper !== undefined) return mapInfo.documentPositionMapper;
return snap.getText(0, snap.getLength());
};
const projectName = project.projectName;
const documentPositionMapper = getDocumentPositionMapper(
{ getCanonicalFileName: this.toCanonicalFileName, log: s => this.logger.info(s), getSourceFileLike: f => this.getSourceFileLike(f, projectName, declarationInfo) },
declarationInfo.fileName,
declarationInfo.getLineInfo(),
readMapFile
);
readMapFile = undefined; // Remove ref to project
if (sourceMapFileInfo) {
declarationInfo.sourceMapFilePath = sourceMapFileInfo.path;
sourceMapFileInfo.declarationInfoPath = declarationInfo.path;
sourceMapFileInfo.documentPositionMapper = documentPositionMapper || false;
sourceMapFileInfo.sourceInfos = this.addSourceInfoToSourceMap(sourceFileName, project, sourceMapFileInfo.sourceInfos);
}
else if (mapFileNameFromDeclarationInfo) {
declarationInfo.sourceMapFilePath = {
watcher: this.addMissingSourceMapFile(
project.currentDirectory === this.currentDirectory ?
mapFileNameFromDeclarationInfo :
getNormalizedAbsolutePath(mapFileNameFromDeclarationInfo, project.currentDirectory),
declarationInfo.path
),
sourceInfos: this.addSourceInfoToSourceMap(sourceFileName, project)
};
}
else {
declarationInfo.sourceMapFilePath = false;
}
return documentPositionMapper;
}
private addSourceInfoToSourceMap(sourceFileName: string | undefined, project: Project, sourceInfos?: Set<Path>) {
if (sourceFileName) {
// Attach as source
const sourceInfo = this.getOrCreateScriptInfoNotOpenedByClient(sourceFileName, project.currentDirectory, project.directoryStructureHost)!;
(sourceInfos || (sourceInfos = new Set())).add(sourceInfo.path);
}
return sourceInfos;
}
private addMissingSourceMapFile(mapFileName: string, declarationInfoPath: Path) {
const fileWatcher = this.watchFactory.watchFile(
mapFileName,
() => {
const declarationInfo = this.getScriptInfoForPath(declarationInfoPath);
if (declarationInfo && declarationInfo.sourceMapFilePath && !isString(declarationInfo.sourceMapFilePath)) {
// Update declaration and source projects
this.delayUpdateProjectGraphs(declarationInfo.containingProjects, /*clearSourceMapperCache*/ true);
this.delayUpdateSourceInfoProjects(declarationInfo.sourceMapFilePath.sourceInfos);
declarationInfo.closeSourceMapFileWatcher();
}
},
PollingInterval.High,
this.hostConfiguration.watchOptions,
WatchType.MissingSourceMapFile,
);
return fileWatcher;
}
/*@internal*/
getSourceFileLike(fileName: string, projectNameOrProject: string | Project, declarationInfo?: ScriptInfo) {
const project = (projectNameOrProject as Project).projectName ? projectNameOrProject as Project : this.findProject(projectNameOrProject as string);
if (project) {
const path = project.toPath(fileName);
const sourceFile = project.getSourceFile(path);
if (sourceFile && sourceFile.resolvedPath === path) return sourceFile;
}
// Need to look for other files.
const info = this.getOrCreateScriptInfoNotOpenedByClient(fileName, (project || this).currentDirectory, project ? project.directoryStructureHost : this.host);
if (!info) return undefined;
// Attach as source
if (declarationInfo && isString(declarationInfo.sourceMapFilePath) && info !== declarationInfo) {
const sourceMapInfo = this.getScriptInfoForPath(declarationInfo.sourceMapFilePath);
if (sourceMapInfo) {
(sourceMapInfo.sourceInfos || (sourceMapInfo.sourceInfos = new Set())).add(info.path);
}
}
// Key doesnt matter since its only for text and lines
if (info.cacheSourceFile) return info.cacheSourceFile.sourceFile;
// Create sourceFileLike
if (!info.sourceFileLike) {
info.sourceFileLike = {
get text() {
Debug.fail("shouldnt need text");
return "";
},
getLineAndCharacterOfPosition: pos => {
const lineOffset = info.positionToLineOffset(pos);
return { line: lineOffset.line - 1, character: lineOffset.offset - 1 };
},
getPositionOfLineAndCharacter: (line, character, allowEdits) => info.lineOffsetToPosition(line + 1, character + 1, allowEdits)
};
}
return info.sourceFileLike;
}
/*@internal*/
setPerformanceEventHandler(performanceEventHandler: PerformanceEventHandler) {
this.performanceEventHandler = performanceEventHandler;
}
setHostConfiguration(args: protocol.ConfigureRequestArguments) {
if (args.file) {
const info = this.getScriptInfoForNormalizedPath(toNormalizedPath(args.file));
if (info) {
info.setOptions(convertFormatOptions(args.formatOptions!), args.preferences);
this.logger.info(`Host configuration update for file ${args.file}`);
}
}
else {
if (args.hostInfo !== undefined) {
this.hostConfiguration.hostInfo = args.hostInfo;
this.logger.info(`Host information ${args.hostInfo}`);
}
if (args.formatOptions) {
this.hostConfiguration.formatCodeOptions = { ...this.hostConfiguration.formatCodeOptions, ...convertFormatOptions(args.formatOptions) };
this.logger.info("Format host information updated");
}
if (args.preferences) {
const { lazyConfiguredProjectsFromExternalProject, includePackageJsonAutoImports } = this.hostConfiguration.preferences;
this.hostConfiguration.preferences = { ...this.hostConfiguration.preferences, ...args.preferences };
if (lazyConfiguredProjectsFromExternalProject && !this.hostConfiguration.preferences.lazyConfiguredProjectsFromExternalProject) {
// Load configured projects for external projects that are pending reload
this.configuredProjects.forEach(project => {
if (project.hasExternalProjectRef() &&
project.pendingReload === ConfigFileProgramReloadLevel.Full &&
!this.pendingProjectUpdates.has(project.getProjectName())) {
project.updateGraph();
}
});
}
if (includePackageJsonAutoImports !== args.preferences.includePackageJsonAutoImports) {
this.invalidateProjectAutoImports(/*packageJsonPath*/ undefined);
}
}
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");
}
if (args.watchOptions) {
this.hostConfiguration.watchOptions = convertWatchOptions(args.watchOptions)?.watchOptions;
this.logger.info(`Host watch options changed to ${JSON.stringify(this.hostConfiguration.watchOptions)}, it will be take effect for next watches.`);
}
}
}
/*@internal*/
getWatchOptions(project: Project) {
const projectOptions = project.getWatchOptions();
return projectOptions && this.hostConfiguration.watchOptions ?
{ ...this.hostConfiguration.watchOptions, ...projectOptions } :
projectOptions || this.hostConfiguration.watchOptions;
}
closeLog() {
this.logger.close();
}
/**
* 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 script infos
this.filenameToScriptInfo.forEach(info => {
if (this.openFiles.has(info.path)) return; // Skip open files
if (!info.fileWatcher) return; // not watched file
// Handle as if file is changed or deleted
this.onSourceFileChanged(info, this.host.fileExists(info.fileName) ? FileWatcherEventKind.Changed : FileWatcherEventKind.Deleted);
});
// Cancel all project updates since we will be updating them now
this.pendingProjectUpdates.forEach((_project, projectName) => {
this.throttledOperations.cancel(projectName);
this.pendingProjectUpdates.delete(projectName);
});
this.throttledOperations.cancel(ensureProjectForOpenFileSchedule);
this.pendingEnsureProjectForOpenFiles = false;
// Reload Projects
this.reloadConfiguredProjectForFiles(this.openFiles as ESMap<Path, NormalizedPath | undefined>, /*clearSemanticCache*/ true, /*delayReload*/ false, returnTrue, "User requested reload projects");
this.externalProjects.forEach(project => {
this.clearSemanticCache(project);
project.updateGraph();
});
this.inferredProjects.forEach(project => this.clearSemanticCache(project));
this.ensureProjectForOpenFiles();
}
private delayReloadConfiguredProjectForFiles(configFileExistenceInfo: ConfigFileExistenceInfo, ignoreIfNotRootOfInferredProject: boolean) {
// Get open files to reload projects for
this.reloadConfiguredProjectForFiles(
configFileExistenceInfo.openFilesImpactedByConfigFile,
/*clearSemanticCache*/ false,
/*delayReload*/ true,
ignoreIfNotRootOfInferredProject ?
isRootOfInferredProject => isRootOfInferredProject : // Reload open files if they are root of inferred project
returnTrue, // Reload all the open files impacted by config file
"Change in config file detected"
);
this.delayEnsureProjectForOpenFiles();
}
/**
* 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
* reloadForInfo provides a way to filter out files to reload configured project for
*/
private reloadConfiguredProjectForFiles<T>(openFiles: ESMap<Path, T>, clearSemanticCache: boolean, delayReload: boolean, shouldReloadProjectFor: (openFileValue: T) => boolean, reason: string) {
const updatedProjects = new Map<string, true>();
const reloadChildProject = (child: ConfiguredProject) => {
if (!updatedProjects.has(child.canonicalConfigFilePath)) {
updatedProjects.set(child.canonicalConfigFilePath, true);
this.reloadConfiguredProject(child, reason, /*isInitialLoad*/ false, clearSemanticCache);
}
};
// try to reload config file for all open files
openFiles.forEach((openFileValue, path) => {
// Invalidate default config file name for open file
this.configFileForOpenFiles.delete(path);
// Filter out the files that need to be ignored
if (!shouldReloadProjectFor(openFileValue)) {
return;
}
const info = this.getScriptInfoForPath(path)!; // TODO: GH#18217
Debug.assert(info.isScriptOpen());
// 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) || this.createConfiguredProject(configFileName);
if (!updatedProjects.has(project.canonicalConfigFilePath)) {
updatedProjects.set(project.canonicalConfigFilePath, true);
if (delayReload) {
project.pendingReload = ConfigFileProgramReloadLevel.Full;
project.pendingReloadReason = reason;
if (clearSemanticCache) this.clearSemanticCache(project);
this.delayUpdateProjectGraph(project);
}
else {
// reload from the disk
this.reloadConfiguredProject(project, reason, /*isInitialLoad*/ false, clearSemanticCache);
// If this project does not contain this file directly, reload the project till the reloaded project contains the script info directly
if (!projectContainsInfoDirectly(project, info)) {
const referencedProject = forEachResolvedProjectReferenceProject(
project,
info.path,
child => {
reloadChildProject(child);
return projectContainsInfoDirectly(child, info);
},
ProjectReferenceProjectLoadKind.FindCreate
);
if (referencedProject) {
// Reload the project's tree that is already present
forEachResolvedProjectReferenceProject(
project,
/*fileName*/ undefined,
reloadChildProject,
ProjectReferenceProjectLoadKind.Find
);
}
}
}
}
}
});
}
/**
* 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 supersede 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.
Debug.assert(info.containingProjects.length > 0);
const firstProject = info.containingProjects[0];
if (!firstProject.isOrphan() &&
isInferredProject(firstProject) &&
firstProject.isRoot(info) &&
forEach(info.containingProjects, p => p !== firstProject && !p.isOrphan())) {
firstProject.removeFile(info, /*fileExists*/ true, /*detachFromProject*/ true);
}
}
/**
* 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
*/
private ensureProjectForOpenFiles() {
this.logger.info("Before ensureProjectForOpenFiles:");
this.printProjects();
this.openFiles.forEach((projectRootPath, path) => {
const info = this.getScriptInfoForPath(path as Path)!;
// collect all orphaned script infos from open files
if (info.isOrphan()) {
this.assignOrphanScriptInfoToInferredProject(info, projectRootPath);
}
else {
// Or remove the root of inferred project if is referenced in more than one projects
this.removeRootOfInferredProjectIfNowPartOfOtherProject(info);
}
});
this.pendingEnsureProjectForOpenFiles = false;
this.inferredProjects.forEach(updateProjectIfDirty);
this.logger.info("After ensureProjectForOpenFiles:");
this.printProjects();
}
/**
* Open file whose contents is managed by the client
* @param filename is absolute pathname
* @param fileContent is a known version of the file content that is more up to date than the one on disk
*/
openClientFile(fileName: string, fileContent?: string, scriptKind?: ScriptKind, projectRootPath?: string): OpenConfiguredProjectResult {
return this.openClientFileWithNormalizedPath(toNormalizedPath(fileName), fileContent, scriptKind, /*hasMixedContent*/ false, projectRootPath ? toNormalizedPath(projectRootPath) : undefined);
}
/*@internal*/
getOriginalLocationEnsuringConfiguredProject(project: Project, location: DocumentPosition): DocumentPosition | undefined {
const originalLocation = project.isSourceOfProjectReferenceRedirect(location.fileName) ?
location :
project.getSourceMapper().tryGetSourcePosition(location);
if (!originalLocation) return undefined;
const { fileName } = originalLocation;
if (!this.getScriptInfo(fileName) && !this.host.fileExists(fileName)) return undefined;
const originalFileInfo: OriginalFileInfo = { fileName: toNormalizedPath(fileName), path: this.toPath(fileName) };
const configFileName = this.getConfigFileNameForFile(originalFileInfo);
if (!configFileName) return undefined;
let configuredProject: ConfiguredProject | undefined = this.findConfiguredProjectByProjectName(configFileName) ||
this.createAndLoadConfiguredProject(configFileName, `Creating project for original file: ${originalFileInfo.fileName}${location !== originalLocation ? " for location: " + location.fileName : ""}`);
updateProjectIfDirty(configuredProject);
const projectContainsOriginalInfo = (project: ConfiguredProject) => {
const info = this.getScriptInfo(fileName);
return info && projectContainsInfoDirectly(project, info);
};
if (configuredProject.isSolution() || !projectContainsOriginalInfo(configuredProject)) {
// Find the project that is referenced from this solution that contains the script info directly
configuredProject = forEachResolvedProjectReferenceProject(
configuredProject,
fileName,
child => {
updateProjectIfDirty(child);
return projectContainsOriginalInfo(child) ? child : undefined;
},
ProjectReferenceProjectLoadKind.FindCreateLoad,
`Creating project referenced in solution ${configuredProject.projectName} to find possible configured project for original file: ${originalFileInfo.fileName}${location !== originalLocation ? " for location: " + location.fileName : ""}`
);
if (!configuredProject) return undefined;
if (configuredProject === project) return originalLocation;
}
// Keep this configured project as referenced from project
addOriginalConfiguredProject(configuredProject);
const originalScriptInfo = this.getScriptInfo(fileName);
if (!originalScriptInfo || !originalScriptInfo.containingProjects.length) return undefined;
// Add configured projects as referenced
originalScriptInfo.containingProjects.forEach(project => {
if (isConfiguredProject(project)) {
addOriginalConfiguredProject(project);
}
});
return originalLocation;
function addOriginalConfiguredProject(originalProject: ConfiguredProject) {
if (!project.originalConfiguredProjects) {
project.originalConfiguredProjects = new Set();
}
project.originalConfiguredProjects.add(originalProject.canonicalConfigFilePath);
}
}
/** @internal */
fileExists(fileName: NormalizedPath): boolean {
return !!this.getScriptInfoForNormalizedPath(fileName) || this.host.fileExists(fileName);
}
private findExternalProjectContainingOpenScriptInfo(info: ScriptInfo): ExternalProject | undefined {
return find(this.externalProjects, proj => {
// Ensure project structure is up-to-date to check if info is present in external project
updateProjectIfDirty(proj);
return proj.containsScriptInfo(info);
});
}
private getOrCreateOpenScriptInfo(fileName: NormalizedPath, fileContent: string | undefined, scriptKind: ScriptKind | undefined, hasMixedContent: boolean | undefined, projectRootPath: NormalizedPath | undefined) {
const info = this.getOrCreateScriptInfoOpenedByClientForNormalizedPath(fileName, projectRootPath ? this.getNormalizedAbsolutePath(projectRootPath) : this.currentDirectory, fileContent, scriptKind, hasMixedContent)!; // TODO: GH#18217
this.openFiles.set(info.path, projectRootPath);
return info;
}
private assignProjectToOpenedScriptInfo(info: ScriptInfo): AssignProjectResult {
let configFileName: NormalizedPath | undefined;
let configFileErrors: readonly Diagnostic[] | undefined;
let project: ConfiguredProject | ExternalProject | undefined = this.findExternalProjectContainingOpenScriptInfo(info);
let retainProjects: ConfiguredProject[] | ConfiguredProject | undefined;
let projectForConfigFileDiag: ConfiguredProject | undefined;
let defaultConfigProjectIsCreated = false;
if (!project && this.serverMode === LanguageServiceMode.Semantic) { // Checking semantic mode is an optimization
configFileName = this.getConfigFileNameForFile(info);
if (configFileName) {
project = this.findConfiguredProjectByProjectName(configFileName);
if (!project) {
project = this.createLoadAndUpdateConfiguredProject(configFileName, `Creating possible configured project for ${info.fileName} to open`);
defaultConfigProjectIsCreated = true;
}
else {
// Ensure project is ready to check if it contains opened script info
updateProjectIfDirty(project);
}
projectForConfigFileDiag = project.containsScriptInfo(info) ? project : undefined;
retainProjects = project;
// If this configured project doesnt contain script info but
// it is solution with project references, try those project references
if (!projectContainsInfoDirectly(project, info)) {
forEachResolvedProjectReferenceProject(
project,
info.path,
child => {
updateProjectIfDirty(child);
// Retain these projects
if (!isArray(retainProjects)) {
retainProjects = [project as ConfiguredProject, child];
}
else {
retainProjects.push(child);
}
// If script info belongs to this child project, use this as default config project
if (projectContainsInfoDirectly(child, info)) {
projectForConfigFileDiag = child;
return child;
}
// If this project uses the script info (even through project reference), if default project is not found, use this for configFileDiag
if (!projectForConfigFileDiag && child.containsScriptInfo(info)) {
projectForConfigFileDiag = child;
}
},
ProjectReferenceProjectLoadKind.FindCreateLoad,
`Creating project referenced in solution ${project.projectName} to find possible configured project for ${info.fileName} to open`
);
}
// Send the event only if the project got created as part of this open request and info is part of the project
if (projectForConfigFileDiag) {
configFileName = projectForConfigFileDiag.getConfigFilePath();
if (projectForConfigFileDiag !== project || defaultConfigProjectIsCreated) {
configFileErrors = projectForConfigFileDiag.getAllProjectErrors();
this.sendConfigFileDiagEvent(projectForConfigFileDiag, info.fileName);
}
}
else {
// Since the file isnt part of configured project, do not send config file info
configFileName = undefined;
}
// Create ancestor configured project
this.createAncestorProjects(info, project);
}
}
// Project we have at this point is going to be updated since its either found through
// - external project search, which updates the project before checking if info is present in it
// - configured project - either created or updated to ensure we know correct status of info
// At this point we need to ensure that containing projects of the info are uptodate
// This will ensure that later question of info.isOrphan() will return correct answer
// and we correctly create inferred project for the info
info.containingProjects.forEach(updateProjectIfDirty);
// 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()) {
// Even though this info did not belong to any of the configured projects, send the config file diag
if (isArray(retainProjects)) {
retainProjects.forEach(project => this.sendConfigFileDiagEvent(project, info.fileName));
}
else if (retainProjects) {
this.sendConfigFileDiagEvent(retainProjects, info.fileName);
}
Debug.assert(this.openFiles.has(info.path));
this.assignOrphanScriptInfoToInferredProject(info, this.openFiles.get(info.path));
}
Debug.assert(!info.isOrphan());
return { configFileName, configFileErrors, retainProjects };
}
private createAncestorProjects(info: ScriptInfo, project: ConfiguredProject) {
// Skip if info is not part of default configured project
if (!info.isAttached(project)) return;
// Create configured project till project root
while (true) {
// Skip if project is not composite
if (!project.isInitialLoadPending() &&
(
!project.getCompilerOptions().composite ||
project.getCompilerOptions().disableSolutionSearching
)
) return;
// Get config file name
const configFileName = this.getConfigFileNameForFile({
fileName: project.getConfigFilePath(),
path: info.path,
configFileInfo: true
});
if (!configFileName) return;
// find or delay load the project
const ancestor = this.findConfiguredProjectByProjectName(configFileName) ||
this.createConfiguredProjectWithDelayLoad(configFileName, `Creating project possibly referencing default composite project ${project.getProjectName()} of open file ${info.fileName}`);
if (ancestor.isInitialLoadPending()) {
// Set a potential project reference
ancestor.setPotentialProjectReference(project.canonicalConfigFilePath);
}
project = ancestor;
}
}
/*@internal*/
loadAncestorProjectTree(forProjects?: ReadonlyCollection<string>) {
forProjects = forProjects || mapDefinedEntries(
this.configuredProjects,
(key, project) => !project.isInitialLoadPending() ? [key, true] : undefined
);
const seenProjects = new Set<NormalizedPath>();
// Work on array copy as we could add more projects as part of callback
for (const project of arrayFrom(this.configuredProjects.values())) {
// If this project has potential project reference for any of the project we are loading ancestor tree for
// load this project first
if (forEachPotentialProjectReference(project, potentialRefPath => forProjects!.has(potentialRefPath))) {
updateProjectIfDirty(project);
}
this.ensureProjectChildren(project, forProjects, seenProjects);
}
}
private ensureProjectChildren(project: ConfiguredProject, forProjects: ReadonlyCollection<string>, seenProjects: Set<NormalizedPath>) {
if (!tryAddToSet(seenProjects, project.canonicalConfigFilePath)) return;
// If this project disables child load ignore it
if (project.getCompilerOptions().disableReferencedProjectLoad) return;
const children = project.getCurrentProgram()?.getResolvedProjectReferences();
if (!children) return;
for (const child of children) {
if (!child) continue;
const referencedProject = forEachResolvedProjectReference(child.references, ref => forProjects.has(ref.sourceFile.path) ? ref : undefined);
if (!referencedProject) continue;
// Load this project,
const configFileName = toNormalizedPath(child.sourceFile.fileName);
const childProject = project.projectService.findConfiguredProjectByProjectName(configFileName) ||
project.projectService.createAndLoadConfiguredProject(configFileName, `Creating project referenced by : ${project.projectName} as it references project ${referencedProject.sourceFile.fileName}`);
updateProjectIfDirty(childProject);
// Ensure children for this project
this.ensureProjectChildren(childProject, forProjects, seenProjects);
}
}
private cleanupAfterOpeningFile(toRetainConfigProjects: readonly ConfiguredProject[] | ConfiguredProject | undefined) {
// This was postponed from closeOpenFile to after opening next file,
// so that we can reuse the project if we need to right away
this.removeOrphanConfiguredProjects(toRetainConfigProjects);
// Remove orphan inferred projects now that we have reused projects
// We need to create a duplicate because we cant guarantee order after removal
for (const inferredProject of this.inferredProjects.slice()) {
if (inferredProject.isOrphan()) {
this.removeProject(inferredProject);
}
}
// 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
// the file from that old project is reopened because of opening file from here.
this.removeOrphanScriptInfos();
}
openClientFileWithNormalizedPath(fileName: NormalizedPath, fileContent?: string, scriptKind?: ScriptKind, hasMixedContent?: boolean, projectRootPath?: NormalizedPath): OpenConfiguredProjectResult {
const info = this.getOrCreateOpenScriptInfo(fileName, fileContent, scriptKind, hasMixedContent, projectRootPath);
const { retainProjects, ...result } = this.assignProjectToOpenedScriptInfo(info);
this.cleanupAfterOpeningFile(retainProjects);
this.telemetryOnOpenFile(info);
this.printProjects();
return result;
}
private removeOrphanConfiguredProjects(toRetainConfiguredProjects: readonly ConfiguredProject[] | ConfiguredProject | undefined) {
const toRemoveConfiguredProjects = new Map(this.configuredProjects);
const markOriginalProjectsAsUsed = (project: Project) => {
if (!project.isOrphan() && project.originalConfiguredProjects) {
project.originalConfiguredProjects.forEach(
(_value, configuredProjectPath) => {
const project = this.getConfiguredProjectByCanonicalConfigFilePath(configuredProjectPath);
return project && retainConfiguredProject(project);
}
);
}
};
if (toRetainConfiguredProjects) {
if (isArray(toRetainConfiguredProjects)) {
toRetainConfiguredProjects.forEach(retainConfiguredProject);
}
else {
retainConfiguredProject(toRetainConfiguredProjects);
}
}
// Do not remove configured projects that are used as original projects of other
this.inferredProjects.forEach(markOriginalProjectsAsUsed);
this.externalProjects.forEach(markOriginalProjectsAsUsed);
this.configuredProjects.forEach(project => {
// If project has open ref (there are more than zero references from external project/open file), keep it alive as well as any project it references
if (project.hasOpenRef()) {
retainConfiguredProject(project);
}
else if (toRemoveConfiguredProjects.has(project.canonicalConfigFilePath)) {
// If the configured project for project reference has more than zero references, keep it alive
forEachReferencedProject(
project,
ref => isRetained(ref) && retainConfiguredProject(project)
);
}
});
// Remove all the non marked projects
toRemoveConfiguredProjects.forEach(project => this.removeProject(project));
function isRetained(project: ConfiguredProject) {
return project.hasOpenRef() || !toRemoveConfiguredProjects.has(project.canonicalConfigFilePath);
}
function retainConfiguredProject(project: ConfiguredProject) {
if (toRemoveConfiguredProjects.delete(project.canonicalConfigFilePath)) {
// Keep original projects used
markOriginalProjectsAsUsed(project);
// Keep all the references alive
forEachReferencedProject(project, retainConfiguredProject);
}
}
}
private removeOrphanScriptInfos() {
const toRemoveScriptInfos = new Map(this.filenameToScriptInfo);
this.filenameToScriptInfo.forEach(info => {
// If script info is open or orphan, retain it and its dependencies
if (!info.isScriptOpen() && info.isOrphan() && !info.isContainedByAutoImportProvider()) {
// Otherwise if there is any source info that is alive, this alive too
if (!info.sourceMapFilePath) return;
let sourceInfos: Set<Path> | undefined;
if (isString(info.sourceMapFilePath)) {
const sourceMapInfo = this.getScriptInfoForPath(info.sourceMapFilePath);
sourceInfos = sourceMapInfo && sourceMapInfo.sourceInfos;
}
else {
sourceInfos = info.sourceMapFilePath.sourceInfos;
}
if (!sourceInfos) return;
if (!forEachKey(sourceInfos, path => {
const info = this.getScriptInfoForPath(path);
return !!info && (info.isScriptOpen() || !info.isOrphan());
})) {
return;
}
}
// Retain this script info
toRemoveScriptInfos.delete(info.path);
if (info.sourceMapFilePath) {
let sourceInfos: Set<Path> | undefined;
if (isString(info.sourceMapFilePath)) {
// And map file info and source infos
toRemoveScriptInfos.delete(info.sourceMapFilePath);
const sourceMapInfo = this.getScriptInfoForPath(info.sourceMapFilePath);
sourceInfos = sourceMapInfo && sourceMapInfo.sourceInfos;
}
else {
sourceInfos = info.sourceMapFilePath.sourceInfos;
}
if (sourceInfos) {
sourceInfos.forEach((_value, path) => toRemoveScriptInfos.delete(path));
}
}
});
toRemoveScriptInfos.forEach(info => {
// if there are not projects that include this script info - delete it
this.stopWatchingScriptInfo(info);
this.deleteScriptInfo(info);
info.closeSourceMapFileWatcher();
});
}
private telemetryOnOpenFile(scriptInfo: ScriptInfo): void {
if (this.serverMode !== LanguageServiceMode.Semantic || !this.eventHandler || !scriptInfo.isJavaScript() || !addToSeen(this.allJsFilesForOpenFileTelemetry, scriptInfo.path)) {
return;
}
const project = scriptInfo.getDefaultProject();
if (!project.languageServiceEnabled) {
return;
}
const sourceFile = project.getSourceFile(scriptInfo.path);
const checkJs = !!sourceFile && !!sourceFile.checkJsDirective;
this.eventHandler({ eventName: OpenFileInfoTelemetryEvent, data: { info: { checkJs } } });
}
/**
* Close file whose contents is managed by the client
* @param filename is absolute pathname
*/
closeClientFile(uncheckedFileName: string): void;
/*@internal*/
closeClientFile(uncheckedFileName: string, skipAssignOrphanScriptInfosToInferredProject: true): boolean;
closeClientFile(uncheckedFileName: string, skipAssignOrphanScriptInfosToInferredProject?: true) {
const info = this.getScriptInfoForNormalizedPath(toNormalizedPath(uncheckedFileName));
const result = info ? this.closeOpenFile(info, skipAssignOrphanScriptInfosToInferredProject) : false;
if (!skipAssignOrphanScriptInfosToInferredProject) {
this.printProjects();
}
return result;
}
private collectChanges(
lastKnownProjectVersions: protocol.ProjectVersionInfo[],
currentProjects: Project[],
includeProjectReferenceRedirectInfo: boolean | undefined,
result: ProjectFilesWithTSDiagnostics[]
): void {
for (const proj of currentProjects) {
const knownProject = find(lastKnownProjectVersions, p => p.projectName === proj.getProjectName());
result.push(proj.getChangesSinceVersion(knownProject && knownProject.version, includeProjectReferenceRedirectInfo));
}
}
/* @internal */
synchronizeProjectList(knownProjects: protocol.ProjectVersionInfo[], includeProjectReferenceRedirectInfo?: boolean): ProjectFilesWithTSDiagnostics[] {
const files: ProjectFilesWithTSDiagnostics[] = [];
this.collectChanges(knownProjects, this.externalProjects, includeProjectReferenceRedirectInfo, files);
this.collectChanges(knownProjects, arrayFrom(this.configuredProjects.values()), includeProjectReferenceRedirectInfo, files);
this.collectChanges(knownProjects, this.inferredProjects, includeProjectReferenceRedirectInfo, files);
return files;
}
/* @internal */
applyChangesInOpenFiles(openFiles: Iterator<OpenFileArguments> | undefined, changedFiles?: Iterator<ChangeFileArguments>, closedFiles?: string[]): void {
let openScriptInfos: ScriptInfo[] | undefined;
let assignOrphanScriptInfosToInferredProject = false;
if (openFiles) {
while (true) {
const iterResult = openFiles.next();
if (iterResult.done) break;
const file = iterResult.value;
// Create script infos so we have the new content for all the open files before we do any updates to projects
const info = this.getOrCreateOpenScriptInfo(
toNormalizedPath(file.fileName),
file.content,
tryConvertScriptKindName(file.scriptKind!),
file.hasMixedContent,
file.projectRootPath ? toNormalizedPath(file.projectRootPath) : undefined
);
(openScriptInfos || (openScriptInfos = [])).push(info);
}
}
if (changedFiles) {
while (true) {
const iterResult = changedFiles.next();
if (iterResult.done) break;
const file = iterResult.value;
const scriptInfo = this.getScriptInfo(file.fileName)!;
Debug.assert(!!scriptInfo);
// Make edits to script infos and marks containing project as dirty
this.applyChangesToFile(scriptInfo, file.changes);
}
}
if (closedFiles) {
for (const file of closedFiles) {
// Close files, but dont assign projects to orphan open script infos, that part comes later
assignOrphanScriptInfosToInferredProject = this.closeClientFile(file, /*skipAssignOrphanScriptInfosToInferredProject*/ true) || assignOrphanScriptInfosToInferredProject;
}
}
// All the script infos now exist, so ok to go update projects for open files
let retainProjects: readonly ConfiguredProject[] | undefined;
if (openScriptInfos) {
retainProjects = flatMap(openScriptInfos, info => this.assignProjectToOpenedScriptInfo(info).retainProjects);
}
// While closing files there could be open files that needed assigning new inferred projects, do it now
if (assignOrphanScriptInfosToInferredProject) {
this.assignOrphanScriptInfosToInferredProject();
}
if (openScriptInfos) {
// Cleanup projects
this.cleanupAfterOpeningFile(retainProjects);
// Telemetry
openScriptInfos.forEach(info => this.telemetryOnOpenFile(info));
this.printProjects();
}
else if (length(closedFiles)) {
this.printProjects();
}
}
/* @internal */
applyChangesToFile(scriptInfo: ScriptInfo, changes: Iterator<TextChange>) {
while (true) {
const iterResult = changes.next();
if (iterResult.done) break;
const change = iterResult.value;
scriptInfo.editContent(change.span.start, change.span.start + change.span.length, change.newText);
}
}
private closeConfiguredProjectReferencedFromExternalProject(configFile: NormalizedPath) {
const configuredProject = this.findConfiguredProjectByProjectName(configFile);
if (configuredProject) {
configuredProject.deleteExternalProjectReference();
if (!configuredProject.hasOpenRef()) {
this.removeProject(configuredProject);
return;
}
}
}
closeExternalProject(uncheckedFileName: string): void {
const fileName = toNormalizedPath(uncheckedFileName);
const configFiles = this.externalProjectToConfiguredProjectMap.get(fileName);
if (configFiles) {
for (const configFile of configFiles) {
this.closeConfiguredProjectReferencedFromExternalProject(configFile);
}
this.externalProjectToConfiguredProjectMap.delete(fileName);
}
else {
// close external project
const externalProject = this.findExternalProjectByProjectName(uncheckedFileName);
if (externalProject) {
this.removeProject(externalProject);
}
}
}
openExternalProjects(projects: protocol.ExternalProject[]): void {
// record project list before the update
const projectsToClose = arrayToMap(this.externalProjects, p => p.getProjectName(), _ => true);
forEachKey(this.externalProjectToConfiguredProjectMap, externalProjectName => {
projectsToClose.set(externalProjectName, true);
});
for (const externalProject of projects) {
this.openExternalProject(externalProject);
// delete project that is present in input list
projectsToClose.delete(externalProject.projectFileName);
}
// close projects that were missing in the input list
forEachKey(projectsToClose, externalProjectName => {
this.closeExternalProject(externalProjectName);
});
}
/** Makes a filename safe to insert in a RegExp */
private static readonly filenameEscapeRegexp = /[-\/\\^$*+?.()|[\]{}]/g;
private static escapeFilenameForRegex(filename: string) {
return filename.replace(this.filenameEscapeRegexp, "\\$&");
}
resetSafeList(): void {
this.safelist = defaultTypeSafeList;
}
applySafeList(proj: protocol.ExternalProject): NormalizedPath[] {
const { rootFiles } = proj;
const typeAcquisition = proj.typeAcquisition!;
Debug.assert(!!typeAcquisition, "proj.typeAcquisition should be set by now");
if (typeAcquisition.enable === false || typeAcquisition.disableFilenameBasedTypeAcquisition) {
return [];
}
const typeAcqInclude = typeAcquisition.include || (typeAcquisition.include = []);
const excludeRules: string[] = [];
const normalizedNames = rootFiles.map(f => normalizeSlashes(f.fileName)) as NormalizedPath[];
const excludedFiles: NormalizedPath[] = [];
for (const name of Object.keys(this.safelist)) {
const rule = this.safelist[name];
for (const root of normalizedNames) {
if (rule.match.test(root)) {
this.logger.info(`Excluding files based on rule ${name} matching file '${root}'`);
// If the file matches, collect its types packages and exclude rules
if (rule.types) {
for (const type of rule.types) {
// Best-effort de-duping here - doesn't need to be unduplicated but
// we don't want the list to become a 400-element array of just 'kendo'
if (typeAcqInclude.indexOf(type) < 0) {
typeAcqInclude.push(type);
}
}
}
if (rule.exclude) {
for (const exclude of rule.exclude) {
const processedRule = root.replace(rule.match, (...groups: string[]) => {
return exclude.map(groupNumberOrString => {
// 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 (!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
return "\\*";
}
return ProjectService.escapeFilenameForRegex(groups[groupNumberOrString]);
}
return groupNumberOrString;
}).join("");
});
if (excludeRules.indexOf(processedRule) === -1) {
excludeRules.push(processedRule);
}
}
}
else {
// If not rules listed, add the default rule to exclude the matched file
const escaped = ProjectService.escapeFilenameForRegex(root);
if (excludeRules.indexOf(escaped) < 0) {
excludeRules.push(escaped);
}
}
}
}
}
const excludeRegexes = excludeRules.map(e => new RegExp(e, "i"));
const filesToKeep: protocol.ExternalFile[] = [];
for (let i = 0; i < proj.rootFiles.length; i++) {
if (excludeRegexes.some(re => re.test(normalizedNames[i]))) {
excludedFiles.push(normalizedNames[i]);
}
else {
let exclude = false;
if (typeAcquisition.enable || typeAcquisition.enableAutoDiscovery) {
const baseName = getBaseFileName(toFileNameLowerCase(normalizedNames[i]));
if (fileExtensionIs(baseName, "js")) {
const inferredTypingName = removeFileExtension(baseName);
const cleanedTypingName = removeMinAndVersionNumbers(inferredTypingName);
const typeName = this.legacySafelist.get(cleanedTypingName);
if (typeName !== undefined) {
this.logger.info(`Excluded '${normalizedNames[i]}' because it matched ${cleanedTypingName} from the legacy safelist`);
excludedFiles.push(normalizedNames[i]);
// *exclude* it from the project...
exclude = true;
// ... but *include* it in the list of types to acquire
// Same best-effort dedupe as above
if (typeAcqInclude.indexOf(typeName) < 0) {
typeAcqInclude.push(typeName);
}
}
}
}
if (!exclude) {
// Exclude any minified files that get this far
if (/^.+[\.-]min\.js$/.test(normalizedNames[i])) {
excludedFiles.push(normalizedNames[i]);
}
else {
filesToKeep.push(proj.rootFiles[i]);
}
}
}
}
proj.rootFiles = filesToKeep;
return excludedFiles;
}
openExternalProject(proj: protocol.ExternalProject): void {
// typingOptions has been deprecated and is only supported for backward compatibility
// purposes. It should be removed in future releases - use typeAcquisition instead.
if (proj.typingOptions && !proj.typeAcquisition) {
const typeAcquisition = convertEnableAutoDiscoveryToEnable(proj.typingOptions);
proj.typeAcquisition = typeAcquisition;
}
proj.typeAcquisition = proj.typeAcquisition || {};
proj.typeAcquisition.include = proj.typeAcquisition.include || [];
proj.typeAcquisition.exclude = proj.typeAcquisition.exclude || [];
if (proj.typeAcquisition.enable === undefined) {
proj.typeAcquisition.enable = hasNoTypeScriptSource(proj.rootFiles.map(f => f.fileName));
}
const excludedFiles = this.applySafeList(proj);
let tsConfigFiles: NormalizedPath[] | undefined;
const rootFiles: protocol.ExternalFile[] = [];
for (const file of proj.rootFiles) {
const normalized = toNormalizedPath(file.fileName);
if (getBaseConfigFileName(normalized)) {
if (this.serverMode === LanguageServiceMode.Semantic && this.host.fileExists(normalized)) {
(tsConfigFiles || (tsConfigFiles = [])).push(normalized);
}
}
else {
rootFiles.push(file);
}
}
// sort config files to simplify comparison later
if (tsConfigFiles) {
tsConfigFiles.sort();
}
const externalProject = this.findExternalProjectByProjectName(proj.projectFileName);
let exisingConfigFiles: string[] | undefined;
if (externalProject) {
externalProject.excludedFiles = excludedFiles;
if (!tsConfigFiles) {
const compilerOptions = convertCompilerOptions(proj.options);
const watchOptionsAndErrors = convertWatchOptions(proj.options, externalProject.getCurrentDirectory());
const lastFileExceededProgramSize = this.getFilenameForExceededTotalSizeLimitForNonTsFiles(proj.projectFileName, compilerOptions, proj.rootFiles, externalFilePropertyReader);
if (lastFileExceededProgramSize) {
externalProject.disableLanguageService(lastFileExceededProgramSize);
}
else {
externalProject.enableLanguageService();
}
externalProject.setProjectErrors(watchOptionsAndErrors?.errors);
// external project already exists and not config files were added - update the project and return;
// The graph update here isnt postponed since any file open operation needs all updated external projects
this.updateRootAndOptionsOfNonInferredProject(externalProject, proj.rootFiles, externalFilePropertyReader, compilerOptions, proj.typeAcquisition, proj.options.compileOnSave, watchOptionsAndErrors?.watchOptions);
externalProject.updateGraph();
return;
}
// some config files were added to external project (that previously were not there)
// close existing project and later we'll open a set of configured projects for these files
this.closeExternalProject(proj.projectFileName);
}
else if (this.externalProjectToConfiguredProjectMap.get(proj.projectFileName)) {
// this project used to include config files
if (!tsConfigFiles) {
// config files were removed from the project - close existing external project which in turn will close configured projects
this.closeExternalProject(proj.projectFileName);
}
else {
// project previously had some config files - compare them with new set of files and close all configured projects that correspond to unused files
const oldConfigFiles = this.externalProjectToConfiguredProjectMap.get(proj.projectFileName)!;
let iNew = 0;
let iOld = 0;
while (iNew < tsConfigFiles.length && iOld < oldConfigFiles.length) {
const newConfig = tsConfigFiles[iNew];
const oldConfig = oldConfigFiles[iOld];
if (oldConfig < newConfig) {
this.closeConfiguredProjectReferencedFromExternalProject(oldConfig);
iOld++;
}
else if (oldConfig > newConfig) {
iNew++;
}
else {
// record existing config files so avoid extra add-refs
(exisingConfigFiles || (exisingConfigFiles = [])).push(oldConfig);
iOld++;
iNew++;
}
}
for (let i = iOld; i < oldConfigFiles.length; i++) {
// projects for all remaining old config files should be closed
this.closeConfiguredProjectReferencedFromExternalProject(oldConfigFiles[i]);
}
}
}
if (tsConfigFiles) {
// store the list of tsconfig files that belong to the external project
this.externalProjectToConfiguredProjectMap.set(proj.projectFileName, tsConfigFiles);
for (const tsconfigFile of tsConfigFiles) {
let project = this.findConfiguredProjectByProjectName(tsconfigFile);
if (!project) {
// errors are stored in the project, do not need to update the graph
project = this.getHostPreferences().lazyConfiguredProjectsFromExternalProject ?
this.createConfiguredProjectWithDelayLoad(tsconfigFile, `Creating configured project in external project: ${proj.projectFileName}`) :
this.createLoadAndUpdateConfiguredProject(tsconfigFile, `Creating configured project in external project: ${proj.projectFileName}`);
}
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
project.addExternalProjectReference();
}
}
}
else {
// no config files - remove the item from the collection
// Create external project and update its graph, do not delay update since
// any file open operation needs all updated external projects
this.externalProjectToConfiguredProjectMap.delete(proj.projectFileName);
const project = this.createExternalProject(proj.projectFileName, rootFiles, proj.options, proj.typeAcquisition, excludedFiles);
project.updateGraph();
}
}
hasDeferredExtension() {
for (const extension of this.hostConfiguration.extraFileExtensions!) { // TODO: GH#18217
if (extension.scriptKind === ScriptKind.Deferred) {
return true;
}
}
return false;
}
configurePlugin(args: protocol.ConfigurePluginRequestArguments) {
// For any projects that already have the plugin loaded, configure the plugin
this.forEachEnabledProject(project => project.onPluginConfigurationChanged(args.pluginName, args.configuration));
// Also save the current configuration to pass on to any projects that are yet to be loaded.
// If a plugin is configured twice, only the latest configuration will be remembered.
this.currentPluginConfigOverrides = this.currentPluginConfigOverrides || new Map();
this.currentPluginConfigOverrides.set(args.pluginName, args.configuration);
}
/*@internal*/
getPackageJsonsVisibleToFile(fileName: string, rootDir?: string): readonly PackageJsonInfo[] {
const packageJsonCache = this.packageJsonCache;
const rootPath = rootDir && this.toPath(rootDir);
const filePath = this.toPath(fileName);
const result: PackageJsonInfo[] = [];
const processDirectory = (directory: Path): boolean | undefined => {
switch (packageJsonCache.directoryHasPackageJson(directory)) {
// Sync and check same directory again
case Ternary.Maybe:
packageJsonCache.searchDirectoryAndAncestors(directory);
return processDirectory(directory);
// Check package.json
case Ternary.True:
const packageJsonFileName = combinePaths(directory, "package.json");
this.watchPackageJsonFile(packageJsonFileName as Path);
const info = packageJsonCache.getInDirectory(directory);
if (info) result.push(info);
}
if (rootPath && rootPath === directory) {
return true;
}
};
forEachAncestorDirectory(getDirectoryPath(filePath), processDirectory);
return result;
}
/*@internal*/
getNearestAncestorDirectoryWithPackageJson(fileName: string): string | undefined {
return forEachAncestorDirectory(fileName, directory => {
switch (this.packageJsonCache.directoryHasPackageJson(this.toPath(directory))) {
case Ternary.True: return directory;
case Ternary.False: return undefined;
case Ternary.Maybe:
return this.host.fileExists(combinePaths(directory, "package.json"))
? directory
: undefined;
}
});
}
/*@internal*/
private watchPackageJsonFile(path: Path) {
const watchers = this.packageJsonFilesMap || (this.packageJsonFilesMap = new Map());
if (!watchers.has(path)) {
this.invalidateProjectAutoImports(path);
watchers.set(path, this.watchFactory.watchFile(
path,
(fileName, eventKind) => {
const path = this.toPath(fileName);
switch (eventKind) {
case FileWatcherEventKind.Created:
return Debug.fail();
case FileWatcherEventKind.Changed:
this.packageJsonCache.addOrUpdate(path);
this.invalidateProjectAutoImports(path);
break;
case FileWatcherEventKind.Deleted:
this.packageJsonCache.delete(path);
this.invalidateProjectAutoImports(path);
watchers.get(path)!.close();
watchers.delete(path);
}
},
PollingInterval.Low,
this.hostConfiguration.watchOptions,
WatchType.PackageJsonFile,
));
}
}
/*@internal*/
private onAddPackageJson(path: Path) {
this.packageJsonCache.addOrUpdate(path);
this.watchPackageJsonFile(path);
}
/*@internal*/
includePackageJsonAutoImports(): PackageJsonAutoImportPreference {
switch (this.hostConfiguration.preferences.includePackageJsonAutoImports) {
case "on": return PackageJsonAutoImportPreference.On;
case "off": return PackageJsonAutoImportPreference.Off;
default: return PackageJsonAutoImportPreference.Auto;
}
}
/*@internal*/
private invalidateProjectAutoImports(packageJsonPath: Path | undefined) {
if (this.includePackageJsonAutoImports()) {
this.configuredProjects.forEach(invalidate);
this.inferredProjects.forEach(invalidate);
this.externalProjects.forEach(invalidate);
}
function invalidate(project: Project) {
if (!packageJsonPath || project.packageJsonsForAutoImport?.has(packageJsonPath)) {
project.markAutoImportProviderAsDirty();
}
}
}
}
/* @internal */
export type ScriptInfoOrConfig = ScriptInfo | TsConfigSourceFile;
/* @internal */
export function isConfigFile(config: ScriptInfoOrConfig): config is TsConfigSourceFile {
return (config as TsConfigSourceFile).kind !== undefined;
}
function printProjectWithoutFileNames(project: Project) {
project.print(/*writeProjectFileNames*/ false);
}
}