Skip to content

Commit

Permalink
Fix path completions for typesVersions (#49154)
Browse files Browse the repository at this point in the history
* Fix path completions for typesVersions

* Add more tests

* Fix case when * is a fragment of a path component

* Once a path pattern matches, only return completions from that pattern and higher priority ones

* Fix iteration order issue

* Aesthetics
  • Loading branch information
andrewbranch authored May 19, 2022
1 parent f6a1713 commit 0921eac
Show file tree
Hide file tree
Showing 10 changed files with 339 additions and 27 deletions.
2 changes: 1 addition & 1 deletion src/compiler/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2299,7 +2299,7 @@ namespace ts {
return startsWith(getCanonicalFileName(str), getCanonicalFileName(prefix)) ? str.substring(prefix.length) : undefined;
}

function isPatternMatch({ prefix, suffix }: Pattern, candidate: string) {
export function isPatternMatch({ prefix, suffix }: Pattern, candidate: string) {
return candidate.length >= prefix.length + suffix.length &&
startsWith(candidate, prefix) &&
endsWith(candidate, suffix);
Expand Down
84 changes: 60 additions & 24 deletions src/services/stringCompletions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -448,10 +448,28 @@ namespace ts.Completions.StringCompletions {

fragment = ensureTrailingDirectorySeparator(fragment);

// const absolutePath = normalizeAndPreserveTrailingSlash(isRootedDiskPath(fragment) ? fragment : combinePaths(scriptPath, fragment)); // TODO(rbuckton): should use resolvePaths
const absolutePath = resolvePath(scriptPath, fragment);
const baseDirectory = hasTrailingDirectorySeparator(absolutePath) ? absolutePath : getDirectoryPath(absolutePath);

// check for a version redirect
const packageJsonPath = findPackageJson(baseDirectory, host);
if (packageJsonPath) {
const packageJson = readJson(packageJsonPath, host as { readFile: (filename: string) => string | undefined });
const typesVersions = (packageJson as any).typesVersions;
if (typeof typesVersions === "object") {
const versionPaths = getPackageJsonTypesVersionsPaths(typesVersions)?.paths;
if (versionPaths) {
const packageDirectory = getDirectoryPath(packageJsonPath);
const pathInPackage = absolutePath.slice(ensureTrailingDirectorySeparator(packageDirectory).length);
if (addCompletionEntriesFromPaths(result, pathInPackage, packageDirectory, extensions, versionPaths, host)) {
// A true result means one of the `versionPaths` was matched, which will block relative resolution
// to files and folders from here. All reachable paths given the pattern match are already added.
return result;
}
}
}
}

const ignoreCase = !(host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames());
if (!tryDirectoryExists(host, baseDirectory)) return result;

Expand Down Expand Up @@ -505,37 +523,51 @@ namespace ts.Completions.StringCompletions {
}
}

// check for a version redirect
const packageJsonPath = findPackageJson(baseDirectory, host);
if (packageJsonPath) {
const packageJson = readJson(packageJsonPath, host as { readFile: (filename: string) => string | undefined });
const typesVersions = (packageJson as any).typesVersions;
if (typeof typesVersions === "object") {
const versionResult = getPackageJsonTypesVersionsPaths(typesVersions);
const versionPaths = versionResult && versionResult.paths;
const rest = absolutePath.slice(ensureTrailingDirectorySeparator(baseDirectory).length);
if (versionPaths) {
addCompletionEntriesFromPaths(result, rest, baseDirectory, extensions, versionPaths, host);
}
}
}

return result;
}

/** @returns whether `fragment` was a match for any `paths` (which should indicate whether any other path completions should be offered) */
function addCompletionEntriesFromPaths(result: NameAndKind[], fragment: string, baseDirectory: string, fileExtensions: readonly string[], paths: MapLike<string[]>, host: LanguageServiceHost) {
let pathResults: { results: NameAndKind[], matchedPattern: boolean }[] = [];
let matchedPathPrefixLength = -1;
for (const path in paths) {
if (!hasProperty(paths, path)) continue;
const patterns = paths[path];
if (patterns) {
for (const { name, kind, extension } of getCompletionsForPathMapping(path, patterns, fragment, baseDirectory, fileExtensions, host)) {
// Path mappings may provide a duplicate way to get to something we've already added, so don't add again.
if (!result.some(entry => entry.name === name)) {
result.push(nameAndKind(name, kind, extension));
}
const pathPattern = tryParsePattern(path);
if (!pathPattern) continue;
const isMatch = typeof pathPattern === "object" && isPatternMatch(pathPattern, fragment);
const isLongestMatch = isMatch && (matchedPathPrefixLength === undefined || pathPattern.prefix.length > matchedPathPrefixLength);
if (isLongestMatch) {
// If this is a higher priority match than anything we've seen so far, previous results from matches are invalid, e.g.
// for `import {} from "some-package/|"` with a typesVersions:
// {
// "bar/*": ["bar/*"], // <-- 1. We add 'bar', but 'bar/*' doesn't match yet.
// "*": ["dist/*"], // <-- 2. We match here and add files from dist. 'bar' is still ok because it didn't come from a match.
// "foo/*": ["foo/*"] // <-- 3. We matched '*' earlier and added results from dist, but if 'foo/*' also matched,
// } results in dist would not be visible. 'bar' still stands because it didn't come from a match.
// This is especially important if `dist/foo` is a folder, because if we fail to clear results
// added by the '*' match, after typing `"some-package/foo/|"` we would get file results from both
// ./dist/foo and ./foo, when only the latter will actually be resolvable.
// See pathCompletionsTypesVersionsWildcard6.ts.
matchedPathPrefixLength = pathPattern.prefix.length;
pathResults = pathResults.filter(r => !r.matchedPattern);
}
if (typeof pathPattern === "string" || matchedPathPrefixLength === undefined || pathPattern.prefix.length >= matchedPathPrefixLength) {
pathResults.push({
matchedPattern: isMatch,
results: getCompletionsForPathMapping(path, patterns, fragment, baseDirectory, fileExtensions, host)
.map(({ name, kind, extension }) => nameAndKind(name, kind, extension)),
});
}
}
}

const equatePaths = host.useCaseSensitiveFileNames?.() ? equateStringsCaseSensitive : equateStringsCaseInsensitive;
const equateResults: EqualityComparer<NameAndKind> = (a, b) => equatePaths(a.name, b.name);
pathResults.forEach(pathResult => pathResult.results.forEach(pathResult => pushIfUnique(result, pathResult, equateResults)));

return matchedPathPrefixLength > -1;
}

/**
Expand Down Expand Up @@ -659,11 +691,15 @@ namespace ts.Completions.StringCompletions {

const pathPrefix = path.slice(0, path.length - 1);
const remainingFragment = tryRemovePrefix(fragment, pathPrefix);
return remainingFragment === undefined ? justPathMappingName(pathPrefix) : flatMap(patterns, pattern =>
getModulesForPathsPattern(remainingFragment, baseUrl, pattern, fileExtensions, host));
if (remainingFragment === undefined) {
const starIsFullPathComponent = path[path.length - 2] === "/";
return starIsFullPathComponent ? justPathMappingName(pathPrefix) : flatMap(patterns, pattern =>
getModulesForPathsPattern("", baseUrl, pattern, fileExtensions, host)?.map(({ name, ...rest }) => ({ name: pathPrefix + name, ...rest })));
}
return flatMap(patterns, pattern => getModulesForPathsPattern(remainingFragment, baseUrl, pattern, fileExtensions, host));

function justPathMappingName(name: string): readonly NameAndKind[] {
return startsWith(name, fragment) ? [directoryResult(name)] : emptyArray;
return startsWith(name, fragment) ? [directoryResult(removeTrailingDirectorySeparator(name))] : emptyArray;
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,6 @@

verify.completions({
marker: test.markerNames(),
exact: ["aaa", "index", "ts3.1", "zzz"],
exact: ["index", "zzz"],
isNewIdentifierLocation: true,
});
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,6 @@

verify.completions({
marker: "",
exact: ["src", "foo/"].map(name => ({ name, kind: "directory" })),
exact: ["src", "foo"].map(name => ({ name, kind: "directory" })),
isNewIdentifierLocation: true,
});
42 changes: 42 additions & 0 deletions tests/cases/fourslash/pathCompletionsTypesVersionsWildcard1.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/// <reference path="fourslash.ts" />

// @module: commonjs

// @Filename: /node_modules/foo/package.json
//// {
//// "types": "index.d.ts",
//// "typesVersions": {
//// "*": {
//// "*": ["dist/*"]
//// }
//// }
//// }

// @Filename: /node_modules/foo/nope.d.ts
//// export const nope = 0;

// @Filename: /node_modules/foo/dist/index.d.ts
//// export const index = 0;

// @Filename: /node_modules/foo/dist/blah.d.ts
//// export const blah = 0;

// @Filename: /node_modules/foo/dist/subfolder/one.d.ts
//// export const one = 0;

// @Filename: /a.ts
//// import { } from "foo//**/";

verify.completions({
marker: "",
isNewIdentifierLocation: true,
exact: ["blah", "index", "subfolder"],
});

edit.insert("subfolder/");

verify.completions({
marker: "",
isNewIdentifierLocation: true,
exact: ["one"],
});
34 changes: 34 additions & 0 deletions tests/cases/fourslash/pathCompletionsTypesVersionsWildcard2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/// <reference path="fourslash.ts" />

// @module: commonjs

// @Filename: /node_modules/foo/package.json
//// {
//// "types": "index.d.ts",
//// "typesVersions": {
//// "<=3.4.1": {
//// "*": ["ts-old/*"]
//// }
//// }
//// }

// @Filename: /node_modules/foo/nope.d.ts
//// export const nope = 0;

// @Filename: /node_modules/foo/ts-old/index.d.ts
//// export const index = 0;

// @Filename: /node_modules/foo/ts-old/blah.d.ts
//// export const blah = 0;

// @Filename: /node_modules/foo/ts-old/subfolder/one.d.ts
//// export const one = 0;

// @Filename: /a.ts
//// import { } from "foo//**/";

verify.completions({
marker: "",
isNewIdentifierLocation: true,
exact: ["nope", "ts-old"],
});
48 changes: 48 additions & 0 deletions tests/cases/fourslash/pathCompletionsTypesVersionsWildcard3.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/// <reference path="fourslash.ts" />

// @module: commonjs

// @Filename: /node_modules/foo/package.json
//// {
//// "types": "index.d.ts",
//// "typesVersions": {
//// ">=4.3.5": {
//// "browser/*": ["dist/*"]
//// }
//// }
//// }

// @Filename: /node_modules/foo/nope.d.ts
//// export const nope = 0;

// @Filename: /node_modules/foo/dist/index.d.ts
//// export const index = 0;

// @Filename: /node_modules/foo/dist/blah.d.ts
//// export const blah = 0;

// @Filename: /node_modules/foo/dist/subfolder/one.d.ts
//// export const one = 0;

// @Filename: /a.ts
//// import { } from "foo//**/";

verify.completions({
marker: "",
isNewIdentifierLocation: true,
exact: ["browser", "nope", "dist"],
});

edit.insert("browser/");

verify.completions({
isNewIdentifierLocation: true,
exact: ["blah", "index", "subfolder"],
});

edit.insert("subfolder/");

verify.completions({
isNewIdentifierLocation: true,
exact: ["one"],
});
41 changes: 41 additions & 0 deletions tests/cases/fourslash/pathCompletionsTypesVersionsWildcard4.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
/// <reference path="fourslash.ts" />

// @module: commonjs

// @Filename: /node_modules/foo/package.json
//// {
//// "types": "index.d.ts",
//// "typesVersions": {
//// ">=4.3.5": {
//// "component-*": ["cjs/components/*"]
//// }
//// }
//// }

// @Filename: /node_modules/foo/nope.d.ts
//// export const nope = 0;

// @Filename: /node_modules/foo/cjs/components/index.d.ts
//// export const index = 0;

// @Filename: /node_modules/foo/cjs/components/blah.d.ts
//// export const blah = 0;

// @Filename: /node_modules/foo/cjs/components/subfolder/one.d.ts
//// export const one = 0;

// @Filename: /a.ts
//// import { } from "foo//**/";

verify.completions({
marker: "",
isNewIdentifierLocation: true,
exact: ["component-blah", "component-index", "component-subfolder", "nope", "cjs"],
});

edit.insert("component-subfolder/");

verify.completions({
isNewIdentifierLocation: true,
exact: ["one"],
});
54 changes: 54 additions & 0 deletions tests/cases/fourslash/pathCompletionsTypesVersionsWildcard5.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
/// <reference path="fourslash.ts" />

// @module: commonjs

// @Filename: /node_modules/foo/package.json
//// {
//// "types": "index.d.ts",
//// "typesVersions": {
//// "*": {
//// "*": ["dist/*"],
//// "foo/*": ["dist/*"],
//// "bar/*": ["dist/*"],
//// "exact-match": ["dist/index.d.ts"]
//// }
//// }
//// }

// @Filename: /node_modules/foo/nope.d.ts
//// export const nope = 0;

// @Filename: /node_modules/foo/dist/index.d.ts
//// export const index = 0;

// @Filename: /node_modules/foo/dist/blah.d.ts
//// export const blah = 0;

// @Filename: /node_modules/foo/dist/foo/onlyInFooFolder.d.ts
//// export const foo = 0;

// @Filename: /node_modules/foo/dist/subfolder/one.d.ts
//// export const one = 0;

// @Filename: /a.ts
//// import { } from "foo//**/";

verify.completions({
marker: "",
isNewIdentifierLocation: true,
exact: ["blah", "index", "foo", "subfolder", "bar", "exact-match"],
});

edit.insert("foo/");

verify.completions({
isNewIdentifierLocation: true,
exact: ["blah", "index", "foo", "subfolder"],
});

edit.insert("foo/");

verify.completions({
isNewIdentifierLocation: true,
exact: ["onlyInFooFolder"],
});
Loading

0 comments on commit 0921eac

Please sign in to comment.