TypeScript/src/services/jsTyping.ts
Jason Ramsay cc3d4d3291 Addressing CR comments
- Adding check to ensure TypingOptions 'include' and 'exclude' arrays are composed of strings
- Allow leading whitespace when removing comments from json
2016-03-08 15:17:09 -08:00

287 lines
13 KiB
TypeScript

// Copyright (c) Microsoft. All rights reserved. Licensed under the Apache License, Version 2.0.
// See LICENSE.txt in the project root for complete license information.
/// <reference path='services.ts' />
/* @internal */
namespace ts.JsTyping {
interface TypingResolutionHost {
directoryExists: (path: string) => boolean;
fileExists: (fileName: string) => boolean;
readFile: (path: string, encoding?: string) => string;
readDirectory: (path: string, extension?: string, exclude?: string[], depth?: number) => string[];
};
// A map of loose file names to library names
// that we are confident require typings
let safeList: Map<string>;
const notFoundTypingNames: string[] = [];
function tryParseJson(jsonPath: string, host: TypingResolutionHost): any {
if (host.fileExists(jsonPath)) {
try {
// Strip out single-line comments
const contents = host.readFile(jsonPath).replace(/^\s*\/\/(.*)$/gm, "");
return JSON.parse(contents);
}
catch (e) { }
}
return undefined;
}
function isTypingEnabled(options: TypingOptions): boolean {
if (options) {
if (options.enableAutoDiscovery ||
(options.include && options.include.length > 0) ||
(options.exclude && options.exclude.length > 0)) {
return true;
}
}
return false;
}
/**
* @param host is the object providing I/O related operations.
* @param fileNames are the file names that belong to the same project.
* @param globalCachePath is used to get the safe list file path and as cache path if the project root path isn't specified.
* @param projectRootPath is the path to the project root directory. This is used for the local typings cache.
* @param typingOptions are used for customizing the typing inference process.
* @param compilerOptions are used as a source of typing inference.
*/
export function discoverTypings(
host: TypingResolutionHost,
fileNames: string[],
globalCachePath: Path,
projectRootPath: Path,
typingOptions: TypingOptions,
compilerOptions: CompilerOptions):
{ cachedTypingPaths: string[], newTypingNames: string[], filesToWatch: string[] } {
// A typing name to typing file path mapping
const inferredTypings: Map<string> = {};
if (!isTypingEnabled(typingOptions)) {
return { cachedTypingPaths: [], newTypingNames: [], filesToWatch: [] };
}
const cachePath = projectRootPath || globalCachePath;
// Only infer typings for .js and .jsx files
fileNames = fileNames
.map(ts.normalizePath)
.filter(f => scriptKindIs(f, /*LanguageServiceHost*/ undefined, ScriptKind.JS, ScriptKind.JSX));
const safeListFilePath = ts.combinePaths(globalCachePath, "safeList.json");
if (!safeList && host.fileExists(safeListFilePath)) {
safeList = tryParseJson(safeListFilePath, host);
}
const filesToWatch: string[] = [];
// Directories to search for package.json, bower.json and other typing information
let searchDirs: string[] = [];
let exclude: string[] = [];
mergeTypings(typingOptions.include);
exclude = typingOptions.exclude || [];
if (typingOptions.enableAutoDiscovery) {
const possibleSearchDirs = fileNames.map(ts.getDirectoryPath);
if (projectRootPath !== undefined) {
possibleSearchDirs.push(projectRootPath);
}
searchDirs = ts.deduplicate(possibleSearchDirs);
for (const searchDir of searchDirs) {
const packageJsonPath = ts.combinePaths(searchDir, "package.json");
getTypingNamesFromJson(packageJsonPath, filesToWatch);
const bowerJsonPath = ts.combinePaths(searchDir, "bower.json");
getTypingNamesFromJson(bowerJsonPath, filesToWatch);
const nodeModulesPath = ts.combinePaths(searchDir, "node_modules");
getTypingNamesFromNodeModuleFolder(nodeModulesPath, filesToWatch);
}
getTypingNamesFromSourceFileNames(fileNames);
getTypingNamesFromCompilerOptions(compilerOptions);
}
const typingsPath = ts.combinePaths(cachePath, "typings");
const tsdJsonPath = ts.combinePaths(cachePath, "tsd.json");
const tsdJsonDict = tryParseJson(tsdJsonPath, host);
if (tsdJsonDict) {
for (const notFoundTypingName of notFoundTypingNames) {
if (inferredTypings.hasOwnProperty(notFoundTypingName) && !inferredTypings[notFoundTypingName]) {
delete inferredTypings[notFoundTypingName];
}
}
// The "installed" property in the tsd.json serves as a registry of installed typings. Each item
// of this object has a key of the relative file path, and a value that contains the corresponding
// commit hash.
if (hasProperty(tsdJsonDict, "installed")) {
for (const cachedTypingPath in tsdJsonDict.installed) {
// Assuming the cachedTypingPath has the format of "[package name]/[file name]"
const cachedTypingName = cachedTypingPath.substr(0, cachedTypingPath.indexOf("/"));
// If the inferred[cachedTypingName] is already not null, which means we found a corresponding
// d.ts file that coming with the package. That one should take higher priority.
if (hasProperty(inferredTypings, cachedTypingName) && !inferredTypings[cachedTypingName]) {
inferredTypings[cachedTypingName] = ts.combinePaths(typingsPath, cachedTypingPath);
}
}
}
}
// Remove typings that the user has added to the exclude list
for (const excludeTypingName of exclude) {
delete inferredTypings[excludeTypingName];
}
const newTypingNames: string[] = [];
const cachedTypingPaths: string[] = [];
for (const typing in inferredTypings) {
if (inferredTypings[typing] !== undefined) {
cachedTypingPaths.push(inferredTypings[typing]);
}
else {
newTypingNames.push(typing);
}
}
return { cachedTypingPaths, newTypingNames, filesToWatch };
/**
* Merge a given list of typingNames to the inferredTypings map
*/
function mergeTypings(typingNames: string[]) {
if (!typingNames) {
return;
}
for (const typing of typingNames) {
if (!inferredTypings.hasOwnProperty(typing)) {
inferredTypings[typing] = undefined;
}
}
}
/**
* Get the typing info from common package manager json files like package.json or bower.json
*/
function getTypingNamesFromJson(jsonPath: string, filesToWatch: string[]) {
const jsonDict = tryParseJson(jsonPath, host);
if (jsonDict) {
filesToWatch.push(jsonPath);
if (jsonDict.hasOwnProperty("dependencies")) {
mergeTypings(Object.keys(jsonDict.dependencies));
}
}
}
/**
* Infer typing names from given file names. For example, the file name "jquery-min.2.3.4.js"
* should be inferred to the 'jquery' typing name; and "angular-route.1.2.3.js" should be inferred
* to the 'angular-route' typing name.
* @param fileNames are the names for source files in the project
*/
function getTypingNamesFromSourceFileNames(fileNames: string[]) {
const jsFileNames = fileNames.filter(hasJavaScriptFileExtension);
const inferredTypingNames = jsFileNames.map(f => ts.removeFileExtension(ts.getBaseFileName(f.toLowerCase())));
const cleanedTypingNames = inferredTypingNames.map(f => f.replace(/((?:\.|-)min(?=\.|$))|((?:-|\.)\d+)/g, ""));
safeList === undefined ? mergeTypings(cleanedTypingNames) : mergeTypings(cleanedTypingNames.filter(f => safeList.hasOwnProperty(f)));
const jsxFileNames = fileNames.filter(f => scriptKindIs(f, /*LanguageServiceHost*/ undefined, ScriptKind.JSX));
if (jsxFileNames.length > 0) {
mergeTypings(["react"]);
}
}
/**
* Infer typing names from node_module folder
* @param nodeModulesPath is the path to the "node_modules" folder
*/
function getTypingNamesFromNodeModuleFolder(nodeModulesPath: string, filesToWatch: string[]) {
// Todo: add support for ModuleResolutionHost too
if (!host.directoryExists(nodeModulesPath)) {
return;
}
const typingNames: string[] = [];
const packageJsonFiles =
host.readDirectory(nodeModulesPath, /*extension*/ undefined, /*exclude*/ undefined, /*depth*/ 2).filter(f => ts.getBaseFileName(f) === "package.json");
for (const packageJsonFile of packageJsonFiles) {
const packageJsonDict = tryParseJson(packageJsonFile, host);
if (!packageJsonDict) { continue; }
filesToWatch.push(packageJsonFile);
// npm 3 has the package.json contains a "_requiredBy" field
// we should include all the top level module names for npm 2, and only module names whose
// "_requiredBy" field starts with "#" or equals "/" for npm 3.
if (packageJsonDict._requiredBy &&
packageJsonDict._requiredBy.filter((r: string) => r[0] === "#" || r === "/").length === 0) {
continue;
}
// If the package has its own d.ts typings, those will take precedence. Otherwise the package name will be used
// to download d.ts files from DefinitelyTyped
const packageName = packageJsonDict["name"];
if (packageJsonDict.hasOwnProperty("typings")) {
const absPath = ts.getNormalizedAbsolutePath(packageJsonDict.typings, ts.getDirectoryPath(packageJsonFile));
inferredTypings[packageName] = absPath;
}
else {
typingNames.push(packageName);
}
}
mergeTypings(typingNames);
}
function getTypingNamesFromCompilerOptions(options: CompilerOptions) {
const typingNames: string[] = [];
if (!options) {
return;
}
if (options.jsx === JsxEmit.React) {
typingNames.push("react");
}
if (options.moduleResolution === ModuleResolutionKind.NodeJs) {
typingNames.push("node");
}
mergeTypings(typingNames);
}
}
/**
* Keep a list of typings names that we know cannot be obtained at the moment (could be because
* of network issues or because the package doesn't hava a d.ts file in DefinitelyTyped), so
* that we won't try again next time within this session.
* @param newTypingNames The list of new typings that the host attempted to acquire
* @param cachePath The path to the tsd.json cache
* @param host The object providing I/O related operations.
*/
export function updateNotFoundTypingNames(newTypingNames: string[], cachePath: string, host: TypingResolutionHost): void {
const tsdJsonPath = ts.combinePaths(cachePath, "tsd.json");
const cacheTsdJsonDict = tryParseJson(tsdJsonPath, host);
if (cacheTsdJsonDict) {
const installedTypingFiles = hasProperty(cacheTsdJsonDict, "installed")
? Object.keys(cacheTsdJsonDict.installed)
: [];
const newMissingTypingNames =
ts.filter(newTypingNames, name => notFoundTypingNames.indexOf(name) < 0 && !isInstalled(name, installedTypingFiles));
for (const newMissingTypingName of newMissingTypingNames) {
notFoundTypingNames.push(newMissingTypingName);
}
}
}
function isInstalled(typing: string, installedKeys: string[]) {
const typingPrefix = typing + "/";
for (const key of installedKeys) {
if (key.indexOf(typingPrefix) === 0) {
return true;
}
}
return false;
}
}