• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
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