Skip to content

Commit

Permalink
Process package.json exports with auto-import provider (#47092)
Browse files Browse the repository at this point in the history
* Have auto-import provider pull in `exports`

* Revert filtering of node_modules relative paths, to do in separate PR

* Do @types and JS prioritization correctly

* Cache entrypoints on PackageJsonInfo

* Add one more test

* Delete unused function

* Fix other tests - dependencies need package.json files

* Do two passes of exports resolution

* Fix missed refactor

* Apply suggestions from code review

Co-authored-by: Nathan Shively-Sanders <293473+sandersn@users.noreply.github.com>

* Uncomment rest of test

* Handle array targets

Co-authored-by: Nathan Shively-Sanders <293473+sandersn@users.noreply.github.com>
  • Loading branch information
andrewbranch and sandersn committed Jan 11, 2022
1 parent 935c05c commit 0f1496f
Show file tree
Hide file tree
Showing 20 changed files with 975 additions and 63 deletions.
161 changes: 151 additions & 10 deletions src/compiler/moduleNameResolver.ts
Expand Up @@ -340,10 +340,7 @@ namespace ts {
}

const failedLookupLocations: string[] = [];
const features =
getEmitModuleResolutionKind(options) === ModuleResolutionKind.Node12 ? NodeResolutionFeatures.Node12Default :
getEmitModuleResolutionKind(options) === ModuleResolutionKind.NodeNext ? NodeResolutionFeatures.NodeNextDefault :
NodeResolutionFeatures.None;
const features = getDefaultNodeResolutionFeatures(options);
const moduleResolutionState: ModuleResolutionState = { compilerOptions: options, host, traceEnabled, failedLookupLocations, packageJsonInfoCache: cache, features, conditions: ["node", "require", "types"] };
let resolved = primaryLookup();
let primary = true;
Expand Down Expand Up @@ -433,6 +430,42 @@ namespace ts {
}
}

function getDefaultNodeResolutionFeatures(options: CompilerOptions) {
return getEmitModuleResolutionKind(options) === ModuleResolutionKind.Node12 ? NodeResolutionFeatures.Node12Default :
getEmitModuleResolutionKind(options) === ModuleResolutionKind.NodeNext ? NodeResolutionFeatures.NodeNextDefault :
NodeResolutionFeatures.None;
}

/**
* @internal
* Does not try `@types/${packageName}` - use a second pass if needed.
*/
export function resolvePackageNameToPackageJson(
packageName: string,
containingDirectory: string,
options: CompilerOptions,
host: ModuleResolutionHost,
cache: ModuleResolutionCache | undefined,
): PackageJsonInfo | undefined {
const moduleResolutionState: ModuleResolutionState = {
compilerOptions: options,
host,
traceEnabled: isTraceEnabled(options, host),
failedLookupLocations: [],
packageJsonInfoCache: cache?.getPackageJsonInfoCache(),
conditions: emptyArray,
features: NodeResolutionFeatures.None,
};

return forEachAncestorDirectory(containingDirectory, ancestorDirectory => {
if (getBaseFileName(ancestorDirectory) !== "node_modules") {
const nodeModulesFolder = combinePaths(ancestorDirectory, "node_modules");
const candidate = combinePaths(nodeModulesFolder, packageName);
return getPackageJsonInfo(candidate, /*onlyRecordFailures*/ false, moduleResolutionState);
}
});
}

/**
* Given a set of options, returns the set of type directive names
* that should be included for this program automatically.
Expand Down Expand Up @@ -1171,11 +1204,6 @@ namespace ts {
return resolvedModule.resolvedFileName;
}

/* @internal */
export function tryResolveJSModule(moduleName: string, initialDir: string, host: ModuleResolutionHost) {
return tryResolveJSModuleWorker(moduleName, initialDir, host).resolvedModule;
}

/* @internal */
enum NodeResolutionFeatures {
None = 0,
Expand Down Expand Up @@ -1536,11 +1564,124 @@ namespace ts {
return withPackageId(packageInfo, loadNodeModuleFromDirectoryWorker(extensions, candidate, onlyRecordFailures, state, packageJsonContent, versionPaths));
}

/* @internal */
export function getEntrypointsFromPackageJsonInfo(
packageJsonInfo: PackageJsonInfo,
options: CompilerOptions,
host: ModuleResolutionHost,
cache: ModuleResolutionCache | undefined,
resolveJs?: boolean,
): string[] | false {
if (!resolveJs && packageJsonInfo.resolvedEntrypoints !== undefined) {
// Cached value excludes resolutions to JS files - those could be
// cached separately, but they're used rarely.
return packageJsonInfo.resolvedEntrypoints;
}

let entrypoints: string[] | undefined;
const extensions = resolveJs ? Extensions.JavaScript : Extensions.TypeScript;
const features = getDefaultNodeResolutionFeatures(options);
const requireState: ModuleResolutionState = {
compilerOptions: options,
host,
traceEnabled: isTraceEnabled(options, host),
failedLookupLocations: [],
packageJsonInfoCache: cache?.getPackageJsonInfoCache(),
conditions: ["node", "require", "types"],
features,
};
const requireResolution = loadNodeModuleFromDirectoryWorker(
extensions,
packageJsonInfo.packageDirectory,
/*onlyRecordFailures*/ false,
requireState,
packageJsonInfo.packageJsonContent,
packageJsonInfo.versionPaths);
entrypoints = append(entrypoints, requireResolution?.path);

if (features & NodeResolutionFeatures.Exports && packageJsonInfo.packageJsonContent.exports) {
for (const conditions of [["node", "import", "types"], ["node", "require", "types"]]) {
const exportState = { ...requireState, failedLookupLocations: [], conditions };
const exportResolutions = loadEntrypointsFromExportMap(
packageJsonInfo,
packageJsonInfo.packageJsonContent.exports,
exportState,
extensions);
if (exportResolutions) {
for (const resolution of exportResolutions) {
entrypoints = appendIfUnique(entrypoints, resolution.path);
}
}
}
}

return packageJsonInfo.resolvedEntrypoints = entrypoints || false;
}

function loadEntrypointsFromExportMap(
scope: PackageJsonInfo,
exports: object,
state: ModuleResolutionState,
extensions: Extensions,
): PathAndExtension[] | undefined {
let entrypoints: PathAndExtension[] | undefined;
if (isArray(exports)) {
for (const target of exports) {
loadEntrypointsFromTargetExports(target);
}
}
// eslint-disable-next-line no-null/no-null
else if (typeof exports === "object" && exports !== null && allKeysStartWithDot(exports as MapLike<unknown>)) {
for (const key in exports) {
loadEntrypointsFromTargetExports((exports as MapLike<unknown>)[key]);
}
}
else {
loadEntrypointsFromTargetExports(exports);
}
return entrypoints;

function loadEntrypointsFromTargetExports(target: unknown): boolean | undefined {
if (typeof target === "string" && startsWith(target, "./") && target.indexOf("*") === -1) {
const partsAfterFirst = getPathComponents(target).slice(2);
if (partsAfterFirst.indexOf("..") >= 0 || partsAfterFirst.indexOf(".") >= 0 || partsAfterFirst.indexOf("node_modules") >= 0) {
return false;
}
const resolvedTarget = combinePaths(scope.packageDirectory, target);
const finalPath = getNormalizedAbsolutePath(resolvedTarget, state.host.getCurrentDirectory?.());
const result = loadJSOrExactTSFileName(extensions, finalPath, /*recordOnlyFailures*/ false, state);
if (result) {
entrypoints = appendIfUnique(entrypoints, result, (a, b) => a.path === b.path);
return true;
}
}
else if (Array.isArray(target)) {
for (const t of target) {
const success = loadEntrypointsFromTargetExports(t);
if (success) {
return true;
}
}
}
// eslint-disable-next-line no-null/no-null
else if (typeof target === "object" && target !== null) {
return forEach(getOwnKeys(target as MapLike<unknown>), key => {
if (key === "default" || contains(state.conditions, key) || isApplicableVersionedTypesKey(state.conditions, key)) {
loadEntrypointsFromTargetExports((target as MapLike<unknown>)[key]);
return true;
}
});
}
}
}

/*@internal*/
interface PackageJsonInfo {
packageDirectory: string;
packageJsonContent: PackageJsonPathFields;
versionPaths: VersionPaths | undefined;
/** false: resolved to nothing. undefined: not yet resolved */
resolvedEntrypoints: string[] | false | undefined;
}

/**
Expand Down Expand Up @@ -1606,7 +1747,7 @@ namespace ts {
trace(host, Diagnostics.Found_package_json_at_0, packageJsonPath);
}
const versionPaths = readPackageJsonTypesVersionPaths(packageJsonContent, state);
const result = { packageDirectory, packageJsonContent, versionPaths };
const result = { packageDirectory, packageJsonContent, versionPaths, resolvedEntrypoints: undefined };
state.packageJsonInfoCache?.setPackageJsonInfo(packageJsonPath, result);
return result;
}
Expand Down
45 changes: 32 additions & 13 deletions src/compiler/moduleSpecifiers.ts
Expand Up @@ -59,44 +59,55 @@ namespace ts.moduleSpecifiers {
};
}

// `importingSourceFile` and `importingSourceFileName`? Why not just use `importingSourceFile.path`?
// Because when this is called by the file renamer, `importingSourceFile` is the file being renamed,
// while `importingSourceFileName` its *new* name. We need a source file just to get its
// `impliedNodeFormat` and to detect certain preferences from existing import module specifiers.
export function updateModuleSpecifier(
compilerOptions: CompilerOptions,
importingSourceFile: SourceFile,
importingSourceFileName: Path,
toFileName: string,
host: ModuleSpecifierResolutionHost,
oldImportSpecifier: string,
): string | undefined {
const res = getModuleSpecifierWorker(compilerOptions, importingSourceFileName, toFileName, host, getPreferencesForUpdate(compilerOptions, oldImportSpecifier, importingSourceFileName, host), {});
const res = getModuleSpecifierWorker(compilerOptions, importingSourceFile, importingSourceFileName, toFileName, host, getPreferencesForUpdate(compilerOptions, oldImportSpecifier, importingSourceFileName, host), {});
if (res === oldImportSpecifier) return undefined;
return res;
}

// Note: importingSourceFile is just for usesJsExtensionOnImports
// `importingSourceFile` and `importingSourceFileName`? Why not just use `importingSourceFile.path`?
// Because when this is called by the declaration emitter, `importingSourceFile` is the implementation
// file, but `importingSourceFileName` and `toFileName` refer to declaration files (the former to the
// one currently being produced; the latter to the one being imported). We need an implementation file
// just to get its `impliedNodeFormat` and to detect certain preferences from existing import module
// specifiers.
export function getModuleSpecifier(
compilerOptions: CompilerOptions,
importingSourceFile: SourceFile,
importingSourceFileName: Path,
toFileName: string,
host: ModuleSpecifierResolutionHost,
): string {
return getModuleSpecifierWorker(compilerOptions, importingSourceFileName, toFileName, host, getPreferences(host, {}, compilerOptions, importingSourceFile), {});
return getModuleSpecifierWorker(compilerOptions, importingSourceFile, importingSourceFileName, toFileName, host, getPreferences(host, {}, compilerOptions, importingSourceFile), {});
}

export function getNodeModulesPackageName(
compilerOptions: CompilerOptions,
importingSourceFileName: Path,
importingSourceFile: SourceFile,
nodeModulesFileName: string,
host: ModuleSpecifierResolutionHost,
preferences: UserPreferences,
): string | undefined {
const info = getInfo(importingSourceFileName, host);
const modulePaths = getAllModulePaths(importingSourceFileName, nodeModulesFileName, host, preferences);
const info = getInfo(importingSourceFile.path, host);
const modulePaths = getAllModulePaths(importingSourceFile.path, nodeModulesFileName, host, preferences);
return firstDefined(modulePaths,
modulePath => tryGetModuleNameAsNodeModule(modulePath, info, host, compilerOptions, /*packageNameOnly*/ true));
modulePath => tryGetModuleNameAsNodeModule(modulePath, info, importingSourceFile, host, compilerOptions, /*packageNameOnly*/ true));
}

function getModuleSpecifierWorker(
compilerOptions: CompilerOptions,
importingSourceFile: SourceFile,
importingSourceFileName: Path,
toFileName: string,
host: ModuleSpecifierResolutionHost,
Expand All @@ -105,7 +116,7 @@ namespace ts.moduleSpecifiers {
): string {
const info = getInfo(importingSourceFileName, host);
const modulePaths = getAllModulePaths(importingSourceFileName, toFileName, host, userPreferences);
return firstDefined(modulePaths, modulePath => tryGetModuleNameAsNodeModule(modulePath, info, host, compilerOptions)) ||
return firstDefined(modulePaths, modulePath => tryGetModuleNameAsNodeModule(modulePath, info, importingSourceFile, host, compilerOptions)) ||
getLocalModuleSpecifier(toFileName, info, compilerOptions, host, preferences);
}

Expand Down Expand Up @@ -222,7 +233,7 @@ namespace ts.moduleSpecifiers {
let pathsSpecifiers: string[] | undefined;
let relativeSpecifiers: string[] | undefined;
for (const modulePath of modulePaths) {
const specifier = tryGetModuleNameAsNodeModule(modulePath, info, host, compilerOptions);
const specifier = tryGetModuleNameAsNodeModule(modulePath, info, importingSourceFile, host, compilerOptions);
nodeModulesSpecifiers = append(nodeModulesSpecifiers, specifier);
if (specifier && modulePath.isRedirect) {
// If we got a specifier for a redirect, it was a bare package specifier (e.g. "@foo/bar",
Expand Down Expand Up @@ -639,7 +650,7 @@ namespace ts.moduleSpecifiers {
: removeFileExtension(relativePath);
}

function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCanonicalFileName, sourceDirectory }: Info, host: ModuleSpecifierResolutionHost, options: CompilerOptions, packageNameOnly?: boolean): string | undefined {
function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCanonicalFileName, sourceDirectory }: Info, importingSourceFile: SourceFile , host: ModuleSpecifierResolutionHost, options: CompilerOptions, packageNameOnly?: boolean): string | undefined {
if (!host.fileExists || !host.readFile) {
return undefined;
}
Expand Down Expand Up @@ -706,11 +717,19 @@ namespace ts.moduleSpecifiers {
let moduleFileToTry = path;
if (host.fileExists(packageJsonPath)) {
const packageJsonContent = JSON.parse(host.readFile!(packageJsonPath)!);
// TODO: Inject `require` or `import` condition based on the intended import mode
if (getEmitModuleResolutionKind(options) === ModuleResolutionKind.Node12 || getEmitModuleResolutionKind(options) === ModuleResolutionKind.NodeNext) {
const fromExports = packageJsonContent.exports && typeof packageJsonContent.name === "string" ? tryGetModuleNameFromExports(options, path, packageRootPath, packageJsonContent.name, packageJsonContent.exports, ["node", "types"]) : undefined;
// `conditions` *could* be made to go against `importingSourceFile.impliedNodeFormat` if something wanted to generate
// an ImportEqualsDeclaration in an ESM-implied file or an ImportCall in a CJS-implied file. But since this function is
// usually called to conjure an import out of thin air, we don't have an existing usage to call `getModeForUsageAtIndex`
// with, so for now we just stick with the mode of the file.
const conditions = ["node", importingSourceFile.impliedNodeFormat === ModuleKind.ESNext ? "import" : "require", "types"];
const fromExports = packageJsonContent.exports && typeof packageJsonContent.name === "string"
? tryGetModuleNameFromExports(options, path, packageRootPath, getPackageNameFromTypesPackageName(packageJsonContent.name), packageJsonContent.exports, conditions)
: undefined;
if (fromExports) {
const withJsExtension = !hasTSFileExtension(fromExports.moduleFileToTry) ? fromExports : { moduleFileToTry: removeFileExtension(fromExports.moduleFileToTry) + tryGetJSExtensionForFile(fromExports.moduleFileToTry, options) };
const withJsExtension = !hasTSFileExtension(fromExports.moduleFileToTry)
? fromExports
: { moduleFileToTry: removeFileExtension(fromExports.moduleFileToTry) + tryGetJSExtensionForFile(fromExports.moduleFileToTry, options) };
return { ...withJsExtension, verbatimFromExports: true };
}
if (packageJsonContent.exports) {
Expand Down
12 changes: 0 additions & 12 deletions src/compiler/utilities.ts
Expand Up @@ -6307,8 +6307,6 @@ namespace ts {
getSymlinkedFiles(): ReadonlyESMap<Path, string> | undefined;
setSymlinkedDirectory(symlink: string, real: SymlinkedDirectory | false): void;
setSymlinkedFile(symlinkPath: Path, real: string): void;
/*@internal*/
setSymlinkedDirectoryFromSymlinkedFile(symlink: string, real: string): void;
/**
* @internal
* Uses resolvedTypeReferenceDirectives from program instead of from files, since files
Expand Down Expand Up @@ -6346,16 +6344,6 @@ namespace ts {
(symlinkedDirectories || (symlinkedDirectories = new Map())).set(symlinkPath, real);
}
},
setSymlinkedDirectoryFromSymlinkedFile(symlink, real) {
this.setSymlinkedFile(toPath(symlink, cwd, getCanonicalFileName), real);
const [commonResolved, commonOriginal] = guessDirectorySymlink(real, symlink, cwd, getCanonicalFileName) || emptyArray;
if (commonResolved && commonOriginal) {
this.setSymlinkedDirectory(commonOriginal, {
real: commonResolved,
realPath: toPath(commonResolved, cwd, getCanonicalFileName),
});
}
},
setSymlinksFromResolutions(files, typeReferenceDirectives) {
Debug.assert(!hasProcessedResolutions);
hasProcessedResolutions = true;
Expand Down

0 comments on commit 0f1496f

Please sign in to comment.