Skip to content
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

Add auto-import for the package.json imports field #55015

Merged
merged 34 commits into from
Dec 21, 2023
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
7c0fef4
Fix extension replacement in auto-import for `package.json#exports`
emmatown Jul 14, 2023
60ec33b
Add auto-import for package.json `imports` field
emmatown May 27, 2023
faef5fd
Fix using incorrect directory and add more tests
emmatown Aug 16, 2023
1d49909
Merge branch 'main' into auto-import-package-json-imports
emmatown Aug 16, 2023
4e676a3
Use JSON.parse
emmatown Aug 16, 2023
747e769
Merge branch 'main' into auto-import-package-json-imports
emmatown Aug 16, 2023
01ceb65
Use readJson again so they can all be replaced at the same time
emmatown Aug 17, 2023
864b9a3
Add test with conditions
emmatown Sep 11, 2023
d0407a9
Merge branch 'main' into auto-import-package-json-imports
emmatown Sep 11, 2023
e092943
Merge remote-tracking branch 'origin/main' into auto-import-package-j…
Andarist Oct 10, 2023
8f781c5
Revert "Use readJson again so they can all be replaced at the same time"
Andarist Oct 10, 2023
a2aa5ae
wrap `JSON.parse` calls with try/catch
Andarist Oct 10, 2023
eaa8e2e
Merge remote-tracking branch 'origin/main' into auto-import-package-j…
Andarist Oct 30, 2023
051239b
Add support for output file remapping
Andarist Oct 30, 2023
b3c5ca4
limit the behavior to imports
Andarist Oct 30, 2023
ea67766
add extra test case
Andarist Oct 30, 2023
7abf4a2
Merge remote-tracking branch 'origin/main' into auto-import-package-j…
Andarist Oct 30, 2023
6a72a51
fixed conflict with the recent changes on main
Andarist Oct 30, 2023
9903d72
Remove the `emitDeclarationOnly` check
Andarist Oct 30, 2023
9e2df94
Move `getCommonSourceDirectory` to the `ModuleSpecifierResolutionHost`
Andarist Oct 31, 2023
c4df857
Reuse `getCommonSourceDirectory` available in the `Program`
Andarist Oct 31, 2023
566e543
support declaration remapping
Andarist Oct 31, 2023
1f9f214
reprioritize checks
Andarist Oct 31, 2023
7a5c164
Deduplicate some emitter code
andrewbranch Nov 15, 2023
45194b2
Fix lints
andrewbranch Nov 15, 2023
8f2d0c3
Add failing test for when full path contains capital letters
andrewbranch Nov 15, 2023
8bef690
Move tryParseJson to be usable in compiler
andrewbranch Nov 15, 2023
cd84162
import `tryParseJson` after it got moved
Andarist Nov 15, 2023
8d68392
Fixed casing comparisons in `MatchingMode.Exact` and `MatchingMode.Di…
Andarist Nov 15, 2023
a9634ed
fixed the casing issue in the `MatchingMode.Pattern` case
Andarist Nov 16, 2023
7f52af2
Merge remote-tracking branch 'origin/main' into auto-import-package-j…
Andarist Nov 16, 2023
4aab191
fixed `endsWith`
Andarist Nov 16, 2023
c435528
Merge branch 'main' into auto-import-package-json-imports
andrewbranch Dec 21, 2023
4f29d3a
Fix bad merge artifacts
andrewbranch Dec 21, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 73 additions & 38 deletions src/compiler/moduleSpecifiers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ import {
getRelativePathFromDirectory,
getRelativePathToDirectoryOrUrl,
getResolvePackageJsonExports,
getResolvePackageJsonImports,
getSourceFileOfModule,
getSupportedExtensions,
getTextOfIdentifierOrLiteral,
Expand Down Expand Up @@ -456,7 +457,7 @@ function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOpt
const allowedEndings = getAllowedEndingsInPrefererredOrder(importMode);
const relativePath = rootDirs && tryGetModuleNameFromRootDirs(rootDirs, moduleFileName, sourceDirectory, getCanonicalFileName, allowedEndings, compilerOptions) ||
processEnding(ensurePathIsNonModuleName(getRelativePathFromDirectory(sourceDirectory, moduleFileName, getCanonicalFileName)), allowedEndings, compilerOptions);
if (!baseUrl && !paths || relativePreference === RelativePreference.Relative) {
if (!baseUrl && !paths && !getResolvePackageJsonImports(compilerOptions) || relativePreference === RelativePreference.Relative) {
emmatown marked this conversation as resolved.
Show resolved Hide resolved
return pathsOnly ? undefined : relativePath;
}

Expand All @@ -466,12 +467,14 @@ function getLocalModuleSpecifier(moduleFileName: string, info: Info, compilerOpt
return pathsOnly ? undefined : relativePath;
}

const fromPaths = paths && tryGetModuleNameFromPaths(relativeToBaseUrl, paths, allowedEndings, host, compilerOptions);
const fromPackageJsonImports = pathsOnly ? undefined : tryGetModuleNameFromPackageJsonImports(moduleFileName, sourceDirectory, compilerOptions, host, importMode);

const fromPaths = pathsOnly || fromPackageJsonImports === undefined ? paths && tryGetModuleNameFromPaths(relativeToBaseUrl, paths, allowedEndings, host, compilerOptions) : undefined;
if (pathsOnly) {
return fromPaths;
}

const maybeNonRelative = fromPaths === undefined && baseUrl !== undefined ? processEnding(relativeToBaseUrl, allowedEndings, compilerOptions) : fromPaths;
const maybeNonRelative = fromPackageJsonImports ?? (fromPaths === undefined && baseUrl !== undefined ? processEnding(relativeToBaseUrl, allowedEndings, compilerOptions) : fromPaths);
if (!maybeNonRelative) {
return relativePath;
}
Expand Down Expand Up @@ -538,8 +541,8 @@ function getNearestAncestorDirectoryWithPackageJson(host: ModuleSpecifierResolut
if (host.getNearestAncestorDirectoryWithPackageJson) {
return host.getNearestAncestorDirectoryWithPackageJson(fileName);
}
return !!forEachAncestorDirectory(fileName, directory => {
return host.fileExists(combinePaths(directory, "package.json")) ? true : undefined;
return forEachAncestorDirectory(fileName, directory => {
return host.fileExists(combinePaths(directory, "package.json")) ? directory : undefined;
});
}

Expand Down Expand Up @@ -808,7 +811,7 @@ const enum MatchingMode {
Pattern
}

function tryGetModuleNameFromExports(options: CompilerOptions, targetFilePath: string, packageDirectory: string, packageName: string, exports: unknown, conditions: string[], mode = MatchingMode.Exact): { moduleFileToTry: string } | undefined {
function tryGetModuleNameFromExportsOrImports(options: CompilerOptions, targetFilePath: string, packageDirectory: string, packageName: string, exports: unknown, conditions: string[], mode: MatchingMode): { moduleFileToTry: string } | undefined {
if (typeof exports === "string") {
const pathOrPattern = getNormalizedAbsolutePath(combinePaths(packageDirectory, exports), /*currentDirectory*/ undefined);
const extensionSwappedTarget = hasTSFileExtension(targetFilePath) ? removeFileExtension(targetFilePath) + tryGetJSExtensionForFile(targetFilePath, options) : undefined;
Expand All @@ -819,6 +822,10 @@ function tryGetModuleNameFromExports(options: CompilerOptions, targetFilePath: s
}
break;
case MatchingMode.Directory:
if (extensionSwappedTarget && containsPath(pathOrPattern, extensionSwappedTarget)) {
const fragment = getRelativePathFromDirectory(pathOrPattern, extensionSwappedTarget, /*ignoreCase*/ false);
return { moduleFileToTry: getNormalizedAbsolutePath(combinePaths(combinePaths(packageName, exports), fragment), /*currentDirectory*/ undefined) };
}
if (containsPath(pathOrPattern, targetFilePath)) {
const fragment = getRelativePathFromDirectory(pathOrPattern, targetFilePath, /*ignoreCase*/ false);
return { moduleFileToTry: getNormalizedAbsolutePath(combinePaths(combinePaths(packageName, exports), fragment), /*currentDirectory*/ undefined) };
Expand All @@ -828,51 +835,82 @@ function tryGetModuleNameFromExports(options: CompilerOptions, targetFilePath: s
const starPos = pathOrPattern.indexOf("*");
const leadingSlice = pathOrPattern.slice(0, starPos);
const trailingSlice = pathOrPattern.slice(starPos + 1);
if (startsWith(targetFilePath, leadingSlice) && endsWith(targetFilePath, trailingSlice)) {
const starReplacement = targetFilePath.slice(leadingSlice.length, targetFilePath.length - trailingSlice.length);
return { moduleFileToTry: packageName.replace("*", starReplacement) };
}
if (extensionSwappedTarget && startsWith(extensionSwappedTarget, leadingSlice) && endsWith(extensionSwappedTarget, trailingSlice)) {
const starReplacement = extensionSwappedTarget.slice(leadingSlice.length, extensionSwappedTarget.length - trailingSlice.length);
return { moduleFileToTry: packageName.replace("*", starReplacement) };
Fixed Show fixed Hide fixed
}
if (startsWith(targetFilePath, leadingSlice) && endsWith(targetFilePath, trailingSlice)) {
const starReplacement = targetFilePath.slice(leadingSlice.length, targetFilePath.length - trailingSlice.length);
return { moduleFileToTry: packageName.replace("*", starReplacement) };
}
break;
}
}
else if (Array.isArray(exports)) {
return forEach(exports, e => tryGetModuleNameFromExports(options, targetFilePath, packageDirectory, packageName, e, conditions));
return forEach(exports, e => tryGetModuleNameFromExportsOrImports(options, targetFilePath, packageDirectory, packageName, e, conditions, mode));
}
else if (typeof exports === "object" && exports !== null) { // eslint-disable-line no-null/no-null
if (allKeysStartWithDot(exports as MapLike<unknown>)) {
// sub-mappings
// 3 cases:
// * directory mappings (legacyish, key ends with / (technically allows index/extension resolution under cjs mode))
// * pattern mappings (contains a *)
// * exact mappings (no *, does not end with /)
return forEach(getOwnKeys(exports as MapLike<unknown>), k => {
const subPackageName = getNormalizedAbsolutePath(combinePaths(packageName, k), /*currentDirectory*/ undefined);
const mode = endsWith(k, "/") ? MatchingMode.Directory
: stringContains(k, "*") ? MatchingMode.Pattern
: MatchingMode.Exact;
return tryGetModuleNameFromExports(options, targetFilePath, packageDirectory, subPackageName, (exports as MapLike<unknown>)[k], conditions, mode);
});
}
else {
// conditional mapping
for (const key of getOwnKeys(exports as MapLike<unknown>)) {
if (key === "default" || conditions.indexOf(key) >= 0 || isApplicableVersionedTypesKey(conditions, key)) {
const subTarget = (exports as MapLike<unknown>)[key];
const result = tryGetModuleNameFromExports(options, targetFilePath, packageDirectory, packageName, subTarget, conditions, mode);
if (result) {
return result;
}
// conditional mapping
for (const key of getOwnKeys(exports as MapLike<unknown>)) {
if (key === "default" || conditions.indexOf(key) >= 0 || isApplicableVersionedTypesKey(conditions, key)) {
const subTarget = (exports as MapLike<unknown>)[key];
const result = tryGetModuleNameFromExportsOrImports(options, targetFilePath, packageDirectory, packageName, subTarget, conditions, mode);
if (result) {
return result;
}
}
}
}
return undefined;
}

function tryGetModuleNameFromExports(options: CompilerOptions, targetFilePath: string, packageDirectory: string, packageName: string, exports: unknown, conditions: string[]): { moduleFileToTry: string } | undefined {
if (typeof exports === "object" && exports !== null && !Array.isArray(exports) && allKeysStartWithDot(exports as MapLike<unknown>)) { // eslint-disable-line no-null/no-null
// sub-mappings
// 3 cases:
// * directory mappings (legacyish, key ends with / (technically allows index/extension resolution under cjs mode))
// * pattern mappings (contains a *)
// * exact mappings (no *, does not end with /)
return forEach(getOwnKeys(exports as MapLike<unknown>), k => {
const subPackageName = getNormalizedAbsolutePath(combinePaths(packageName, k), /*currentDirectory*/ undefined);
const mode = endsWith(k, "/") ? MatchingMode.Directory
: stringContains(k, "*") ? MatchingMode.Pattern
: MatchingMode.Exact;
return tryGetModuleNameFromExportsOrImports(options, targetFilePath, packageDirectory, subPackageName, (exports as MapLike<unknown>)[k], conditions, mode);
});
}
return tryGetModuleNameFromExportsOrImports(options, targetFilePath, packageDirectory, packageName, exports, conditions, MatchingMode.Exact);
}

function tryGetModuleNameFromPackageJsonImports(moduleFileName: string, sourceDirectory: Path, options: CompilerOptions, host: ModuleSpecifierResolutionHost, importMode: ResolutionMode) {
if (!host.readFile || !getResolvePackageJsonImports(options)) {
return undefined;
}

const ancestorDirectoryWithPackageJson = getNearestAncestorDirectoryWithPackageJson(host, sourceDirectory);
if (!ancestorDirectoryWithPackageJson) {
return undefined;
}
const packageJsonPath = combinePaths(ancestorDirectoryWithPackageJson, "package.json");
const cachedPackageJson = host.getPackageJsonInfoCache?.()?.getPackageJsonInfo(packageJsonPath);
if (typeof cachedPackageJson !== "object" && cachedPackageJson !== undefined || !host.fileExists(packageJsonPath)) {
return undefined;
}
const packageJsonContent = cachedPackageJson?.contents.packageJsonContent || JSON.parse(host.readFile(packageJsonPath)!);
Copy link
Member

Choose a reason for hiding this comment

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

I don't think this is a new problem as this file already has code that looks exactly like this, but I'm somewhat certain that we should be using readJson for this; if the JSON is malformed, we'll throw here and then I don't know what will happen. readJson is what's used to get stuff into the cache, and generally speaking other raw JSON.parse calls all appear to at least try/catch.

Copy link
Member

Choose a reason for hiding this comment

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

I see that there's another thread about this at #55015 (comment); if we want to leave this JSON.parse in here given we already do that, that's fine, but overall if we're avoiding readJson because it calls the wrong JSON parser, but the alternatiive is to call JSON.parse unguarded... that feels spooky to me.

const imports = packageJsonContent?.imports;
if (!imports) {
return undefined;
}
const conditions = getConditions(options, importMode === ModuleKind.ESNext);
return forEach(getOwnKeys(imports as MapLike<unknown>), k => {
if (!startsWith(k, "#") || k === "#" || startsWith(k, "#/")) return undefined;
const mode = endsWith(k, "/") ? MatchingMode.Directory
: stringContains(k, "*") ? MatchingMode.Pattern
: MatchingMode.Exact;
return tryGetModuleNameFromExportsOrImports(options, moduleFileName, ancestorDirectoryWithPackageJson, k, (imports as MapLike<unknown>)[k], conditions, mode);
})?.moduleFileToTry;
}

function tryGetModuleNameFromRootDirs(rootDirs: readonly string[], moduleFileName: string, sourceDirectory: string, getCanonicalFileName: (file: string) => string, allowedEndings: readonly ModuleSpecifierEnding[], compilerOptions: CompilerOptions): string | undefined {
const normalizedTargetPaths = getPathsRelativeToRootDirs(moduleFileName, rootDirs, getCanonicalFileName);
if (normalizedTargetPaths === undefined) {
Expand Down Expand Up @@ -973,10 +1011,7 @@ function tryGetModuleNameAsNodeModule({ path, isRedirect }: ModulePath, { getCan
? tryGetModuleNameFromExports(options, path, packageRootPath, packageName, packageJsonContent.exports, conditions)
: undefined;
if (fromExports) {
const withJsExtension = !hasTSFileExtension(fromExports.moduleFileToTry)
? fromExports
: { moduleFileToTry: removeFileExtension(fromExports.moduleFileToTry) + tryGetJSExtensionForFile(fromExports.moduleFileToTry, options) };
return { ...withJsExtension, verbatimFromExports: true };
return { ...fromExports, verbatimFromExports: true };
}
if (packageJsonContent.exports) {
return { moduleFileToTry: path, blockedByExports: true };
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/// <reference path="fourslash.ts" />

// @module: nodenext

// @Filename: /node_modules/pkg/package.json
//// {
//// "name": "pkg",
//// "version": "1.0.0",
//// "exports": {
//// "./something.ts": "./a.js"
//// }
//// }

// @Filename: /node_modules/pkg/a.d.ts
//// export function foo(): void;

// @Filename: /package.json
//// {
//// "dependencies": {
//// "pkg": "*"
//// }
//// }

// @Filename: /index.ts
//// foo/**/

verify.importFixModuleSpecifiers("", ["pkg/something.ts"]);
18 changes: 18 additions & 0 deletions tests/cases/fourslash/autoImportPackageJsonImportsLength1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// <reference path="fourslash.ts" />

// @module: nodenext

// @Filename: /package.json
//// {
//// "imports": {
//// "#*": "./src/*.ts"
andrewbranch marked this conversation as resolved.
Show resolved Hide resolved
//// }
//// }

// @Filename: /src/a/b/c/something.ts
//// export function something(name: string): any;

// @Filename: /src/a/b/c/d.ts
//// something/**/

verify.importFixModuleSpecifiers("", ["./something"]);
andrewbranch marked this conversation as resolved.
Show resolved Hide resolved
18 changes: 18 additions & 0 deletions tests/cases/fourslash/autoImportPackageJsonImportsLength2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// <reference path="fourslash.ts" />

// @module: nodenext

// @Filename: /package.json
//// {
//// "imports": {
//// "#*": "./src/*.ts"
//// }
//// }

// @Filename: /src/a/b/c/something.ts
//// export function something(name: string): any;

// @Filename: /a.ts
//// something/**/

verify.importFixModuleSpecifiers("", ["#a/b/c/something"]);
andrewbranch marked this conversation as resolved.
Show resolved Hide resolved
18 changes: 18 additions & 0 deletions tests/cases/fourslash/autoImportPackageJsonImportsPattern.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// <reference path="fourslash.ts" />

// @module: nodenext

// @Filename: /package.json
//// {
//// "imports": {
//// "#*": "./src/*"
//// }
//// }

// @Filename: /src/something.ts
//// export function something(name: string): any;

// @Filename: /a.ts
//// something/**/

verify.importFixModuleSpecifiers("", ["#something.js"]);
Copy link
Contributor

@Andarist Andarist Mar 9, 2024

Choose a reason for hiding this comment

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

@andrewbranch should this autocomplete #something.js? I'm investigating some stuff and I concluded the conclusion that perhaps this should actually be just #something. @module: nodenext doesn't prescribe the specifier ending - and since this package.json doesn't have a type it's an implied CJS file so the ending is optional.

note for myself: fixing this would likely depend on using getModuleSpecifierEndingPreference appropriately

Hmm, I still observe some inconsistencies elsewhere but now I concluded that this one is correct - imports/exports always require extensions (regardless of the module format).

18 changes: 18 additions & 0 deletions tests/cases/fourslash/autoImportPackageJsonImportsPattern_js.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// <reference path="fourslash.ts" />

// @module: nodenext

// @Filename: /package.json
//// {
//// "imports": {
//// "#*": "./src/*.js"
//// }
//// }

// @Filename: /src/something.ts
//// export function something(name: string): any;

// @Filename: /a.ts
//// something/**/

verify.importFixModuleSpecifiers("", ["#something"]);
18 changes: 18 additions & 0 deletions tests/cases/fourslash/autoImportPackageJsonImportsPattern_js_ts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// <reference path="fourslash.ts" />

// @module: nodenext

// @Filename: /package.json
//// {
//// "imports": {
//// "#*.js": "./src/*.ts"
//// }
//// }

// @Filename: /src/something.ts
//// export function something(name: string): any;

// @Filename: /a.ts
//// something/**/

verify.importFixModuleSpecifiers("", ["#something.js"]);
18 changes: 18 additions & 0 deletions tests/cases/fourslash/autoImportPackageJsonImportsPattern_ts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// <reference path="fourslash.ts" />

// @module: nodenext

// @Filename: /package.json
//// {
//// "imports": {
//// "#*": "./src/*.ts"
//// }
//// }

// @Filename: /src/something.ts
//// export function something(name: string): any;

// @Filename: /a.ts
//// something/**/

verify.importFixModuleSpecifiers("", ["#something"]);
18 changes: 18 additions & 0 deletions tests/cases/fourslash/autoImportPackageJsonImportsPattern_ts_js.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// <reference path="fourslash.ts" />

// @module: nodenext

// @Filename: /package.json
//// {
//// "imports": {
//// "#*.ts": "./src/*.js"
//// }
//// }

// @Filename: /src/something.ts
//// export function something(name: string): any;

// @Filename: /a.ts
//// something/**/

verify.importFixModuleSpecifiers("", ["#something.ts"]);
18 changes: 18 additions & 0 deletions tests/cases/fourslash/autoImportPackageJsonImportsPattern_ts_ts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/// <reference path="fourslash.ts" />

// @module: nodenext

// @Filename: /package.json
//// {
//// "imports": {
//// "#*.ts": "./src/*.ts"
//// }
//// }

// @Filename: /src/something.ts
//// export function something(name: string): any;

// @Filename: /a.ts
//// something/**/

verify.importFixModuleSpecifiers("", ["#something.ts"]);
20 changes: 20 additions & 0 deletions tests/cases/fourslash/autoImportPackageJsonImportsPreference1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
/// <reference path="fourslash.ts" />

// @module: nodenext

// @Filename: /package.json
//// {
//// "imports": {
//// "#*": "./src/*.ts"
//// }
//// }

// @Filename: /src/a/b/c/something.ts
//// export function something(name: string): any;

// @Filename: /a.ts
//// something/**/

verify.importFixModuleSpecifiers("", ["./src/a/b/c/something"], {
importModuleSpecifierPreference: "relative"
});
Loading
Loading