1/* @internal */ 2namespace ts.JsTyping { 3 4 export interface TypingResolutionHost { 5 directoryExists(path: string): boolean; 6 fileExists(fileName: string): boolean; 7 readFile(path: string, encoding?: string): string | undefined; 8 readDirectory(rootDir: string, extensions: readonly string[], excludes: readonly string[] | undefined, includes: readonly string[] | undefined, depth?: number): string[]; 9 } 10 11 interface PackageJson { 12 _requiredBy?: string[]; 13 dependencies?: MapLike<string>; 14 devDependencies?: MapLike<string>; 15 name?: string; 16 optionalDependencies?: MapLike<string>; 17 peerDependencies?: MapLike<string>; 18 types?: string; 19 typings?: string; 20 } 21 22 export interface CachedTyping { 23 typingLocation: string; 24 version: Version; 25 } 26 27 export function isTypingUpToDate(cachedTyping: CachedTyping, availableTypingVersions: MapLike<string>) { 28 const availableVersion = new Version(getProperty(availableTypingVersions, `ts${versionMajorMinor}`) || getProperty(availableTypingVersions, "latest")!); 29 return availableVersion.compareTo(cachedTyping.version) <= 0; 30 } 31 32 export const nodeCoreModuleList: readonly string[] = [ 33 "assert", 34 "async_hooks", 35 "buffer", 36 "child_process", 37 "cluster", 38 "console", 39 "constants", 40 "crypto", 41 "dgram", 42 "dns", 43 "domain", 44 "events", 45 "fs", 46 "http", 47 "https", 48 "http2", 49 "inspector", 50 "net", 51 "os", 52 "path", 53 "perf_hooks", 54 "process", 55 "punycode", 56 "querystring", 57 "readline", 58 "repl", 59 "stream", 60 "string_decoder", 61 "timers", 62 "tls", 63 "tty", 64 "url", 65 "util", 66 "v8", 67 "vm", 68 "zlib" 69 ]; 70 71 export const nodeCoreModules = new Set(nodeCoreModuleList); 72 73 export function nonRelativeModuleNameForTypingCache(moduleName: string) { 74 return nodeCoreModules.has(moduleName) ? "node" : moduleName; 75 } 76 77 /** 78 * A map of loose file names to library names that we are confident require typings 79 */ 80 export type SafeList = ReadonlyESMap<string, string>; 81 82 export function loadSafeList(host: TypingResolutionHost, safeListPath: Path): SafeList { 83 const result = readConfigFile(safeListPath, path => host.readFile(path)); 84 return new Map(getEntries<string>(result.config)); 85 } 86 87 export function loadTypesMap(host: TypingResolutionHost, typesMapPath: Path): SafeList | undefined { 88 const result = readConfigFile(typesMapPath, path => host.readFile(path)); 89 if (result.config) { 90 return new Map(getEntries<string>(result.config.simpleMap)); 91 } 92 return undefined; 93 } 94 95 /** 96 * @param host is the object providing I/O related operations. 97 * @param fileNames are the file names that belong to the same project 98 * @param projectRootPath is the path to the project root directory 99 * @param safeListPath is the path used to retrieve the safe list 100 * @param packageNameToTypingLocation is the map of package names to their cached typing locations and installed versions 101 * @param typeAcquisition is used to customize the typing acquisition process 102 * @param compilerOptions are used as a source for typing inference 103 */ 104 export function discoverTypings( 105 host: TypingResolutionHost, 106 log: ((message: string) => void) | undefined, 107 fileNames: string[], 108 projectRootPath: Path, 109 safeList: SafeList, 110 packageNameToTypingLocation: ReadonlyESMap<string, CachedTyping>, 111 typeAcquisition: TypeAcquisition, 112 unresolvedImports: readonly string[], 113 typesRegistry: ReadonlyESMap<string, MapLike<string>>): 114 { cachedTypingPaths: string[], newTypingNames: string[], filesToWatch: string[] } { 115 116 if (!typeAcquisition || !typeAcquisition.enable) { 117 return { cachedTypingPaths: [], newTypingNames: [], filesToWatch: [] }; 118 } 119 120 // A typing name to typing file path mapping 121 const inferredTypings = new Map<string, string>(); 122 123 // Only infer typings for .js and .jsx files 124 fileNames = mapDefined(fileNames, fileName => { 125 const path = normalizePath(fileName); 126 if (hasJSFileExtension(path)) { 127 return path; 128 } 129 }); 130 131 const filesToWatch: string[] = []; 132 133 if (typeAcquisition.include) addInferredTypings(typeAcquisition.include, "Explicitly included types"); 134 const exclude = typeAcquisition.exclude || []; 135 136 // Directories to search for package.json, bower.json and other typing information 137 const possibleSearchDirs = new Set(fileNames.map(getDirectoryPath)); 138 possibleSearchDirs.add(projectRootPath); 139 possibleSearchDirs.forEach((searchDir) => { 140 const packageJsonPath = combinePaths(searchDir, "package.json"); 141 getTypingNamesFromJson(packageJsonPath, filesToWatch); 142 143 const bowerJsonPath = combinePaths(searchDir, "bower.json"); 144 getTypingNamesFromJson(bowerJsonPath, filesToWatch); 145 146 const bowerComponentsPath = combinePaths(searchDir, "bower_components"); 147 getTypingNamesFromPackagesFolder(bowerComponentsPath, filesToWatch); 148 149 const nodeModulesPath = combinePaths(searchDir, "node_modules"); 150 getTypingNamesFromPackagesFolder(nodeModulesPath, filesToWatch); 151 }); 152 if(!typeAcquisition.disableFilenameBasedTypeAcquisition) { 153 getTypingNamesFromSourceFileNames(fileNames); 154 } 155 // add typings for unresolved imports 156 if (unresolvedImports) { 157 const module = deduplicate<string>( 158 unresolvedImports.map(nonRelativeModuleNameForTypingCache), 159 equateStringsCaseSensitive, 160 compareStringsCaseSensitive); 161 addInferredTypings(module, "Inferred typings from unresolved imports"); 162 } 163 // Add the cached typing locations for inferred typings that are already installed 164 packageNameToTypingLocation.forEach((typing, name) => { 165 const registryEntry = typesRegistry.get(name); 166 if (inferredTypings.has(name) && inferredTypings.get(name) === undefined && registryEntry !== undefined && isTypingUpToDate(typing, registryEntry)) { 167 inferredTypings.set(name, typing.typingLocation); 168 } 169 }); 170 171 // Remove typings that the user has added to the exclude list 172 for (const excludeTypingName of exclude) { 173 const didDelete = inferredTypings.delete(excludeTypingName); 174 if (didDelete && log) log(`Typing for ${excludeTypingName} is in exclude list, will be ignored.`); 175 } 176 177 const newTypingNames: string[] = []; 178 const cachedTypingPaths: string[] = []; 179 inferredTypings.forEach((inferred, typing) => { 180 if (inferred !== undefined) { 181 cachedTypingPaths.push(inferred); 182 } 183 else { 184 newTypingNames.push(typing); 185 } 186 }); 187 const result = { cachedTypingPaths, newTypingNames, filesToWatch }; 188 if (log) log(`Result: ${JSON.stringify(result)}`); 189 return result; 190 191 function addInferredTyping(typingName: string) { 192 if (!inferredTypings.has(typingName)) { 193 inferredTypings.set(typingName, undefined!); // TODO: GH#18217 194 } 195 } 196 function addInferredTypings(typingNames: readonly string[], message: string) { 197 if (log) log(`${message}: ${JSON.stringify(typingNames)}`); 198 forEach(typingNames, addInferredTyping); 199 } 200 201 /** 202 * Get the typing info from common package manager json files like package.json or bower.json 203 */ 204 function getTypingNamesFromJson(jsonPath: string, filesToWatch: Push<string>) { 205 if (!host.fileExists(jsonPath)) { 206 return; 207 } 208 209 filesToWatch.push(jsonPath); 210 const jsonConfig: PackageJson = readConfigFile(jsonPath, path => host.readFile(path)).config; 211 const jsonTypingNames = flatMap([jsonConfig.dependencies, jsonConfig.devDependencies, jsonConfig.optionalDependencies, jsonConfig.peerDependencies], getOwnKeys); 212 addInferredTypings(jsonTypingNames, `Typing names in '${jsonPath}' dependencies`); 213 } 214 215 /** 216 * Infer typing names from given file names. For example, the file name "jquery-min.2.3.4.js" 217 * should be inferred to the 'jquery' typing name; and "angular-route.1.2.3.js" should be inferred 218 * to the 'angular-route' typing name. 219 * @param fileNames are the names for source files in the project 220 */ 221 function getTypingNamesFromSourceFileNames(fileNames: string[]) { 222 const fromFileNames = mapDefined(fileNames, j => { 223 if (!hasJSFileExtension(j)) return undefined; 224 225 const inferredTypingName = removeFileExtension(getBaseFileName(j.toLowerCase())); 226 const cleanedTypingName = removeMinAndVersionNumbers(inferredTypingName); 227 return safeList.get(cleanedTypingName); 228 }); 229 if (fromFileNames.length) { 230 addInferredTypings(fromFileNames, "Inferred typings from file names"); 231 } 232 233 const hasJsxFile = some(fileNames, f => fileExtensionIs(f, Extension.Jsx)); 234 if (hasJsxFile) { 235 if (log) log(`Inferred 'react' typings due to presence of '.jsx' extension`); 236 addInferredTyping("react"); 237 } 238 } 239 240 /** 241 * Infer typing names from packages folder (ex: node_module, bower_components) 242 * @param packagesFolderPath is the path to the packages folder 243 */ 244 function getTypingNamesFromPackagesFolder(packagesFolderPath: string, filesToWatch: Push<string>) { 245 filesToWatch.push(packagesFolderPath); 246 247 // Todo: add support for ModuleResolutionHost too 248 if (!host.directoryExists(packagesFolderPath)) { 249 return; 250 } 251 252 // depth of 2, so we access `node_modules/foo` but not `node_modules/foo/bar` 253 const fileNames = host.readDirectory(packagesFolderPath, [Extension.Json], /*excludes*/ undefined, /*includes*/ undefined, /*depth*/ 2); 254 if (log) log(`Searching for typing names in ${packagesFolderPath}; all files: ${JSON.stringify(fileNames)}`); 255 const packageNames: string[] = []; 256 for (const fileName of fileNames) { 257 const normalizedFileName = normalizePath(fileName); 258 const baseFileName = getBaseFileName(normalizedFileName); 259 if (baseFileName !== "package.json" && baseFileName !== "bower.json") { 260 continue; 261 } 262 const result = readConfigFile(normalizedFileName, (path: string) => host.readFile(path)); 263 const packageJson: PackageJson = result.config; 264 265 // npm 3's package.json contains a "_requiredBy" field 266 // we should include all the top level module names for npm 2, and only module names whose 267 // "_requiredBy" field starts with "#" or equals "/" for npm 3. 268 if (baseFileName === "package.json" && packageJson._requiredBy && 269 filter(packageJson._requiredBy, (r: string) => r[0] === "#" || r === "/").length === 0) { 270 continue; 271 } 272 273 // If the package has its own d.ts typings, those will take precedence. Otherwise the package name will be used 274 // to download d.ts files from DefinitelyTyped 275 if (!packageJson.name) { 276 continue; 277 } 278 const ownTypes = packageJson.types || packageJson.typings; 279 if (ownTypes) { 280 const absolutePath = getNormalizedAbsolutePath(ownTypes, getDirectoryPath(normalizedFileName)); 281 if (log) log(` Package '${packageJson.name}' provides its own types.`); 282 inferredTypings.set(packageJson.name, absolutePath); 283 } 284 else { 285 packageNames.push(packageJson.name); 286 } 287 } 288 addInferredTypings(packageNames, " Found package names"); 289 } 290 291 } 292 293 export const enum NameValidationResult { 294 Ok, 295 EmptyName, 296 NameTooLong, 297 NameStartsWithDot, 298 NameStartsWithUnderscore, 299 NameContainsNonURISafeCharacters 300 } 301 302 const maxPackageNameLength = 214; 303 304 export interface ScopedPackageNameValidationResult { 305 name: string; 306 isScopeName: boolean; 307 result: NameValidationResult; 308 } 309 export type PackageNameValidationResult = NameValidationResult | ScopedPackageNameValidationResult; 310 311 /** 312 * Validates package name using rules defined at https://docs.npmjs.com/files/package.json 313 */ 314 export function validatePackageName(packageName: string): PackageNameValidationResult { 315 return validatePackageNameWorker(packageName, /*supportScopedPackage*/ true); 316 } 317 318 function validatePackageNameWorker(packageName: string, supportScopedPackage: false): NameValidationResult; 319 function validatePackageNameWorker(packageName: string, supportScopedPackage: true): PackageNameValidationResult; 320 function validatePackageNameWorker(packageName: string, supportScopedPackage: boolean): PackageNameValidationResult { 321 if (!packageName) { 322 return NameValidationResult.EmptyName; 323 } 324 if (packageName.length > maxPackageNameLength) { 325 return NameValidationResult.NameTooLong; 326 } 327 if (packageName.charCodeAt(0) === CharacterCodes.dot) { 328 return NameValidationResult.NameStartsWithDot; 329 } 330 if (packageName.charCodeAt(0) === CharacterCodes._) { 331 return NameValidationResult.NameStartsWithUnderscore; 332 } 333 // check if name is scope package like: starts with @ and has one '/' in the middle 334 // scoped packages are not currently supported 335 if (supportScopedPackage) { 336 const matches = /^@([^/]+)\/([^/]+)$/.exec(packageName); 337 if (matches) { 338 const scopeResult = validatePackageNameWorker(matches[1], /*supportScopedPackage*/ false); 339 if (scopeResult !== NameValidationResult.Ok) { 340 return { name: matches[1], isScopeName: true, result: scopeResult }; 341 } 342 const packageResult = validatePackageNameWorker(matches[2], /*supportScopedPackage*/ false); 343 if (packageResult !== NameValidationResult.Ok) { 344 return { name: matches[2], isScopeName: false, result: packageResult }; 345 } 346 return NameValidationResult.Ok; 347 } 348 } 349 if (encodeURIComponent(packageName) !== packageName) { 350 return NameValidationResult.NameContainsNonURISafeCharacters; 351 } 352 return NameValidationResult.Ok; 353 } 354 355 export function renderPackageNameValidationFailure(result: PackageNameValidationResult, typing: string): string { 356 return typeof result === "object" ? 357 renderPackageNameValidationFailureWorker(typing, result.result, result.name, result.isScopeName) : 358 renderPackageNameValidationFailureWorker(typing, result, typing, /*isScopeName*/ false); 359 } 360 361 function renderPackageNameValidationFailureWorker(typing: string, result: NameValidationResult, name: string, isScopeName: boolean): string { 362 const kind = isScopeName ? "Scope" : "Package"; 363 switch (result) { 364 case NameValidationResult.EmptyName: 365 return `'${typing}':: ${kind} name '${name}' cannot be empty`; 366 case NameValidationResult.NameTooLong: 367 return `'${typing}':: ${kind} name '${name}' should be less than ${maxPackageNameLength} characters`; 368 case NameValidationResult.NameStartsWithDot: 369 return `'${typing}':: ${kind} name '${name}' cannot start with '.'`; 370 case NameValidationResult.NameStartsWithUnderscore: 371 return `'${typing}':: ${kind} name '${name}' cannot start with '_'`; 372 case NameValidationResult.NameContainsNonURISafeCharacters: 373 return `'${typing}':: ${kind} name '${name}' contains non URI safe characters`; 374 case NameValidationResult.Ok: 375 return Debug.fail(); // Shouldn't have called this. 376 default: 377 throw Debug.assertNever(result); 378 } 379 } 380} 381