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