microsoft / TypeScript

TypeScript is a superset of JavaScript that compiles to clean JavaScript output.
https://www.typescriptlang.org
Apache License 2.0
100.46k stars 12.42k forks source link

No completions from `tsserver` for property values in generic type arguments #56299

Open kronodeus opened 11 months ago

kronodeus commented 11 months ago

🔎 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

type TypeA<T extends 'one' | 'two'> = T
type TypeB<T extends { prop: 'one' | 'two' }> = T

// ✅ Good: Completions provided
type ExampleA = TypeA<''>

// ❌ Bad: Completions NOT provided
type ExampleB = TypeB<{ prop: '' }>

🙁 Actual behavior

Getting completions between the quotes on line 5 (using Control + Space on Mac OS) correctly provides 'one' and 'two' as options.

Screenshot 2023-11-02 at 5 38 51 PM

However, doing the same between the quotes on line 8 provides no completions.

Screenshot 2023-11-02 at 5 39 05 PM

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

Screenshot 2023-11-02 at 5 44 00 PM

I enabled verbose tsserver logging in Visual Studio Code and captured the requests and responses for these completion requests.

Line 5 Request:

{
    "seq":12,
    "type":"request",
    "command":"completionInfo",
    "arguments":{
        "file":"/Users/ryan.palmer/Desktop/Test.ts",
        "line":5,
        "offset":24,
        "includeExternalModuleExports":true,
        "includeInsertTextCompletions":true,
        "triggerKind":1
    }
}

Line 5 Response:

{
    "seq":0,
    "type":"response",
    "command":"completionInfo",
    "request_seq":12,
    "success":true,
    "body":{
        "isGlobalCompletion":false,
        "isMemberCompletion":false,
        "isNewIdentifierLocation":false,
        "optionalReplacementSpan":{
            "start":{
                "line":5,
                "offset":24
            },
            "end":{
                "line":5,
                "offset":24
            }
        },
        "entries":[
            {
                "name":"one",
                "kind":"string",
                "kindModifiers":"",
                "sortText":"11",
                "replacementSpan":{
                    "start":{
                        "line":5,
                        "offset":24
                    },
                    "end":{
                        "line":5,
                        "offset":24
                    }
                }
            },
            {
                "name":"two",
                "kind":"string",
                "kindModifiers":"",
                "sortText":"11",
                "replacementSpan":{
                    "start":{
                        "line":5,
                        "offset":24
                    },
                    "end":{
                        "line":5,
                        "offset":24
                    }
                }
            }
        ]
    }
}

Line 8 Request:

{
    "seq":9,
    "type":"request",
    "command":"completionInfo",
    "arguments":{
        "file":"/Users/ryan.palmer/Desktop/Test.ts",
        "line":8,
        "offset":32,
        "includeExternalModuleExports":true,
        "includeInsertTextCompletions":true,
        "triggerKind":1
    }
}

Line 8 Response:

{
    "seq":0,
    "type":"response",
    "command":"completionInfo",
    "request_seq":9,
    "success":false,
    "message":"No content available."
}

Line 8 Logs:

Info 451  [17:32:09.801] getCompletionData: Get current token: 0.018829017877578735
Info 452  [17:32:09.801] getCompletionData: Is inside comment: 0.007900983095169067
Info 453  [17:32:09.801] getCompletionData: Get previous token: 0.10709202289581299
Info 454  [17:32:09.802] getCompletionsAtPosition: isCompletionListBlocker: 0.03820300102233887
Info 455  [17:32:09.802] Returning an empty list because completion was requested in an invalid position.

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.

Andarist commented 11 months ago

It looks like a strong candidate for being a duplicate of https://github.com/microsoft/TypeScript/issues/52898

kronodeus commented 11 months ago

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

kronodeus commented 11 months ago

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.

kronodeus commented 10 months ago

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.

Andarist commented 10 months ago

If u are interested in contributing, i can offer some guidance around the codebase etc. Feel free to DM me

kronodeus commented 10 months ago

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