Skip to content

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

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
kronodeus opened this issue Nov 3, 2023 · 6 comments
Open
Labels
Domain: Completion Lists The issue relates to showing completion lists in an editor Experience Enhancement Noncontroversial enhancements Help Wanted You can do this Suggestion An idea for TypeScript
Milestone

Comments

@kronodeus
Copy link

kronodeus commented Nov 3, 2023

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

@RyanCavanaugh RyanCavanaugh added Suggestion An idea for TypeScript Help Wanted You can do this Domain: Completion Lists The issue relates to showing completion lists in an editor Experience Enhancement Noncontroversial enhancements labels Nov 3, 2023
@RyanCavanaugh RyanCavanaugh added this to the Backlog milestone Nov 3, 2023
@Andarist
Copy link
Contributor

Andarist commented Nov 6, 2023

It looks like a strong candidate for being a duplicate of #52898

@kronodeus
Copy link
Author

kronodeus commented Nov 7, 2023

@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
Copy link
Author

kronodeus commented Nov 7, 2023

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
Copy link
Author

kronodeus commented Nov 9, 2023

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
Copy link
Contributor

Andarist commented Nov 9, 2023

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

@kronodeus
Copy link
Author

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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Domain: Completion Lists The issue relates to showing completion lists in an editor Experience Enhancement Noncontroversial enhancements Help Wanted You can do this Suggestion An idea for TypeScript
Projects
None yet
Development

No branches or pull requests

3 participants