Open kronodeus opened 11 months ago
It looks like a strong candidate for being a duplicate of https://github.com/microsoft/TypeScript/issues/52898
@Andarist The issue described there seems to be a much more severe lack of completions for type arguments to functions. However, it is specific to functions. The issue described there does not apply to type references, which my issue relates to.
Here is a simplified example that demonstrates that functions get no completions at all for type arguments while type references do:
type OneOrTwo = "one" | "two"
// Completions do not work for functions
declare function MyFunction<T extends OneOrTwo>(): T
MyFunction<'no completions here'>()
// Completions DO work for type references
type MyType<T extends OneOrTwo> = T
MyType<'completions here'>
That issue actually seems a lot more straightforward to fix because I can't think of a reason why you couldn't route completion requests for type arguments to functions down the same code path as type arguments to type references. But maybe that's overly optimistic.
The core problem starts here in stringCompletions.ts
. When getting completions for the example code, the grandparent node of the string literal node is SyntaxKind.PropertySignature
(AST viewer link), but the switch statement in fromUnionableLiteralType
doesn't have a case for this.
Intuitively, we'd want to handle this the same way we handle the case for SyntaxKind.TypeReference
:
switch (grandParent.kind) {
// ...
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 undefined;
}
// ...
}
However, it doesn't seem trivial because the type checker is also not expecting this node hierarchy. Here in checker.ts
the getTypeArgumentConstraint
function is expecting the parent to be a TypeReference
. I believe what we would need to do is get the constraint for the actual TypeReference
node in this context which, given the example code, would be the great-great-grandparent. This constraint would correspond to the whole object, so we'd need to derive a sub-constraint for the string property inside the object.
The type checker is clearly already aware of the constraints of the string property because it is correctly showing "one" | "two"
as the type on hover. So this idea of sub-constraints for properties should be achievable with the existing type information. A general-purpose solution is probably needed here to handle other scenarios like functions or nested objects. In essence, instead of assuming the grandparent will be a TypeReference
, we walk up the ancestry until we find a TypeReference
, get the constraint for that node, and then derive a sub-constraint for node we started from.
I'd like to attempt a fix but could use some guidance. In the meantime, I hacked together a tsserver
plugin that implements completions for this scenario:
import type tss from 'typescript/lib/tsserverlibrary'
// TODO: Shouldn't be hacking internal APIs
declare module 'typescript/lib/tsserverlibrary' {
function findTokenOnLeftOfPosition(sourceFile: tss.SourceFile, position: number): tss.Node
function createTextSpanFromStringLiteralLikeContent(node: tss.StringLiteralLike): tss.TextSpan
function createTextSpanFromNode(node: tss.Node): tss.TextSpan
function quote(sourceFile: tss.SourceFile, preferences: tss.UserPreferences, text: string): string
interface TypeChecker {
getTypeArgumentConstraint(node: tss.Node): tss.Type
}
}
class Info {
constructor(
readonly pos: number,
readonly ts: typeof tss,
readonly tc: tss.TypeChecker,
readonly ls: tss.LanguageService,
readonly program: tss.Program,
readonly sourceFile: tss.SourceFile,
readonly options: tss.GetCompletionsAtPositionOptions = { quotePreference: 'auto' },
readonly formatting: tss.FormatCodeSettings = {}
) {}
}
export = ({ typescript }: { typescript: typeof tss }) => {
return {
create({ project, languageService }: tss.server.PluginCreateInfo) {
project.projectService.logger.info('Fluent TS Server Plugin initialized')
return createLanguageServiceProxy(typescript, languageService)
},
}
}
function createLanguageServiceProxy(ts: typeof tss, ls: tss.LanguageService) {
const proxy: tss.LanguageService = Object.create(ls)
proxy.getCompletionsAtPosition = (fileName, pos, options, formatting) => {
const program = ls.getProgram()
const file = program?.getSourceFile(fileName)
const tc = program?.getTypeChecker()
const completions = ls.getCompletionsAtPosition(fileName, pos, options, formatting)
return program && file && tc
? getCompletions(new Info(pos, ts, tc, ls, program, file, options, formatting), completions)
: completions
}
return proxy
}
function getCompletions(info: Info, completions = createEmptyCompletions()) {
const contextToken = info.ts.findTokenOnLeftOfPosition(info.sourceFile, info.pos)
const property = info.ts.findAncestor(contextToken, info.ts.isPropertySignature)
if (property && info.ts.findAncestor(contextToken, info.ts.isTypeReferenceNode)) {
const constraint = getTypeArgumentConstraint(contextToken, info)
if (constraint) {
const types = constraint.isUnionOrIntersection() ? constraint.types : [constraint]
completions.entries.push(...getCompletionEntries(contextToken, types, info))
}
}
return completions
}
function createEmptyCompletions(): tss.CompletionInfo {
return {
entries: [],
isGlobalCompletion: false,
isMemberCompletion: false,
isNewIdentifierLocation: false,
}
}
function getTypeArgumentConstraint(node: tss.Node, info: Info): tss.Type | undefined {
const { ts, tc } = info
if (ts.isTypeReferenceNode(node.parent)) {
return tc.getTypeArgumentConstraint(node)
} else if (ts.isPropertySignature(node.parent)) {
const typeLiteral = node.parent.parent
if (ts.isTypeLiteralNode(typeLiteral)) {
const typeLiteralConstraint = getTypeArgumentConstraint(typeLiteral, info)
const propertyConstraint = typeLiteralConstraint?.getProperty(node.parent.name.getText())
if (propertyConstraint) {
return tc.getTypeOfSymbolAtLocation(propertyConstraint, node.parent)
}
}
}
return node.parent ? getTypeArgumentConstraint(node.parent, info) : undefined
}
function getCompletionEntries(contextToken: tss.Node, types: tss.Type[], info: Info): tss.CompletionEntry[] {
const entries: tss.CompletionEntry[] = []
for (const type of types) {
if (type.isLiteral()) {
entries.push({
kind: info.ts.ScriptElementKind.string,
kindModifiers: info.ts.ScriptElementKindModifier.none,
name: getEntryText(contextToken, type.value, info),
sortText: '0', // Display at top
replacementSpan: getReplacementSpan(contextToken, info),
})
}
}
return entries
}
function getEntryText(contextToken: tss.Node, value: tss.LiteralType['value'], info: Info) {
return !info.ts.isStringLiteralLike(contextToken) && typeof value === 'string'
? info.ts.quote(info.sourceFile, info.options, value)
: value.toString()
}
function getReplacementSpan(contextToken: tss.Node, { ts, pos }: Info) {
if (ts.isStringLiteralLike(contextToken)) {
return ts.createTextSpanFromStringLiteralLikeContent(contextToken) // Replace content of string
} else if (ts.isLiteralTypeLiteral(contextToken)) {
return ts.createTextSpanFromNode(contextToken) // Replace entire literal value
} else {
return ts.createTextSpan(pos, 0) // Insert at position without replacing
}
}
For now it'll do as a workaround but I don't feel great about this as long-term solution. Some of the internal APIs in the tsserver
library would help here but are evidently not publicly accessible.
Edit:
I updated the plugin code above. It now supports a lot more usage scenarios and works for everything I tested.
If u are interested in contributing, i can offer some guidance around the codebase etc. Feel free to DM me
@Andarist Yes I would greatly appreciate that! I will reach out. I updated the plugin code above in an attempt to achieve the idea I had about deriving sub-constraints for nested type literals. It works great, but I had to utilize some internal APIs to achieve it.
Here's the critical code section:
function getTypeArgumentConstraint(node: tss.Node, info: Info): tss.Type | undefined {
const { ts, tc } = info
if (ts.isTypeReferenceNode(node.parent)) {
return tc.getTypeArgumentConstraint(node)
} else if (ts.isPropertySignature(node.parent)) {
const typeLiteral = node.parent.parent
if (ts.isTypeLiteralNode(typeLiteral)) {
const typeLiteralConstraint = getTypeArgumentConstraint(typeLiteral, info)
const propertyConstraint = typeLiteralConstraint?.getProperty(node.parent.name.getText())
if (propertyConstraint) {
return tc.getTypeOfSymbolAtLocation(propertyConstraint, node.parent)
}
}
}
return node.parent ? getTypeArgumentConstraint(node.parent, info) : undefined
}
It recursively walks up to the nearest TypeReference
ancestor and gets the top-level constraint on the type argument. The sub-constraints then propagate down through the property signatures where they are needed.
When I find time I'd love to take a stab at a permanent fix, but does this seem like generally the right direction or am I way off?
🔎 Search Terms
"tsserver", "generic object completion", "generic intellisense", "generic typeahead", "generic completion", "generic object properties", "object property completion", "typescript server", "typescript lsp"
🕗 Version & Regression Information
This is the behavior in every version I tried, and I reviewed the FAQ for entries about typeahead support for generic type arguments.
⏯ Playground Link
https://www.typescriptlang.org/play?#code/C4TwDgpgBAKuEEEA8MoQB7AgOwCYGcoByAe2wiKgB9jgB3EogPigF5YAoUSWeAIRRpMOAlADeUMACcSYAFzEyFarQaUAvi3YwOHAPR6ogUHIoAcRIlcCgMIkAtmAA2EYAEsyhaSQBur3BFwueCgAUXQAQwdnBDZeSGQiZl0DKEAZcig+cKsoWyiXd2xCADkAeVQvX39A7mgwyKcIPli4SAEJL3liDSYgA
💻 Code
🙁 Actual behavior
Getting completions between the quotes on line 5 (using
Control + Space
on Mac OS) correctly provides'one'
and'two'
as options.However, doing the same between the quotes on line 8 provides no completions.
🙂 Expected behavior
Getting completions between the quotes on both lines 5 and 8 (using
Control + Space
on Mac OS) correctly provides'one'
and'two'
as options.Additional information about the issue
The type of
prop
is correct ('one' | 'two'
) and if you enter an invalid value you get the correct type error. Therefore, TypeScript should have sufficient information to provide the exact same completions in both examples.I enabled verbose
tsserver
logging in Visual Studio Code and captured the requests and responses for these completion requests.Line 5 Request:
Line 5 Response:
Line 8 Request:
Line 8 Response:
Line 8 Logs:
As you can see, the requests are both identical except for position. But the response for the request on line 8 is empty, and there is a message indicating the position is invalid.