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

Initial import/export/self-name support #68

Closed
wants to merge 14 commits into from

Conversation

weswigham
Copy link
Owner

@weswigham weswigham commented Sep 15, 2021

This is the final part of the node12 module resolution work - the new resolver itself. It has support for package exports, imports and package self-name lookup, in addition to implementing the rules for both the cjs and esm resolution modes (eg, no index or automatic extension resolution in esm flavored imports).

Also containing extensive refactoring around our resolution caching infrastructure to handle how the same import (eg, "./foo" could be resolved in one of two modes (the cjs or esm resolution mode), and can have separate resolution results for both).

TODO:


function getUnderlyingCacheKey(specifier: string, mode: ModuleKind.CommonJS | ModuleKind.ESNext | undefined) {
const result = mode === undefined ? specifier : `${mode}|${specifier}`;
memoizedReverseKeys.set(result, [specifier, mode]);

Choose a reason for hiding this comment

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

I’m still nervous about every cache access now also doing a set in another map. Would it at least be possible to skip this under the old module resolution settings? In those cases, mode will always be undefined, so forEach wouldn’t need to do the lookup and therefore we wouldn’t need to do the set?

Copy link
Owner Author

Choose a reason for hiding this comment

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

No? Then I'd need the compiler options in the cache key/arguments and I'd need to take that in as a parameter and switch on that and... It doesn't really change much. Plus, I really don't think this is going to matter much - this doesn't increase the complexity of the operation at all (there's no extra iteration being done) and only increases the memory usage by the size of a few pointers per element. This is highly unlikely to be any kind of bottleneck, imo.

Choose a reason for hiding this comment

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

Then I'd need the compiler options in the cache key

The cache can be used across different programs?

Choose a reason for hiding this comment

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

I should be able to test this with --extendedDiagnostics... I don’t think our perf suite reports module resolution time (or program construction time) directly, IIRC.

Copy link
Owner Author

Choose a reason for hiding this comment

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

The cache can be used across different programs?

Right, exactly - I'd be trading the one thing I needed a reverse lookup cache for for two (since it's not like I can guarantee the new resolution modes won't ever be used in a context prior to cache construction). It wouldn't really help - the key is already minimal.

Copy link

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

I won't block on the map thing, it's something that can be optimized later if needed

Copy link
Collaborator

@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's what I have so far. Nothing substantive since I've just started on the bulk of the new stuff in moduleNameResolver.

Also, my two primary work computers are only able to display lines about half as long as the longest length. Please break up the lines more! In the meantime I guess I can open the review on two screens at once or something.

src/compiler/checker.ts Show resolved Hide resolved
src/compiler/moduleNameResolver.ts Show resolved Hide resolved
src/compiler/moduleNameResolver.ts Outdated Show resolved Hide resolved
src/compiler/moduleNameResolver.ts Outdated Show resolved Hide resolved
);
}

function nodeNextModuleNameResolverWorker(features: NodeResolutionFeatures, moduleName: string, containingFile: string, compilerOptions: CompilerOptions, host: ModuleResolutionHost, cache?: ModuleResolutionCache, redirectedReference?: ResolvedProjectReference, resolutionMode?: ModuleKind.CommonJS | ModuleKind.ESNext): ResolvedModuleWithFailedLookupLocations {
Copy link
Collaborator

Choose a reason for hiding this comment

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

why does node12 delegate to nodeNext...Worker instead of node...Worker or something?

Copy link
Owner Author

Choose a reason for hiding this comment

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

nodeNext...Worker sets the appropriate EsmMode Feature flag using the resolutionMode parameter before calling into node...Worker (which no longer uses resolutionMode).

src/compiler/moduleNameResolver.ts Outdated Show resolved Hide resolved
}

return loadModuleFromFileNoImplicitExtensions(extensions, candidate, onlyRecordFailures, state);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

can you move this closer to its usage, after getLoadModuleFromTargetImportOrExport?

Copy link
Owner Author

@weswigham weswigham Sep 23, 2021

Choose a reason for hiding this comment

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

Its primary usage is immediately above this in loadFromFile (which is in turn already used all over) - in getLoadModuleFromTargetImportOrExport it's a secondary, new usage?

Copy link
Collaborator

Choose a reason for hiding this comment

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

loadJSOrExactTSFileName looks like it's new in the review. I think I put my comment on a confusing line that indicated a different function.

Copy link
Collaborator

@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 haven't looked at the tests yet, but the rest of the code looks like it matches what we've discussed in various meeting (as well as I understand it). So far I just have a couple of questions and readability suggestions, nothing substantive.

}

return loadModuleFromFileNoImplicitExtensions(extensions, candidate, onlyRecordFailures, state);
}
Copy link
Collaborator

Choose a reason for hiding this comment

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

loadJSOrExactTSFileName looks like it's new in the review. I think I put my comment on a confusing line that indicated a different function.

function loadModuleFromImportsOrExports(extensions: Extensions, state: ModuleResolutionState, cache: ModuleResolutionCache | undefined, redirectedReference: ResolvedProjectReference | undefined, moduleName: string, lookupTable: object, scope: PackageJsonInfo, isImports: boolean): SearchResult<Resolved> | undefined {
const loadModuleFromTargetImportOrExport = getLoadModuleFromTargetImportOrExport(extensions, state, cache, redirectedReference, moduleName, scope, isImports);

if (!endsWith(moduleName, directorySeparator) && moduleName.indexOf("*") === -1 && hasProperty(lookupTable, moduleName)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

lookupTable is cast to {[x: string]: unknown} 4 out of 5 times. Maybe that should be its type and the callers should cast? Or maybe the cast should happen once at the beginning?

Copy link
Owner Author

Choose a reason for hiding this comment

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

Nah - it's possibly null or a string earlier on as well - we just don't have a "dictionary-like object" guard, so there's casts.

return toSearchResult(/*value*/ undefined);
}
const useCaseSensitiveFileNames = typeof state.host.useCaseSensitiveFileNames === "function" ? state.host.useCaseSensitiveFileNames() : state.host.useCaseSensitiveFileNames;
const directoryPath = toPath(combinePaths(directory, "dummy"), state.host.getCurrentDirectory?.(), createGetCanonicalFileName(useCaseSensitiveFileNames === undefined ? true : useCaseSensitiveFileNames));
Copy link
Collaborator

Choose a reason for hiding this comment

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

why is 'dummy' added after directory?

Copy link
Owner Author

Choose a reason for hiding this comment

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

Because the function expects a file in the directory for the path and not a directory path - the first thing it does is pop off that last segment. We do similar elsewhere in the resolver.

const target = (lookupTable as {[idx: string]: unknown})[moduleName];
return loadModuleFromTargetImportOrExport(target, /*subpath*/ "", /*pattern*/ false);
}
const expandingKeys = sort(filter(getOwnKeys(lookupTable as MapLike<unknown>), k => k.indexOf("*") !== -1 || endsWith(k, "/")), (a, b) => a.length - b.length);
Copy link
Collaborator

Choose a reason for hiding this comment

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

can we use String.includes/String.endsWith yet? MDN says ES2015. (and we use startsWith/endsWith in the checker)

Copy link
Owner Author

Choose a reason for hiding this comment

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

Our built-in endsWith function uses string.prototype.endsWith if it's available.

}
}

function matchesPatternWithTrailer(target: string, name: string) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

  1. This would be easier to read if all 3 cases moved their detection AND extraction code into this function, and the containing loop's body was something like loadModuleFromTargetImportOrExport(...matchesPatternWithTrailer(lookupTable, potentialTarget)).

  2. matchesPatternWithTrailer's code could be easier to read if it handled cases in order ['fixed', 'trailing*', 'pattern*trailer'].

Copy link
Owner Author

Choose a reason for hiding this comment

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

Most of the current export/import map lookup structure is structured like it is because it matches how the node spec says it should be structured - departing too much from that is going to make it harder to update or debug future changes, I think.

}
}
}
else if (target === null) { // eslint-disable-line no-null/no-null
Copy link
Collaborator

Choose a reason for hiding this comment

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

you could drop the target !== null above if you move this inside the typeof target === 'object' branch

Copy link
Owner Author

Choose a reason for hiding this comment

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

See prior comment on "it's this order because the node resolver spec days it's in this order".

if (!startsWith(key, "types@")) return false;
const range = VersionRange.tryParse(key.substring("types@".length));
if (!range) return false;
return range.test(version);
Copy link
Collaborator

Choose a reason for hiding this comment

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

where is version declared?

Copy link
Owner Author

Choose a reason for hiding this comment

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

In core.ts. I'd normally write ts.version to clarify, but our lint settings don't like that, so... 🤷

return isImportCall(walkUpParenthesizedExpressions(usage.parent)) ? ModuleKind.ESNext : ModuleKind.CommonJS;
}
// in esm files, import=require statements are cjs format, otherwise everything is esm
// imports are only parent'd up to their containing declaration/expression, so access farther parents with care
Copy link
Collaborator

Choose a reason for hiding this comment

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

👍 for this comment

Copy link
Owner Author

Choose a reason for hiding this comment

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

The "imports are parent's up to their containing declaration" is a new change I made, btw, because it was hideously complicated to calculate this without those parent pointers (I did for awhile - it was expensive and bug prone - better to just set the parents when the imports are collected unconditionally).

Copy link
Collaborator

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

Just a couple of ideas for better wording. I'm not sure they're improvements however.

// These should _mostly_ work - `import = require` always desugars to require calls, which do have extension and index resolution (but can't load anything that resolves to esm!)
import m24 = require("./");
~~~~
!!! error TS1471: Module './' cannot be imported using this construct. The specifier only resolves to an es module, which cannot be imported synchronously. Use dynamic import instead.
Copy link
Collaborator

Choose a reason for hiding this comment

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

It might be better to uppercase ES in the error. I'm not sure.

Choose a reason for hiding this comment

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

👍 I requested this change on a different error message in one of the other PRs. Let’s use capital ES throughout.

import * as cjsi from "inner/cjs";
import * as mjsi from "inner/mjs";
~~~~~~~~~~~
!!! error TS1471: Module 'inner/mjs' cannot be imported using this construct. The specifier only resolves to an es module, which cannot be imported synchronously. Use dynamic import instead.
Copy link
Collaborator

Choose a reason for hiding this comment

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

'statement' reads better than 'construct' here -- it's more specific and common.

Copy link
Owner Author

@weswigham weswigham Sep 24, 2021

Choose a reason for hiding this comment

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

I use construct because it also applies to cjs require, which is an expression, and having two error messages just to distinguish statement vs expression seems gratuitous.

@weswigham
Copy link
Owner Author

Merged in microsoft#45884

@weswigham weswigham closed this Sep 24, 2021
@sandersn sandersn deleted the imports-exports-selfs branch October 14, 2021 15:59
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
3 participants