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

Conversation

emmatown
Copy link
Contributor

Fixes #49492

This treats package.json#imports as roughly equivalent to compilerOptions#paths in terms of deciding which specifier to use, it also prefers package.json#imports over compilerOptions#paths

While doing this, I found some broken behaviour around extension replacement which was already observable with the package.json#exports field auto-import but is more visible with package.json#imports so this fixes that as well. autoImportPackageJsonExportsSpecifierEndsInTs shows an example of what's fixed for package.json#exports. That fix and test is in a seperate commit if that's easier to review

@typescript-bot typescript-bot added the For Backlog Bug PRs that fix a backlog bug label Jul 14, 2023
@andrewbranch andrewbranch self-assigned this Jul 14, 2023
@sandersn sandersn added this to Not started in PR Backlog Jul 28, 2023
@sandersn sandersn moved this from Not started to Waiting on reviewers in PR Backlog Jul 28, 2023
Copy link
Member

@andrewbranch andrewbranch left a comment

Choose a reason for hiding this comment

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

Thank you for adding this!

if (typeof cachedPackageJson !== "object" && cachedPackageJson !== undefined) {
return undefined;
}
const packageJsonContent = cachedPackageJson?.contents.packageJsonContent || readJson(packageJsonPath, host as { readFile(fileName: string): string | undefined });
Copy link
Member

Choose a reason for hiding this comment

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

Use JSON.parse for package.json files. readJson uses the TypeScript parser to construct an AST and then converts that to an object 😵 I’ve been meaning to audit existing uses of readJson and then rename it; I know some can be replaced.

Copy link
Contributor Author

@emmatown emmatown Aug 16, 2023

Choose a reason for hiding this comment

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

Yeah, I saw that 😅. I was aiming to consistent with the implementation of auto-import for the exports field. I was thinking maybe that should be left for a seperate PR to avoid more ad-hoc handling of package.json reading? Sorry, I was thinking of some other code, I've updated it to JSON.parse.

Use JSON.parse for package.json files. readJson uses the TypeScript parser to construct an AST and then converts that to an object 😵 I’ve been meaning to audit existing uses of readJson and then rename it; I know some can be replaced.

I've actually checked btw and all the usages of readJson are for reading package.jsons and the readJsonOrUndefined function that's used by readJson is only used for build info so all of them can really be replaced with JSON.parse

Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure that replacing everything with JSON.parse is generally correct, because it'll throw if the file has malformed JSON. The services project has a helper, tryParseJson, which catches this, and that's used by its createPackageJsonInfo (which then makes its package.json cache).

Copy link
Member

Choose a reason for hiding this comment

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

Yes, JSON.parse should be in a try/catch, particularly when reading a local package.json as opposed to one in node_modules. It’s virtually impossible for a node_modules package.json to be malformed so I don’t care as much there. My point was only that we shouldn’t be creating TypeScript AST nodes and issuing parse diagnostics just to get an object representation of a JSON file.

diff --git a/src/compiler/moduleSpecifiers.ts b/src/compiler/moduleSpecifiers.ts
index 4dcf24ea4b..d6022afe4b 100644
--- a/src/compiler/moduleSpecifiers.ts
+++ b/src/compiler/moduleSpecifiers.ts
@@ -90,7 +90,6 @@ import {
     pathIsBareSpecifier,
     pathIsRelative,
     PropertyAccessExpression,
-    readJson,
     removeExtension,
     removeFileExtension,
     removeSuffix,
@@ -894,11 +893,11 @@ function tryGetModuleNameFromPackageJsonImports(moduleFileName: string, sourceDi
     }
     const packageJsonPath = combinePaths(ancestorDirectoryWithPackageJson, "package.json");
     const cachedPackageJson = host.getPackageJsonInfoCache?.()?.getPackageJsonInfo(packageJsonPath);
-    if (typeof cachedPackageJson !== "object" && cachedPackageJson !== undefined) {
+    if (typeof cachedPackageJson !== "object" && cachedPackageJson !== undefined || !host.fileExists(packageJsonPath)) {
         return undefined;
     }
-    const packageJsonContent = cachedPackageJson?.contents.packageJsonContent || readJson(packageJsonPath, host as { readFile(fileName: string): string | undefined });
-    const imports = (packageJsonContent as any).imports;
+    const packageJsonContent = cachedPackageJson?.contents.packageJsonContent || JSON.parse(host.readFile(packageJsonPath)!);
+    const imports = packageJsonContent?.imports;
     if (!imports) {
         return undefined;
     }
PR Backlog automation moved this from Waiting on reviewers to Needs merge Aug 16, 2023
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.

if (typeof cachedPackageJson !== "object" && cachedPackageJson !== undefined) {
return undefined;
}
const packageJsonContent = cachedPackageJson?.contents.packageJsonContent || readJson(packageJsonPath, host as { readFile(fileName: string): string | undefined });
Copy link
Member

Choose a reason for hiding this comment

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

I'm not sure that replacing everything with JSON.parse is generally correct, because it'll throw if the file has malformed JSON. The services project has a helper, tryParseJson, which catches this, and that's used by its createPackageJsonInfo (which then makes its package.json cache).

Copy link
Member

@jakebailey jakebailey left a comment

Choose a reason for hiding this comment

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

Reading the tests, I'm a little confused about the import conditions which map to .ts files...

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 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.

PR Backlog automation moved this from Needs merge to Waiting on author Aug 16, 2023
Copy link
Member

@weswigham weswigham left a comment

Choose a reason for hiding this comment

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

None of the new tests test conditional imports - those probably need to be checked. Complex examples of nested conditions like

{
  "imports": {
    "#thing": {
        "types": { "import": "./types-esm/thing.d.mts", "require": "./types/thing.d.ts" },
        "default": { "import": "./esm/thing.mjs", "require": "./dist/thing.js" }
     }
  }
}

are actually more common in the wild than you'd think for exports, and likely have just as much utility for imports.

@lenaggar
Copy link

Just wanted to say, I'm excited about this 😍
Thank you 🙏🏻

@MrHBS
Copy link

MrHBS commented Sep 3, 2023

@emmatown Almost there!

@andrewbranch
Copy link
Member

I would love to get this into 5.3. @emmatown, are you interested in continuing? If not, someone on the team can probably address the remaining feedback and get it ready to merge.

@typescript-bot typescript-bot removed the For Backlog Bug PRs that fix a backlog bug label Sep 11, 2023
break;
case MatchingMode.Pattern:
const starPos = pathOrPattern.indexOf("*");
const leadingSlice = pathOrPattern.slice(0, starPos);
const trailingSlice = pathOrPattern.slice(starPos + 1);
if (startsWith(targetFilePath, leadingSlice) && endsWith(targetFilePath, trailingSlice)) {
if (extensionSwappedTarget && startsWith(extensionSwappedTarget, leadingSlice, ignoreCase) && endsWith(extensionSwappedTarget, trailingSlice, ignoreCase)) {
Copy link
Member

Choose a reason for hiding this comment

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

I think instead of changing starts with and endsWith, may be we need helper that uses getCanonicalFileName intead for startsWith(extensionSwappedTarget, leadingSlice, ignoreCase) && endsWith(extensionSwappedTarget, trailingSlice, ignoreCase) type of pattern (similar to tryRemovePrefix)

Copy link
Contributor

Choose a reason for hiding this comment

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

Is there a test case that I should add and cover for? Code style-wise, I think that I prefer the current approach since it feels simpler to me. But I, of course, know way less about potential edge cases of this whole section and overall coding guidelines in this codebase.

@andrewbranch andrewbranch merged commit fbcdb8c into microsoft:main Dec 21, 2023
19 checks passed
PR Backlog automation moved this from Waiting on author to Done Dec 21, 2023
@Andarist Andarist deleted the auto-import-package-json-imports branch December 21, 2023 17:52
chriskrycho added a commit to chriskrycho/repro-ts-5.4-glimmer-exports-issue that referenced this pull request Jan 9, 2024
This shows a failure mode around a package which (oddly!) publishes an
`index.ts` file in its root but also specifies both the `types` and
`exports.types` keys in `package.json` as pointing to an emitted `.d.ts`
file in a `dist` directory. If the `index.ts` is removed or renamed,
`tsc` correctly uses `types` to resolve the location.

This appears to have been introduced by microsoft/TypeScript#55015.
@webpro
Copy link

webpro commented Feb 29, 2024

Inspired by and in anticipation of this feature being released, I wrote https://www.webpro.nl/articles/using-subpath-imports-and-path-aliases (please let me know if there's mistakes). Thanks @emmatown and @Andarist for the hard work!

// @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).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
For Milestone Bug PRs that fix a bug with a specific milestone
Projects
PR Backlog
  
Done
Development

Successfully merging this pull request may close these issues.

[suggestions] Provide import suggestions for imports from package.json "imports" field