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

Proposal: If there’s a package.json, only auto-import things in it, more or less #31893

Conversation

andrewbranch
Copy link
Member

@andrewbranch andrewbranch commented Jun 13, 2019

This started out as an attempt to solve #30713 in the general case, as an alternative to #31065. As it stands, it’s possible that this doesn’t change the status of #30713 at all, depending on the specifics of the project, but my hunch is that this is, in other ways, an improvement over the status quo.

You shouldn’t import node_modules that aren’t in your package.json

Your node_modules folder has bazillions of things in it as dependencies of dependencies that you shouldn’t import directly without explicitly adding them to your own top-level dependencies (in your package.json). And yet, if any of these second-order dependencies have typings (whether included or in @types), TypeScript happily offers to import it for you. This is especially frustrating when the correct import would be offered to you were it not shadowed by a wrong one. For example, I installed @emotion/core which re-exports some symbols from @emotion/css. Obviously I want to import from my explicit dependency of @emotion/core, not one of its dependencies:

Before (GIF)

auto-import-wrong

Imports from @emotion/css because that’s the shorter path. ESLint rule no-extraneous-dependencies enabled for emphasis.

After (GIF)

auto-import-right

Imports from @emotion/core because that’s an explicit dependency in my package.json.

Special handling for Node core modules

Core modules like os and crypto are the one exception to the rule. Actually, in a TypeScript project, they’re almost not an exception, because the user is expected to have @types/node in their package.json if they’re using those modules. But in a JavaScript project supported by type acquisition in VS Code, the user will probably not explicitly install @types/node, but will still expect IntelliSense and auto-import to work for those modules if they’re writing for Node. If they’re purely writing for the browser, they expect not to see auto-import suggestions for them (#30713).

In a JavaScript project, especially one that tends not to install @types and doesn’t have a jsconfig.json, I haven’t found a bulletproof heuristic for whether or not to offer Node core module imports. Today, they’re offered if you have @types/node in your program for any reason, including as a dependency of a dependency, which happens pretty frequently.

My proposed heuristic is “are you already importing Node core modules.” If you are, we should probably help you import more of them. If you’re not, either you don’t want to import any, or we’ll help you after you write the first one by hand. We can also define this more or less conservatively, depending on where we think is the best balance between minimizing false positives, minimizing false negatives, and maximizing performance:

  • Does the file you’re editing import core modules? Super cheap to check, and if yes, we can definitely offer the auto-import. (PR currently does this.)
  • Do any other script files in the same directory as the file you’re editing import core modules? A little more expensive to check, but eliminates a lot of false negatives (not offering an import when we should), with low chance of introducing false positives (offering an import when we shouldn’t). (This might be the best middle ground IMO.)
  • Do any other script files in your program import core modules? More expensive to check, but likely not outrageously so, and could be cached. Likely introduces false positives for many projects that are primarily browser apps with a small amount of Node build scripts. (I don’t think this is a great option.)
JS auto-import from node core heuristic (GIF)

auto-import-node-same-file

writeFile is offered as an auto-import once there’s another import from a Node core module in the file.

Open questions

  1. Performance and code reuse. There are a few different places that analyze package.json files in the language service now (string literal completions for module specifiers is the other most significant). I initially tried to pull some functions from other places into some common utils, but the other use cases were just different enough that it was non-trivial to generalize without sacrificing performance. Rather than invest the time into making a general util for getting info out of package.json(s) that runs every time someone is requesting it, I wondered if it would be worthwhile to cache some of this in the language service so it can be reused for as long as the package.json stays the same, which is obviously much longer than script files on average. Thoughts?
  2. Corner cases where this implementation will break. This PR seems to work in the tests I’ve written and playing around with small projects in VS Code, but I expect there will be feedback on how I’ve approached a couple things; I may have missed some subtle complexities. I’ll leave a review of my own code inline to point out some things I wasn’t 100% confident on.
  3. Corner cases on why we might not want this at all? I think my logic is sound as far as the desirability of this feature goes, and I think the only place where we still might do the wrong thing is with JS and Node core modules, but we already do the wrong thing there some of the time. Projects almost always have a package.json from the first moment of their existence, but if they don’t we simply fall back to the current behavior of not filtering. But are there any other reasons why this might not be a good idea? Can you think of any edge cases where it’s deal-breakingly disruptive?

src/services/codefixes/importFixes.ts Outdated Show resolved Hide resolved
let seenDeps: Map<true> | undefined;
function *readPackageJsonDependencies() {
type PackageJson = Record<typeof dependencyKeys[number], Record<string, string> | undefined>;
const dependencyKeys = ["dependencies", "devDependencies", "optionalDependencies"] as const;
Copy link
Member Author

@andrewbranch andrewbranch Jun 13, 2019

Choose a reason for hiding this comment

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

I saw string completions consider peerDependencies, but I was on the fence about whether that makes sense here. Usually a peer dependency is also a dev dependency; if it’s not, we probably don’t have typings for it anyway, and would never offer an auto-import in the first place. It seems unlikely to me that anyone will notice/care whether we look there.

Copy link
Contributor

Choose a reason for hiding this comment

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

Yeah, I think excluding peer deps is the right thing to do here, if it's not in your dependencies (or through other deps) then it won't be in the tree at all

// If `!!d.name.originalKeywordKind`, this is `export { _break as break };` -- skip this and prefer the keyword completion.
// If `!!d.parent.parent.moduleSpecifier`, this is `export { foo } from "foo"` re-export, which creates a new symbol (thus isn't caught by the first check).
isExportSpecifier(d) && (d.propertyName ? isIdentifierANonContextualKeyword(d.name) : !!d.parent.parent.moduleSpecifier))) {
if (typeChecker.getMergedSymbol(symbol.parent!) !== resolvedModuleSymbol) {
Copy link
Member Author

Choose a reason for hiding this comment

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

I need to look more at this case and see if we can still always continue—I think we might need to do something similar to below, but forgot to investigate further.

Copy link
Member Author

Choose a reason for hiding this comment

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

Yep, I was right—fixed in de8ef32

src/services/completions.ts Outdated Show resolved Hide resolved
}
else {
// This is not a re-export, so see if we have any aliases pending and remove them
potentialDuplicateSymbols.delete(getSymbolId(symbol).toString());
Copy link
Member Author

Choose a reason for hiding this comment

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

I was unable to come up with a test case where this happens. I think a well-behaved language service host probably tends to order source files in such a way that we always see the original symbol first and the re-exports later, but I think it’s possible that with circular imports or a less savvy language service host, we would need this branch.

@RyanCavanaugh
Copy link
Member

cc @mjbvz

@niieani
Copy link

niieani commented Jun 14, 2019

Not sure if this is covered (I didn't see it being mentioned), but please remember about monorepos, which are configured as a single project (i.e. single {js,ts}config in the root directory).

In all cases we should use the package.json closest to the file that is being edited, rather than the one closest to the project's root ({js,ts}config).

@andrewbranch
Copy link
Member Author

andrewbranch commented Jun 14, 2019

Hey @niieani, thanks for the feedback! The behavior I’ve coded here is to look for all package.jsons in scope for the current file and offer auto-imports from all of them. You’re right that using the one closest to the project root would be insufficient, but using only the closest one I think would be too restrictive—for example, electron-react-typescript-boilerplate uses one package.json at the root for most dependencies, and a second one in app/ specifically for native dependencies that need to be built for multiple platforms when packaging.

(Once I hear some feedback from the team about whether we want to go forward with this, I’ll add a test case for this behavior too)

@@ -620,4 +666,73 @@ namespace ts.codefix {
// Need `|| "_"` to ensure result isn't empty.
return !isStringANonContextualKeyword(res) ? res || "_" : `_${res}`;
}

function createLazyPackageJsonDependencyReader(fromFile: SourceFile, host: LanguageServiceHost) {
const packageJsonPaths = findPackageJsons(getDirectoryPath(fromFile.fileName), host);
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 from tsserver perspective caching packageJson should be fairely ok since we do watch failed lookup locations and any changes in there result in recomputing program. But that's not guaranteed with other hosts since they can have their own module resolution or use the default resolution cache which doesn't watch these locations.

Copy link
Member Author

Choose a reason for hiding this comment

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

we do watch failed lookup locations and any changes in there result in recomputing program

Just to make sure I’ve interpreted this correctly: you’re saying that if a user runs npm install new-package which changes the package.json, synchronizeHostData will be called so I could update the cache there?

But that's not guaranteed with other hosts

Maybe we can implement a fast check to determine whether the cache is up-to-date (last modified timestamp? content hash?). Actually, that might be better than reading eagerly on recomputing the program, since the program will change more often than the package.json.

Copy link
Member

@sandersn sandersn left a comment

Choose a reason for hiding this comment

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

I don't understand the overall structure of the code that's changed in completions.ts. Can you explain what alias-vs-not-alias is and exported-vs-reexported? I need to get a big picture before I can understand the specific uses of things like potentatialDuplicateSymbol and coveredReExportedSymbols.

Also a few style suggestions.

src/services/completions.ts Outdated Show resolved Hide resolved
src/services/completions.ts Outdated Show resolved Hide resolved
src/services/completions.ts Outdated Show resolved Hide resolved
@andrewbranch andrewbranch requested a review from sandersn June 26, 2019 23:33
@andrewbranch
Copy link
Member Author

Thanks for the review @sandersn. I added a much more cohesive comment with diagrams and examples. It may be worth mentioning separately, though, that most of the apparent complexity is just a performance optimization. The goal is simply “don’t add aliases to the list iff the symbol they’re aliasing is in the list,” but the trick is doing that in a single pass in an arbitrary module order, aggressively caching work the type checker has already done. If we didn’t care about time complexity or calling checker.resolveAlias() way more times than we need to, the algorithm would probably look much simpler.

@andrewbranch
Copy link
Member Author

Ping @mjbvz—could I get someone from the VS Code side to give this idea a quick look? I would love to get this in before we cut the beta release. If that doesn’t happen I’ll shoot for typescript@experimental, but either way we’ll want to get VS Code’s feedback since most requests/issues in this area come via the Code repo. Thanks! 😄

@mjbvz
Copy link
Contributor

mjbvz commented Jul 3, 2019

@andrewbranch I think the idea makes a lot of sense but we will need to see how it works for some real world projects. If this PR is merged, we can ask users on VS Code 1.36+ to installed this extension and see if there are any issues reported

* occur for that symbol---that is, the original symbol is not in Bucket A, so we should include the alias. Move
* everything from Bucket C to Bucket A.
*/
function getSymbolsFromOtherSourceFileExports(/** Bucket A */ symbols: Symbol[], tokenText: string, target: ScriptTarget, host: LanguageServiceHost): void {
Copy link
Contributor

Choose a reason for hiding this comment

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

👌

Copy link
Member

@sandersn sandersn left a comment

Choose a reason for hiding this comment

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

I still need to re-read completions.ts, but here are some style nits for now.

@sandersn sandersn self-assigned this Jul 10, 2019
Copy link
Member

@sandersn sandersn left a comment

Choose a reason for hiding this comment

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

ok, here are the nits this time. I hope.

Copy link
Member

@sandersn sandersn left a comment

Choose a reason for hiding this comment

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

HERE are my minor nits, which I worked so hard to reproduce!

src/services/codefixes/importFixes.ts Outdated Show resolved Hide resolved
src/services/codefixes/importFixes.ts Show resolved Hide resolved
src/services/codefixes/importFixes.ts Outdated Show resolved Hide resolved
src/services/codefixes/importFixes.ts Show resolved Hide resolved
src/services/codefixes/importFixes.ts Outdated Show resolved Hide resolved
src/services/codefixes/importFixes.ts Outdated Show resolved Hide resolved
src/services/codefixes/importFixes.ts Outdated Show resolved Hide resolved
Copy link
Member

@sandersn sandersn left a comment

Choose a reason for hiding this comment

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

One more question and some minor suggestions for completions.ts.

src/services/completions.ts Outdated Show resolved Hide resolved
src/services/completions.ts Outdated Show resolved Hide resolved
src/services/completions.ts Outdated Show resolved Hide resolved
src/services/completions.ts Outdated Show resolved Hide resolved
src/services/completions.ts Show resolved Hide resolved
@andrewbranch andrewbranch force-pushed the enhancement/only-import-from-package-json branch from cc3615c to ac0f70a Compare July 11, 2019 22:40
@andrewbranch andrewbranch merged commit 60a1b1d into microsoft:master Jul 12, 2019
@andrewbranch andrewbranch deleted the enhancement/only-import-from-package-json branch July 12, 2019 17:09
andrewbranch added a commit that referenced this pull request Jul 17, 2019
andrewbranch added a commit to andrewbranch/TypeScript that referenced this pull request Jul 17, 2019
andrewbranch added a commit that referenced this pull request Jul 17, 2019
typescript-bot pushed a commit to typescript-bot/TypeScript that referenced this pull request Jul 17, 2019
Component commits:
f6cb90a Revert "Proposal: If there’s a package.json, only auto-import things in it, more or less (microsoft#31893)"
This reverts commit 60a1b1d.
DanielRosenwasser pushed a commit that referenced this pull request Jul 18, 2019
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

8 participants