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 dependencies?: MapLike<string>; 13 devDependencies?: MapLike<string>; 14 name?: string; 15 optionalDependencies?: MapLike<string>; 16 peerDependencies?: MapLike<string>; 17 types?: string; 18 typings?: string; 19 } 20 21 export interface CachedTyping { 22 typingLocation: string; 23 version: Version; 24 } 25 26 export function isTypingUpToDate(cachedTyping: CachedTyping, availableTypingVersions: MapLike<string>) { 27 const availableVersion = new Version(getProperty(availableTypingVersions, `ts${versionMajorMinor}`) || getProperty(availableTypingVersions, "latest")!); 28 return availableVersion.compareTo(cachedTyping.version) <= 0; 29 } 30 31 const unprefixedNodeCoreModuleList = [ 32 "assert", 33 "assert/strict", 34 "async_hooks", 35 "buffer", 36 "child_process", 37 "cluster", 38 "console", 39 "constants", 40 "crypto", 41 "dgram", 42 "diagnostics_channel", 43 "dns", 44 "dns/promises", 45 "domain", 46 "events", 47 "fs", 48 "fs/promises", 49 "http", 50 "https", 51 "http2", 52 "inspector", 53 "module", 54 "net", 55 "os", 56 "path", 57 "perf_hooks", 58 "process", 59 "punycode", 60 "querystring", 61 "readline", 62 "repl", 63 "stream", 64 "stream/promises", 65 "string_decoder", 66 "timers", 67 "timers/promises", 68 "tls", 69 "trace_events", 70 "tty", 71 "url", 72 "util", 73 "util/types", 74 "v8", 75 "vm", 76 "wasi", 77 "worker_threads", 78 "zlib" 79 ]; 80 81 export const prefixedNodeCoreModuleList = unprefixedNodeCoreModuleList.map(name => `node:${name}`); 82 83 export const nodeCoreModuleList: readonly string[] = [...unprefixedNodeCoreModuleList, ...prefixedNodeCoreModuleList]; 84 85 export const nodeCoreModules = new Set(nodeCoreModuleList); 86 87 export function nonRelativeModuleNameForTypingCache(moduleName: string) { 88 return nodeCoreModules.has(moduleName) ? "node" : moduleName; 89 } 90 91 /** 92 * A map of loose file names to library names that we are confident require typings 93 */ 94 export type SafeList = ReadonlyESMap<string, string>; 95 96 export function loadSafeList(host: TypingResolutionHost, safeListPath: Path): SafeList { 97 const result = readConfigFile(safeListPath, path => host.readFile(path)); 98 return new Map(getEntries<string>(result.config)); 99 } 100 101 export function loadTypesMap(host: TypingResolutionHost, typesMapPath: Path): SafeList | undefined { 102 const result = readConfigFile(typesMapPath, path => host.readFile(path)); 103 if (result.config) { 104 return new Map(getEntries<string>(result.config.simpleMap)); 105 } 106 return undefined; 107 } 108 109 /** 110 * @param host is the object providing I/O related operations. 111 * @param fileNames are the file names that belong to the same project 112 * @param projectRootPath is the path to the project root directory 113 * @param safeListPath is the path used to retrieve the safe list 114 * @param packageNameToTypingLocation is the map of package names to their cached typing locations and installed versions 115 * @param typeAcquisition is used to customize the typing acquisition process 116 * @param compilerOptions are used as a source for typing inference 117 */ 118 export function discoverTypings( 119 host: TypingResolutionHost, 120 log: ((message: string) => void) | undefined, 121 fileNames: string[], 122 projectRootPath: Path, 123 safeList: SafeList, 124 packageNameToTypingLocation: ReadonlyESMap<string, CachedTyping>, 125 typeAcquisition: TypeAcquisition, 126 unresolvedImports: readonly string[], 127 typesRegistry: ReadonlyESMap<string, MapLike<string>>, 128 compilerOptions: CompilerOptions): 129 { cachedTypingPaths: string[], newTypingNames: string[], filesToWatch: string[] } { 130 131 if (!typeAcquisition || !typeAcquisition.enable) { 132 return { cachedTypingPaths: [], newTypingNames: [], filesToWatch: [] }; 133 } 134 135 // A typing name to typing file path mapping 136 const inferredTypings = new Map<string, string>(); 137 138 // Only infer typings for .js and .jsx files 139 fileNames = mapDefined(fileNames, fileName => { 140 const path = normalizePath(fileName); 141 if (hasJSFileExtension(path)) { 142 return path; 143 } 144 }); 145 146 const filesToWatch: string[] = []; 147 148 if (typeAcquisition.include) addInferredTypings(typeAcquisition.include, "Explicitly included types"); 149 const exclude = typeAcquisition.exclude || []; 150 151 // Directories to search for package.json, bower.json and other typing information 152 if (!compilerOptions.types) { 153 const possibleSearchDirs = new Set(fileNames.map(getDirectoryPath)); 154 possibleSearchDirs.add(projectRootPath); 155 possibleSearchDirs.forEach((searchDir) => { 156 getTypingNames(searchDir, "bower.json", "bower_components", filesToWatch); 157 getTypingNames(searchDir, "package.json", "node_modules", filesToWatch); 158 getTypingNames(searchDir, "oh-package.json5", "oh_modules", filesToWatch); 159 }); 160 } 161 162 if(!typeAcquisition.disableFilenameBasedTypeAcquisition) { 163 getTypingNamesFromSourceFileNames(fileNames); 164 } 165 // add typings for unresolved imports 166 if (unresolvedImports) { 167 const module = deduplicate<string>( 168 unresolvedImports.map(nonRelativeModuleNameForTypingCache), 169 equateStringsCaseSensitive, 170 compareStringsCaseSensitive); 171 addInferredTypings(module, "Inferred typings from unresolved imports"); 172 } 173 // Add the cached typing locations for inferred typings that are already installed 174 packageNameToTypingLocation.forEach((typing, name) => { 175 const registryEntry = typesRegistry.get(name); 176 if (inferredTypings.has(name) && inferredTypings.get(name) === undefined && registryEntry !== undefined && isTypingUpToDate(typing, registryEntry)) { 177 inferredTypings.set(name, typing.typingLocation); 178 } 179 }); 180 181 // Remove typings that the user has added to the exclude list 182 for (const excludeTypingName of exclude) { 183 const didDelete = inferredTypings.delete(excludeTypingName); 184 if (didDelete && log) log(`Typing for ${excludeTypingName} is in exclude list, will be ignored.`); 185 } 186 187 const newTypingNames: string[] = []; 188 const cachedTypingPaths: string[] = []; 189 inferredTypings.forEach((inferred, typing) => { 190 if (inferred !== undefined) { 191 cachedTypingPaths.push(inferred); 192 } 193 else { 194 newTypingNames.push(typing); 195 } 196 }); 197 const result = { cachedTypingPaths, newTypingNames, filesToWatch }; 198 if (log) log(`Result: ${JSON.stringify(result)}`); 199 return result; 200 201 function addInferredTyping(typingName: string) { 202 if (!inferredTypings.has(typingName)) { 203 inferredTypings.set(typingName, undefined!); // TODO: GH#18217 204 } 205 } 206 function addInferredTypings(typingNames: readonly string[], message: string) { 207 if (log) log(`${message}: ${JSON.stringify(typingNames)}`); 208 forEach(typingNames, addInferredTyping); 209 } 210 211 /** 212 * Adds inferred typings from manifest/module pairs (think package.json + node_modules) 213 * 214 * @param projectRootPath is the path to the directory where to look for package.json, bower.json and other typing information 215 * @param manifestName is the name of the manifest (package.json or bower.json) 216 * @param modulesDirName is the directory name for modules (node_modules or bower_components). Should be lowercase! 217 * @param filesToWatch are the files to watch for changes. We will push things into this array. 218 */ 219 function getTypingNames(projectRootPath: string, manifestName: string, modulesDirName: string, filesToWatch: string[]): void { 220 // First, we check the manifests themselves. They're not 221 // _required_, but they allow us to do some filtering when dealing 222 // with big flat dep directories. 223 const manifestPath = combinePaths(projectRootPath, manifestName); 224 let manifest; 225 let manifestTypingNames; 226 if (host.fileExists(manifestPath)) { 227 filesToWatch.push(manifestPath); 228 manifest = readConfigFile(manifestPath, path => host.readFile(path)).config; 229 manifestTypingNames = flatMap([manifest.dependencies, manifest.devDependencies, manifest.optionalDependencies, manifest.peerDependencies], getOwnKeys); 230 addInferredTypings(manifestTypingNames, `Typing names in '${manifestPath}' dependencies`); 231 } 232 233 // Now we scan the directories for typing information in 234 // already-installed dependencies (if present). Note that this 235 // step happens regardless of whether a manifest was present, 236 // which is certainly a valid configuration, if an unusual one. 237 const packagesFolderPath = combinePaths(projectRootPath, modulesDirName); 238 filesToWatch.push(packagesFolderPath); 239 if (!host.directoryExists(packagesFolderPath)) { 240 return; 241 } 242 243 // There's two cases we have to take into account here: 244 // 1. If manifest is undefined, then we're not using a manifest. 245 // That means that we should scan _all_ dependencies at the top 246 // level of the modulesDir. 247 // 2. If manifest is defined, then we can do some special 248 // filtering to reduce the amount of scanning we need to do. 249 // 250 // Previous versions of this algorithm checked for a `_requiredBy` 251 // field in the package.json, but that field is only present in 252 // `npm@>=3 <7`. 253 254 // Package names that do **not** provide their own typings, so 255 // we'll look them up. 256 const packageNames: string[] = []; 257 258 const dependencyManifestNames = manifestTypingNames 259 // This is #1 described above. 260 ? manifestTypingNames.map(typingName => combinePaths(packagesFolderPath, typingName, manifestName)) 261 // And #2. Depth = 3 because scoped packages look like `node_modules/@foo/bar/package.json` 262 : host.readDirectory(packagesFolderPath, [Extension.Json], /*excludes*/ undefined, /*includes*/ undefined, /*depth*/ 3) 263 .filter(manifestPath => { 264 if (getBaseFileName(manifestPath) !== manifestName) { 265 return false; 266 } 267 // It's ok to treat 268 // `node_modules/@foo/bar/package.json` as a manifest, 269 // but not `node_modules/jquery/nested/package.json`. 270 // We only assume depth 3 is ok for formally scoped 271 // packages. So that needs this dance here. 272 const pathComponents = getPathComponents(normalizePath(manifestPath)); 273 const isScoped = pathComponents[pathComponents.length - 3][0] === "@"; 274 return isScoped && pathComponents[pathComponents.length - 4].toLowerCase() === modulesDirName || // `node_modules/@foo/bar` 275 !isScoped && pathComponents[pathComponents.length - 3].toLowerCase() === modulesDirName; // `node_modules/foo` 276 }); 277 278 if (log) log(`Searching for typing names in ${packagesFolderPath}; all files: ${JSON.stringify(dependencyManifestNames)}`); 279 280 // Once we have the names of things to look up, we iterate over 281 // and either collect their included typings, or add them to the 282 // list of typings we need to look up separately. 283 for (const manifestPath of dependencyManifestNames) { 284 const normalizedFileName = normalizePath(manifestPath); 285 const result = readConfigFile(normalizedFileName, (path: string) => host.readFile(path)); 286 const manifest: PackageJson = result.config; 287 288 // If the package has its own d.ts typings, those will take precedence. Otherwise the package name will be used 289 // to download d.ts files from DefinitelyTyped 290 if (!manifest.name) { 291 continue; 292 } 293 const ownTypes = manifest.types || manifest.typings; 294 if (ownTypes) { 295 const absolutePath = getNormalizedAbsolutePath(ownTypes, getDirectoryPath(normalizedFileName)); 296 if (host.fileExists(absolutePath)) { 297 if (log) log(` Package '${manifest.name}' provides its own types.`); 298 inferredTypings.set(manifest.name, absolutePath); 299 } 300 else { 301 if (log) log(` Package '${manifest.name}' provides its own types but they are missing.`); 302 } 303 } 304 else { 305 packageNames.push(manifest.name); 306 } 307 } 308 309 addInferredTypings(packageNames, " Found package names"); 310 } 311 312 /** 313 * Infer typing names from given file names. For example, the file name "jquery-min.2.3.4.js" 314 * should be inferred to the 'jquery' typing name; and "angular-route.1.2.3.js" should be inferred 315 * to the 'angular-route' typing name. 316 * @param fileNames are the names for source files in the project 317 */ 318 function getTypingNamesFromSourceFileNames(fileNames: string[]) { 319 const fromFileNames = mapDefined(fileNames, j => { 320 if (!hasJSFileExtension(j)) return undefined; 321 322 const inferredTypingName = removeFileExtension(getBaseFileName(j.toLowerCase())); 323 const cleanedTypingName = removeMinAndVersionNumbers(inferredTypingName); 324 return safeList.get(cleanedTypingName); 325 }); 326 if (fromFileNames.length) { 327 addInferredTypings(fromFileNames, "Inferred typings from file names"); 328 } 329 330 const hasJsxFile = some(fileNames, f => fileExtensionIs(f, Extension.Jsx)); 331 if (hasJsxFile) { 332 if (log) log(`Inferred 'react' typings due to presence of '.jsx' extension`); 333 addInferredTyping("react"); 334 } 335 } 336 } 337 338 export const enum NameValidationResult { 339 Ok, 340 EmptyName, 341 NameTooLong, 342 NameStartsWithDot, 343 NameStartsWithUnderscore, 344 NameContainsNonURISafeCharacters 345 } 346 347 const maxPackageNameLength = 214; 348 349 export interface ScopedPackageNameValidationResult { 350 name: string; 351 isScopeName: boolean; 352 result: NameValidationResult; 353 } 354 export type PackageNameValidationResult = NameValidationResult | ScopedPackageNameValidationResult; 355 356 /** 357 * Validates package name using rules defined at https://docs.npmjs.com/files/package.json 358 */ 359 export function validatePackageName(packageName: string): PackageNameValidationResult { 360 return validatePackageNameWorker(packageName, /*supportScopedPackage*/ true); 361 } 362 363 function validatePackageNameWorker(packageName: string, supportScopedPackage: false): NameValidationResult; 364 function validatePackageNameWorker(packageName: string, supportScopedPackage: true): PackageNameValidationResult; 365 function validatePackageNameWorker(packageName: string, supportScopedPackage: boolean): PackageNameValidationResult { 366 if (!packageName) { 367 return NameValidationResult.EmptyName; 368 } 369 if (packageName.length > maxPackageNameLength) { 370 return NameValidationResult.NameTooLong; 371 } 372 if (packageName.charCodeAt(0) === CharacterCodes.dot) { 373 return NameValidationResult.NameStartsWithDot; 374 } 375 if (packageName.charCodeAt(0) === CharacterCodes._) { 376 return NameValidationResult.NameStartsWithUnderscore; 377 } 378 // check if name is scope package like: starts with @ and has one '/' in the middle 379 // scoped packages are not currently supported 380 if (supportScopedPackage) { 381 const matches = /^@([^/]+)\/([^/]+)$/.exec(packageName); 382 if (matches) { 383 const scopeResult = validatePackageNameWorker(matches[1], /*supportScopedPackage*/ false); 384 if (scopeResult !== NameValidationResult.Ok) { 385 return { name: matches[1], isScopeName: true, result: scopeResult }; 386 } 387 const packageResult = validatePackageNameWorker(matches[2], /*supportScopedPackage*/ false); 388 if (packageResult !== NameValidationResult.Ok) { 389 return { name: matches[2], isScopeName: false, result: packageResult }; 390 } 391 return NameValidationResult.Ok; 392 } 393 } 394 if (encodeURIComponent(packageName) !== packageName) { 395 return NameValidationResult.NameContainsNonURISafeCharacters; 396 } 397 return NameValidationResult.Ok; 398 } 399 400 export function renderPackageNameValidationFailure(result: PackageNameValidationResult, typing: string): string { 401 return typeof result === "object" ? 402 renderPackageNameValidationFailureWorker(typing, result.result, result.name, result.isScopeName) : 403 renderPackageNameValidationFailureWorker(typing, result, typing, /*isScopeName*/ false); 404 } 405 406 function renderPackageNameValidationFailureWorker(typing: string, result: NameValidationResult, name: string, isScopeName: boolean): string { 407 const kind = isScopeName ? "Scope" : "Package"; 408 switch (result) { 409 case NameValidationResult.EmptyName: 410 return `'${typing}':: ${kind} name '${name}' cannot be empty`; 411 case NameValidationResult.NameTooLong: 412 return `'${typing}':: ${kind} name '${name}' should be less than ${maxPackageNameLength} characters`; 413 case NameValidationResult.NameStartsWithDot: 414 return `'${typing}':: ${kind} name '${name}' cannot start with '.'`; 415 case NameValidationResult.NameStartsWithUnderscore: 416 return `'${typing}':: ${kind} name '${name}' cannot start with '_'`; 417 case NameValidationResult.NameContainsNonURISafeCharacters: 418 return `'${typing}':: ${kind} name '${name}' contains non URI safe characters`; 419 case NameValidationResult.Ok: 420 return Debug.fail(); // Shouldn't have called this. 421 default: 422 throw Debug.assertNever(result); 423 } 424 } 425} 426