Skip to content

Process package.json exports with auto-import provider #47092

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
161 changes: 151 additions & 10 deletions src/compiler/moduleNameResolver.ts
Original file line number Diff line number Diff line change
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;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling this function caches its results on the PackageJsonInfo, which itself is stored in the PackageJsonInfoCache which is part of the ModuleResolutionCache—open to suggestions for a cleaner place to put this if there are objections.

}

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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
Expand Up @@ -6306,8 +6306,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 @@ -6345,16 +6343,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
Loading