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