Skip to content

Completions from template literal types #59794

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

Open
wants to merge 7 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
84 changes: 62 additions & 22 deletions src/services/stringCompletions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ import {
ensureTrailingDirectorySeparator,
equateStringsCaseSensitive,
escapeString,
every,
Extension,
fileExtensionIsOneOf,
filter,
Expand Down Expand Up @@ -133,6 +134,7 @@ import {
singleElementArray,
skipConstraint,
skipParentheses,
some,
SourceFile,
startsWith,
StringLiteralLike,
Expand All @@ -141,6 +143,7 @@ import {
supportedTSImplementationExtensions,
Symbol,
SyntaxKind,
TemplateLiteralType,
textPart,
TextSpan,
tryAndIgnoreErrors,
Expand Down Expand Up @@ -270,19 +273,29 @@ function convertStringLiteralCompletions(
};
}
case StringLiteralCompletionKind.Types: {
const textOfNode = getTextOfNode(contextToken);
const quoteChar = contextToken.kind === SyntaxKind.NoSubstitutionTemplateLiteral
? CharacterCodes.backtick
: startsWith(getTextOfNode(contextToken), "'")
: textOfNode.charCodeAt(0) === CharacterCodes.singleQuote
? CharacterCodes.singleQuote
: CharacterCodes.doubleQuote;
const entries = completion.types.map(type => ({
name: escapeString(type.value, quoteChar),
kindModifiers: ScriptElementKindModifier.none,
kind: ScriptElementKind.string,
sortText: SortText.LocationPriority,
replacementSpan: getReplacementSpanForContextToken(contextToken, position),
commitCharacters: [],
}));
let tokenTextContent = textOfNode;
if (tokenTextContent) tokenTextContent = tokenTextContent.slice(1);
if (tokenTextContent.charCodeAt(tokenTextContent.length - 1) === quoteChar) tokenTextContent = tokenTextContent.slice(0, tokenTextContent.length - 1);
const uniques = new Map<string, true>();
const entries = mapDefined(completion.types, type => {
const name = type.isStringLiteral()
? escapeString(type.value, quoteChar)
: getStringLiteralCompletionFromTemplateLiteralTypeAndTextOfNode(type, tokenTextContent, quoteChar);
return addToSeen(uniques, name) ? {
name,
kindModifiers: ScriptElementKindModifier.none,
kind: ScriptElementKind.string,
sortText: SortText.LocationPriority,
replacementSpan: getReplacementSpanForContextToken(contextToken, position),
commitCharacters: [],
} : undefined;
});
return {
isGlobalCompletion: false,
isMemberCompletion: false,
Expand All @@ -297,6 +310,22 @@ function convertStringLiteralCompletions(
}
}

function getStringLiteralCompletionFromTemplateLiteralTypeAndTextOfNode(type: TemplateLiteralType, tokenTextContent: string, quoteChar: CharacterCodes.backtick | CharacterCodes.singleQuote | CharacterCodes.doubleQuote) {
Copy link
Contributor

Choose a reason for hiding this comment

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

There is an existing algorithm for matching strings against template literal types that you can find in isTypeMatchedByTemplateLiteralType. Instead of trying to implement a custom one, I'd recommend looking into how you could reuse that one. You can expose some checker functionality on the TypeChecker using new methods annotated with /** @internal */.

Perhaps you will have to focus on the algorithm in inferFromLiteralPartsToTemplateLiteral. It could return a number for the last segment matched successfully instead of undefined. Using this number you could look at the text after it and suggest that as autocompletion.

Note that this is just a rough idea, I don't know how feasible it will be in practice 😉

It could be nice to use the same algorithm to autocomplete properties coming from index signatures with template literal keys. You don't have to implement this as part of this PR though. One thing at a time 😅

Copy link
Contributor Author

@MichalMarsalek MichalMarsalek Aug 29, 2024

Choose a reason for hiding this comment

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

Hmm, this matching works in the escaped string (source code) space, while the checker's works in raw string content space. Shouldn't be the biggest issue though, instead of escaping the type.text, we would need to unescape token.content. Especially for future generalisations, this is probably a good direction to take but this version is so simple, that I didn't feel the need to use the checker.

Copy link
Contributor Author

@MichalMarsalek MichalMarsalek Aug 29, 2024

Choose a reason for hiding this comment

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

Also, inferFromLiteralPartsToTemplateLiteral starts by first checking that both the starts and the ends of the source and the target are compatible, which is not what we need here. (Ok but that seems to be just for a perf reason? and could be skipped with a flag.)

Copy link
Contributor Author

@MichalMarsalek MichalMarsalek Aug 29, 2024

Choose a reason for hiding this comment

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

Actually, I don't think that function is very useful for matching of the partial string. I think that this requires a different algorithm by nature.

Copy link
Contributor

Choose a reason for hiding this comment

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

Could u expand on that? At the very least the rules there could guide u how to handle number interpolations and more

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Sure, there are functions like isValidTypeForTemplateLiteralPlaceholder that could certainly prove usefule, but I'm not sure the overall algorithm that finds the target text parts in the source type is that reuseable. And

it's much less clear to me how they should work

meant less "I don't know how to do it" and more "I don't know what it should be doing". Like in

const x: `a${number}` = "a/**/

what is the completion? I guess nothing? How about

const x: `a${number}` = "ab/**/

Maybe replacement of "ab with "a ?

let firstUnusedCharacterIndex = 0;
let textIndex = 0;
for (; textIndex < type.texts.length - 1; textIndex++) {
const escapedText = escapeString(type.texts[textIndex], quoteChar);
const match = tokenTextContent.indexOf(escapedText, firstUnusedCharacterIndex);
if (match < 0) {
// The current text was not matched so we suggest appending it or replacing if we aren't passed the first ${string}.
return textIndex === 0 ? escapedText : tokenTextContent + escapedText;
}
firstUnusedCharacterIndex = match + escapedText.length;
}
// We can always suggest appending the last text, since if it is already present, it can be part of the last ${string}.
return tokenTextContent + escapeString(type.texts[textIndex], quoteChar);
}

/** @internal */
export function getStringLiteralCompletionDetails(name: string, sourceFile: SourceFile, position: number, contextToken: Node | undefined, program: Program, host: LanguageServiceHost, cancellationToken: CancellationToken, preferences: UserPreferences) {
if (!contextToken || !isStringLiteralLike(contextToken)) return undefined;
Expand All @@ -315,7 +344,7 @@ function stringLiteralCompletionDetails(name: string, location: Node, completion
return match && createCompletionDetailsForSymbol(match, match.name, checker, sourceFile, location, cancellationToken);
}
case StringLiteralCompletionKind.Types:
return find(completion.types, t => t.value === name) ? createCompletionDetails(name, ScriptElementKindModifier.none, ScriptElementKind.string, [textPart(name)]) : undefined;
return find(completion.types, t => t.isStringLiteral() && t.value === name) ? createCompletionDetails(name, ScriptElementKindModifier.none, ScriptElementKind.string, [textPart(name)]) : undefined;
default:
return Debug.assertNever(completion);
}
Expand Down Expand Up @@ -378,9 +407,10 @@ interface StringLiteralCompletionsFromProperties {
readonly symbols: readonly Symbol[];
readonly hasIndexSignature: boolean;
}
type StringLiteralLikeType = StringLiteralType | TemplateLiteralType;
interface StringLiteralCompletionsFromTypes {
readonly kind: StringLiteralCompletionKind.Types;
readonly types: readonly StringLiteralType[];
readonly types: readonly (StringLiteralLikeType)[];
readonly isNewIdentifier: boolean;
}
type StringLiteralCompletion = { readonly kind: StringLiteralCompletionKind.Paths; readonly paths: readonly PathCompletion[]; } | StringLiteralCompletionsFromProperties | StringLiteralCompletionsFromTypes;
Expand Down Expand Up @@ -411,7 +441,11 @@ function getStringLiteralCompletionEntries(sourceFile: SourceFile, node: StringL
// });
return stringLiteralCompletionsForObjectLiteral(typeChecker, parent.parent);
}
return fromContextualType() || fromContextualType(ContextFlags.None);
const fromContextualTypeWithCompletionFlag = fromContextualType();
if (fromContextualTypeWithCompletionFlag && some(fromContextualTypeWithCompletionFlag.types, t => t.isStringLiteral())) {
return fromContextualTypeWithCompletionFlag;
}
return fromContextualType(ContextFlags.None);

case SyntaxKind.ElementAccessExpression: {
const { expression, argumentExpression } = parent as ElementAccessExpression;
Expand Down Expand Up @@ -456,7 +490,7 @@ function getStringLiteralCompletionEntries(sourceFile: SourceFile, node: StringL
if (!contextualTypes) {
return;
}
const literals = contextualTypes.types.filter(literal => !tracker.hasValue(literal.value));
const literals = contextualTypes.types.filter(literal => literal.isStringLiteral() && !tracker.hasValue(literal.value));
return { kind: StringLiteralCompletionKind.Types, types: literals, isNewIdentifier: false };

case SyntaxKind.ImportSpecifier:
Expand All @@ -476,8 +510,13 @@ function getStringLiteralCompletionEntries(sourceFile: SourceFile, node: StringL
const uniques = exports.filter(e => e.escapedName !== InternalSymbolName.Default && !existing.has(e.escapedName));
return { kind: StringLiteralCompletionKind.Properties, symbols: uniques, hasIndexSignature: false };

default:
return fromContextualType() || fromContextualType(ContextFlags.None);
default: {
const fromContextualTypeWithCompletionFlag = fromContextualType();
if (fromContextualTypeWithCompletionFlag && some(fromContextualTypeWithCompletionFlag.types, t => t.isStringLiteral())) {
return fromContextualTypeWithCompletionFlag;
}
return fromContextualType(ContextFlags.None);
}
}

function fromUnionableLiteralType(grandParent: Node): StringLiteralCompletionsFromTypes | StringLiteralCompletionsFromProperties | undefined {
Expand All @@ -486,7 +525,7 @@ function getStringLiteralCompletionEntries(sourceFile: SourceFile, node: StringL
case SyntaxKind.TypeReference: {
const typeArgument = findAncestor(parent, n => n.parent === grandParent) as LiteralTypeNode;
if (typeArgument) {
return { kind: StringLiteralCompletionKind.Types, types: getStringLiteralTypes(typeChecker.getTypeArgumentConstraint(typeArgument)), isNewIdentifier: false };
return { kind: StringLiteralCompletionKind.Types, types: getStringLiteralLikeTypes(typeChecker.getTypeArgumentConstraint(typeArgument)), isNewIdentifier: false };
}
return undefined;
}
Expand All @@ -511,7 +550,7 @@ function getStringLiteralCompletionEntries(sourceFile: SourceFile, node: StringL
if (result.kind === StringLiteralCompletionKind.Properties) {
return { kind: StringLiteralCompletionKind.Properties, symbols: result.symbols.filter(sym => !contains(alreadyUsedTypes, sym.name)), hasIndexSignature: result.hasIndexSignature };
}
return { kind: StringLiteralCompletionKind.Types, types: result.types.filter(t => !contains(alreadyUsedTypes, t.value)), isNewIdentifier: false };
return { kind: StringLiteralCompletionKind.Types, types: result.types.filter(t => t.isStringLiteral() && !contains(alreadyUsedTypes, t.value)), isNewIdentifier: false };
}
default:
return undefined;
Expand All @@ -521,7 +560,7 @@ function getStringLiteralCompletionEntries(sourceFile: SourceFile, node: StringL
function fromContextualType(contextFlags: ContextFlags = ContextFlags.Completions): StringLiteralCompletionsFromTypes | undefined {
// Get completion for string literal from string literal type
// i.e. var x: "hi" | "hello" = "/*completion position*/"
const types = getStringLiteralTypes(getContextualTypeFromParent(node, typeChecker, contextFlags));
const types = getStringLiteralLikeTypes(getContextualTypeFromParent(node, typeChecker, contextFlags));
if (!types.length) {
return;
}
Expand Down Expand Up @@ -559,7 +598,7 @@ function getStringLiteralCompletionsFromSignature(call: CallLikeExpression, arg:
}
}
isNewIdentifier = isNewIdentifier || !!(type.flags & TypeFlags.String);
return getStringLiteralTypes(type, uniques);
return getStringLiteralLikeTypes(type, uniques);
});
return length(types) ? { kind: StringLiteralCompletionKind.Types, types, isNewIdentifier } : undefined;
}
Expand Down Expand Up @@ -591,11 +630,12 @@ function stringLiteralCompletionsForObjectLiteral(checker: TypeChecker, objectLi
};
}

function getStringLiteralTypes(type: Type | undefined, uniques = new Map<string, true>()): readonly StringLiteralType[] {
function getStringLiteralLikeTypes(type: Type | undefined, uniques = new Map<string, true>()): readonly StringLiteralLikeType[] {
if (!type) return emptyArray;
type = skipConstraint(type);
return type.isUnion() ? flatMap(type.types, t => getStringLiteralTypes(t, uniques)) :
type.isStringLiteral() && !(type.flags & TypeFlags.EnumLiteral) && addToSeen(uniques, type.value) ? [type] : emptyArray;
return type.isUnion() ? flatMap(type.types, t => getStringLiteralLikeTypes(t, uniques)) :
type.isStringLiteral() && !(type.flags & TypeFlags.EnumLiteral) && addToSeen(uniques, type.value)
|| (type.flags & TypeFlags.TemplateLiteral && every((type as TemplateLiteralType).types, t => !!(TypeFlags.String & t.flags))) ? [type as StringLiteralLikeType] : emptyArray;
}

interface NameAndKind {
Expand Down
Loading