Skip to content

[ty] Offer string literal completion suggestions in function calls, annotated assignments, and TypedDict contexts#22154

Draft
Minibrams wants to merge 14 commits intoastral-sh:mainfrom
Minibrams:string-literal-completions
Draft

[ty] Offer string literal completion suggestions in function calls, annotated assignments, and TypedDict contexts#22154
Minibrams wants to merge 14 commits intoastral-sh:mainfrom
Minibrams:string-literal-completions

Conversation

@Minibrams
Copy link

@Minibrams Minibrams commented Dec 23, 2025

Summary

This PR adds completion logic to the ty language server to offer string literal completions when the user is typing a string literal in a function call (arg or kwarg), in the rhs of an annotated assignment, or as a key in a TypedDict-compatible object.

from typing import Literal, TypedDict

class TD(TypedDict):
    left: int
    right: str

type A = Literal['a', 'b', 'c']

def func(a: A):
     pass

func("")   # Completions offered here
v: A = ""  # Completions offered here

td: TD = {"left": 1, "right": "x"}
td[""]  # Completions offered here

This functionality addresses two of the items in the ty Code completion plan.

Fixes astral-sh/ty#2189

Test Plan

Four e2e tests have been added to ./crates/ty_server/tests/e2e/completions.rs:

fn string_literal_completions_for_calls() ...
fn string_literal_completions_for_typed_assignment() ...
fn string_literal_completions_filter_non_strings() ...
fn string_literal_completions_for_typed_dict_subscript_keys() ...

@carljm carljm removed their request for review December 23, 2025 03:15
@AlexWaygood AlexWaygood added server Related to the LSP server ty Multi-file analysis & type inference labels Dec 23, 2025
@astral-sh-bot
Copy link

astral-sh-bot bot commented Dec 23, 2025

Diagnostic diff on typing conformance tests

No changes detected when running ty on typing conformance tests ✅

@astral-sh-bot
Copy link

astral-sh-bot bot commented Dec 23, 2025

mypy_primer results

Changes were detected when running on open source projects
tornado (https://github.com/tornadoweb/tornado)
- tornado/gen.py:255:62: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `None | Awaitable[Unknown] | list[Awaitable[Unknown]] | dict[Any, Awaitable[Unknown]] | Future[Unknown]`, found `_T@next | _T@next | _VT@next`
+ tornado/gen.py:255:62: error[invalid-argument-type] Argument to bound method `__init__` is incorrect: Expected `None | Awaitable[Unknown] | list[Awaitable[Unknown]] | dict[Any, Awaitable[Unknown]] | Future[Unknown]`, found `_T@next | _VT@next | _T@next`

pydantic (https://github.com/pydantic/pydantic)
- pydantic/fields.py:943:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
+ pydantic/fields.py:943:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
- pydantic/fields.py:983:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
+ pydantic/fields.py:983:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
- pydantic/fields.py:1026:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
+ pydantic/fields.py:1026:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
- pydantic/fields.py:1066:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
+ pydantic/fields.py:1066:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
- pydantic/fields.py:1109:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
+ pydantic/fields.py:1109:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
- pydantic/fields.py:1148:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
+ pydantic/fields.py:1148:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
- pydantic/fields.py:1188:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`
+ pydantic/fields.py:1188:5: error[invalid-parameter-default] Default value of type `PydanticUndefinedType` is not assignable to annotated parameter type `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`
- pydantic/fields.py:1567:13: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, Divergent] | ((dict[str, Divergent], /) -> None) | None`, found `Top[dict[Unknown, Unknown]] | (((dict[str, Divergent], /) -> None) & ~Top[dict[Unknown, Unknown]]) | None`
+ pydantic/fields.py:1567:13: error[invalid-argument-type] Argument is incorrect: Expected `dict[str, int | float | str | ... omitted 3 union elements] | ((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) | None`, found `Top[dict[Unknown, Unknown]] | (((dict[str, int | float | str | ... omitted 3 union elements], /) -> None) & ~Top[dict[Unknown, Unknown]]) | None`

prefect (https://github.com/PrefectHQ/prefect)
- src/integrations/prefect-dbt/prefect_dbt/core/settings.py:94:28: error[invalid-assignment] Object of type `T@resolve_block_document_references | dict[str, Any]` is not assignable to `dict[str, Any]`
+ src/integrations/prefect-dbt/prefect_dbt/core/settings.py:94:28: error[invalid-assignment] Object of type `T@resolve_block_document_references | dict[str, Any] | str | ... omitted 4 union elements` is not assignable to `dict[str, Any]`
- src/integrations/prefect-dbt/prefect_dbt/core/settings.py:99:28: error[invalid-assignment] Object of type `T@resolve_variables | dict[str, Any]` is not assignable to `dict[str, Any]`
+ src/integrations/prefect-dbt/prefect_dbt/core/settings.py:99:28: error[invalid-assignment] Object of type `T@resolve_variables | str | int | ... omitted 4 union elements` is not assignable to `dict[str, Any]`
- src/prefect/cli/deploy/_core.py:86:21: error[invalid-assignment] Object of type `T@resolve_block_document_references | dict[str, Any]` is not assignable to `dict[str, Any]`
+ src/prefect/cli/deploy/_core.py:86:21: error[invalid-assignment] Object of type `T@resolve_block_document_references | dict[str, Any] | str | ... omitted 4 union elements` is not assignable to `dict[str, Any]`
- src/prefect/cli/deploy/_core.py:87:21: error[invalid-assignment] Object of type `T@resolve_variables` is not assignable to `dict[str, Any]`
+ src/prefect/cli/deploy/_core.py:87:21: error[invalid-assignment] Object of type `T@resolve_variables | str | int | ... omitted 4 union elements` is not assignable to `dict[str, Any]`
- src/prefect/deployments/steps/core.py:137:38: error[invalid-argument-type] Argument is incorrect: Expected `T@resolve_variables`, found `T@resolve_block_document_references | dict[str, Any]`
+ src/prefect/deployments/steps/core.py:137:38: error[invalid-argument-type] Argument is incorrect: Expected `T@resolve_variables`, found `T@resolve_block_document_references | dict[str, Any] | str | ... omitted 4 union elements`
- src/prefect/utilities/templating.py:320:13: error[invalid-assignment] Invalid subscript assignment with key of type `object` and value of type `T@resolve_block_document_references | dict[str, Any]` on object of type `dict[str, Any]`
+ src/prefect/utilities/templating.py:320:13: error[invalid-assignment] Invalid subscript assignment with key of type `object` and value of type `T@resolve_block_document_references | dict[str, Any] | str | ... omitted 4 union elements` on object of type `dict[str, Any]`
- src/prefect/utilities/templating.py:323:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_block_document_references | dict[str, Any]`, found `list[T@resolve_block_document_references | dict[str, Any] | Unknown]`
+ src/prefect/utilities/templating.py:323:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_block_document_references | dict[str, Any]`, found `list[T@resolve_block_document_references | dict[str, Any] | str | ... omitted 5 union elements]`
- src/prefect/utilities/templating.py:437:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `dict[object, T@resolve_variables | Unknown]`
+ src/prefect/utilities/templating.py:437:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `dict[object, T@resolve_variables | str | int | ... omitted 5 union elements]`
- src/prefect/utilities/templating.py:442:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `list[T@resolve_variables | Unknown]`
+ src/prefect/utilities/templating.py:442:16: error[invalid-return-type] Return type does not match returned value: expected `T@resolve_variables`, found `list[T@resolve_variables | str | int | ... omitted 5 union elements]`
- src/prefect/workers/base.py:232:13: error[invalid-argument-type] Argument is incorrect: Expected `T@resolve_variables`, found `T@resolve_block_document_references | dict[str, Any]`
+ src/prefect/workers/base.py:232:13: error[invalid-argument-type] Argument is incorrect: Expected `T@resolve_variables`, found `T@resolve_block_document_references | dict[str, Any] | str | ... omitted 4 union elements`
- src/prefect/workers/base.py:234:20: error[invalid-argument-type] Argument expression after ** must be a mapping type: Found `T@resolve_variables`
+ src/prefect/workers/base.py:234:20: error[invalid-argument-type] Argument expression after ** must be a mapping type: Found `T@resolve_variables | str | int | ... omitted 4 union elements`

static-frame (https://github.com/static-frame/static-frame)
- static_frame/core/bus.py:675:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Bus[Any], object_]`, found `InterGetItemILocReduces[Self@iloc | Bus[Any], object_ | Self@iloc]`
+ static_frame/core/bus.py:671:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[Bus[Any], object_]`, found `InterGetItemLocReduces[Bus[Any] | Bottom[Series[Any, Any]] | ndarray[Never, Never] | ... omitted 6 union elements, object_]`
+ static_frame/core/bus.py:675:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Bus[Any], object_]`, found `InterGetItemILocReduces[Bus[Any] | ndarray[Never, Never] | TypeBlocks | ... omitted 6 union elements, object_ | Self@iloc]`
- static_frame/core/index.py:580:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[TVContainer_co@loc, TVDtype@Index]`, found `InterGetItemLocReduces[Bottom[Series[Any, Any]] | Any, TVDtype@Index]`
+ static_frame/core/index.py:580:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[TVContainer_co@loc, TVDtype@Index]`, found `InterGetItemLocReduces[Any | Bottom[Series[Any, Any]], TVDtype@Index]`
- static_frame/core/node_selector.py:526:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[TVContainer_co@InterfaceSelectQuartet, Any]`, found `InterGetItemLocReduces[Bottom[Series[Any, Any]] | Unknown, Any]`
+ static_frame/core/node_selector.py:526:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemLocReduces[TVContainer_co@InterfaceSelectQuartet, Any]`, found `InterGetItemLocReduces[Unknown | Bottom[Series[Any, Any]], Any]`
+ static_frame/core/series.py:772:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Series[Any, Any], TVDtype@Series]`, found `InterGetItemILocReduces[Series[Any, Any] | ndarray[Never, Never] | TypeBlocks | ... omitted 6 union elements, TVDtype@Series]`
- static_frame/core/series.py:4072:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[SeriesHE[Any, Any], TVDtype@SeriesHE]`, found `InterGetItemILocReduces[Bottom[Series[Any, Any]] | Bottom[Index[Any]] | TypeBlocks | ... omitted 7 union elements, TVDtype@SeriesHE]`
+ static_frame/core/series.py:4072:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[SeriesHE[Any, Any], TVDtype@SeriesHE]`, found `InterGetItemILocReduces[Bottom[Series[Any, Any]] | ndarray[Never, Never] | TypeBlocks | ... omitted 7 union elements, TVDtype@SeriesHE]`
+ static_frame/core/yarn.py:418:16: error[invalid-return-type] Return type does not match returned value: expected `InterGetItemILocReduces[Yarn[Any], object_]`, found `InterGetItemILocReduces[Yarn[Any] | Bottom[Index[Any]] | TypeBlocks | ... omitted 6 union elements, object_]`
- Found 1837 diagnostics
+ Found 1840 diagnostics

pandas-stubs (https://github.com/pandas-dev/pandas-stubs)
+ pandas-stubs/_typing.pyi:1232:16: warning[unused-ignore-comment] Unused blanket `type: ignore` directive
+ tests/frame/test_groupby.py:229:15: error[type-assertion-failure] Type `Series[Any]` does not match asserted type `Series[str | bytes | int | ... omitted 12 union elements]`
+ tests/frame/test_groupby.py:625:15: error[type-assertion-failure] Type `Series[Any]` does not match asserted type `Series[str | bytes | int | ... omitted 12 union elements]`
- Found 5167 diagnostics
+ Found 5170 diagnostics

Memory usage changes were detected when running on open source projects
flake8 (https://github.com/pycqa/flake8)
-     memo fields = ~49MB
+     memo fields = ~52MB

trio (https://github.com/python-trio/trio)
-     struct fields = ~11MB
+     struct fields = ~12MB

sphinx (https://github.com/sphinx-doc/sphinx)
-     struct fields = ~20MB
+     struct fields = ~21MB
-     memo metadata = ~73MB
+     memo metadata = ~76MB
-     memo fields = ~176MB
+     memo fields = ~185MB

prefect (https://github.com/PrefectHQ/prefect)
- TOTAL MEMORY USAGE: ~690MB
+ TOTAL MEMORY USAGE: ~725MB
-     memo metadata = ~167MB
+     memo metadata = ~176MB
-     memo fields = ~424MB
+     memo fields = ~445MB

@AlexWaygood AlexWaygood reopened this Dec 23, 2025
@AlexWaygood
Copy link
Member

Three e2e tests have been added to ./crates/ty_server/tests/e2e/completions.rs:

most of our completions tests are in crates/ty_ide/completions.rs -- could you say a bit more about why you chose to use end-to-end server tests for these?

@AlexWaygood
Copy link
Member

(Thanks for the PR! 😃)

@Minibrams
Copy link
Author

Found time to add completions for TypedDict keys as well, so went ahead and added it. I'll leave it alone for review now 😄

@Minibrams
Copy link
Author

Minibrams commented Dec 23, 2025

Three e2e tests have been added to ./crates/ty_server/tests/e2e/completions.rs:

most of our completions tests are in crates/ty_ide/completions.rs -- could you say a bit more about why you chose to use end-to-end server tests for these?

No particularly good reason, it just seemed like the natural place to put them 😄 Wasn't sure what the scope of the tests in crates/ty_ide/completions.rs was, but was sure I wanted to write e2e tests, so there they went. I tend to prefer e2e tests as I find they capture the important bits of functionality more naturally than typical unit tests do.

But I see some of the tests in crates/ty_ide/completions.rs practically do the same thing but with less setup so I can put them in there instead no problem if we prefer.

@AlexWaygood
Copy link
Member

If there's no specific reason these tests need the full e2e framework, I'd be inclined to put them with most of our other completions tests in completions.rs.

@AlexWaygood AlexWaygood changed the title [ty] Offer string literal completion suggestions in function calls, a… [ty] Offer string literal completion suggestions in function calls, annotated assignments, and TypedDict contexts Dec 23, 2025
@Minibrams
Copy link
Author

If there's no specific reason these tests need the full e2e framework, I'd be inclined to put them with most of our other completions tests in completions.rs.

Done

@AlexWaygood
Copy link
Member

(Sorry for the slow review here, most of us have been on leave over the Christmas period! Normal Astral review response times should resume from the beginning of next week 😄)

return None;
}

if cursor.is_in_string() {
Copy link
Member

Choose a reason for hiding this comment

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

I would do if let Some(literal) = cursor.string_literal() { here. That would avoid calling cursor.is_in_string() twice.

Copy link
Author

Choose a reason for hiding this comment

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

This breaks the incomplete t-string tests like no_completions_in_tstring_incomplete_double_quote since there the cursor is inside a string token, but there's no ExprStringLiteral node yet (which cursor.string_literal() requires)

Copy link
Member

Choose a reason for hiding this comment

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

I'm not following. If there's no ExprStringLiteral, then doesn't cursor.string_literal() return None and thus this function bails and no completions are provided?

Copy link
Member

Choose a reason for hiding this comment

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

Basically, string_literal() starts by bailing when cursor.is_in_string() is false. But the only call to string_literal() is guarded by the condition that cursor.is_in_string() is true. I think there should be a simplification here?

/// Determine whether we're in a syntactic place where literal suggestions make sense.
fn string_literal_site(&self) -> Option<StringLiteralSite<'m>> {
let range = TextRange::empty(self.offset);
let covering = self.covering_node(TextRange::empty(self.offset));
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
let covering = self.covering_node(TextRange::empty(self.offset));
let covering = self.covering_node(range);

}

/// Determine whether we're in a syntactic place where literal suggestions make sense.
fn string_literal_site(&self) -> Option<StringLiteralSite<'m>> {
Copy link
Member

Choose a reason for hiding this comment

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

I'm curious about the approach here. It seems fraught to need to enumerate all the possible places where the type of a string literal is known. For example:

from typing import Literal
type A = Literal["foo", "bar", "baz"]
xs: list[A] = ["<CURSOR>"]

I don't mean to suggest that you should add the above case here, but rather, is there a more general approach we can use here? This approach gets further complicated below where there is a lot logic dedicated to each particular instance. This will also only grow as more sources of string literals are supported.

It seems like we want to offer these sorts of completions anywhere a string literal is found. Can we find the type of a string literal more generally and then offer completions from there? It seems like that should be possible.

(Maybe I'm wrong about this. @AlexWaygood might be a good person to chime in here.)

Copy link
Author

Choose a reason for hiding this comment

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

That would definitely be a more robust approach, do we have any expected_type(expr) or similar API in the codebase currently? If not I can take a shot at one later today.

Copy link
Author

Choose a reason for hiding this comment

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

I have added a HasExpectedType API in SemanticModel with a expected_type() function that returns the contextual type annotation (if any), which add_string_literal_completions() now uses to offer completions anywhere the expected type of an expression is a string literal.

I also added your list-of-literals example as a test-case.

Copy link
Author

Choose a reason for hiding this comment

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

Oof, 20% performance hit. Happy to hear ideas on how to optimize. Maybe we could lazyload expected types?

@Minibrams Minibrams requested a review from Gankra as a code owner January 10, 2026 06:42
@codspeed-hq
Copy link

codspeed-hq bot commented Jan 10, 2026

CodSpeed Performance Report

Merging this PR will degrade performance by 19.45%

Comparing Minibrams:string-literal-completions (15fd69d) with main (11cc324)

Summary

❌ 5 regressed benchmarks
✅ 18 untouched benchmarks
⏩ 30 skipped benchmarks1

⚠️ Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
WallTime colour_science 86.6 s 106 s -18.28%
WallTime pandas 62.3 s 75 s -16.88%
WallTime sympy 50.5 s 53 s -4.66%
WallTime static_frame 21 s 26.1 s -19.45%
WallTime freqtrade 7.8 s 8.8 s -11.76%

Footnotes

  1. 30 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

Copy link
Member

@BurntSushi BurntSushi left a comment

Choose a reason for hiding this comment

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

Thank you for working on this! I feel like the new approach here is probably the right way to go about this instead of trying to write bespoke code for each type of string literal. I left a number of comments specifically on the completion side of things here.

I'm less well equipped to review the changes for adding HasExpectedType though. It might make sense to land that in a separate PR and get review from probably @AlexWaygood. But I'll defer to Alex to whether a separate PR makes sense.

return None;
}

if cursor.is_in_string() {
Copy link
Member

Choose a reason for hiding this comment

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

I'm not following. If there's no ExprStringLiteral, then doesn't cursor.string_literal() return None and thus this function bails and no completions are provided?

.and_then(|ty| imp(db, ty, &CompletionKindVisitor::default()))
})
}
}
Copy link
Member

Choose a reason for hiding this comment

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

I think this should go away. I'm guessing it slipped in via a bad merge or something.

return None;
}

if cursor.is_in_string() {
Copy link
Member

Choose a reason for hiding this comment

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

Basically, string_literal() starts by bailing when cursor.is_in_string() is false. But the only call to string_literal() is guarded by the condition that cursor.is_in_string() is true. I think there should be a simplification here?

fn add_string_literal_completions<'db>(
db: &'db dyn Db,
file: File,
_parsed: &ParsedModuleRef,
Copy link
Member

Choose a reason for hiding this comment

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

I think this should be removed? It's available at cursor.parsed anyway.

}
}

fn escape_for_quote(value: &str, quote: char) -> Cow<'_, str> {
Copy link
Member

Choose a reason for hiding this comment

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

It seems like you could accept QuoteStyle here and just do QuoteStyle::as_char(). And then delete the case analysis above.

Copy link
Member

Choose a reason for hiding this comment

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

Also, please add a contract to this function.

It took me a minute to figure out why this was needed, so saying more about that would help too.

Also, from a quick glance, I don't see tests that exercise a code path that uses this.

Finally, are we sure a routine like this doesn't already exist? It looks like maybe there is something in ruff_python_literal. And if not, perhaps this routine should live there. cc @MichaReiser

let insert: Box<str> = match escaped {
Cow::Borrowed(s) => Box::<str>::from(s),
Cow::Owned(s) => s.into_boxed_str(),
};
Copy link
Member

Choose a reason for hiding this comment

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

If we're just going to create a Box<str> anyway, then why have escape_for_quote return a Cow?

Even better, probably just have escape_for_quote return a Name, since that's ultimately what we want here.

};

for (value, literal_ty) in out {
let escaped = escape_for_quote(value, quote_char);
Copy link
Member

Choose a reason for hiding this comment

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

Hmmm. Somewhat recently, I changed the completion display to prioritize the actual insertion text:

let name = comp.insert.as_deref().unwrap_or(&comp.name).to_string();

But I think this implies that this will show escape quotes to the human when that really should be hidden.

I guess this means that we probably shouldn't show the insertion text in completions and instead have more sophistication around just improving name instead.

I'd be fine ignoring this for now and creating an issue when this PR gets merged tracking a fix for this.

}
Type::TypeAlias(alias) => {
collect_string_literals_from_type(db, alias.value_type(db), seen_types, out);
}
Copy link
Member

Choose a reason for hiding this comment

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

My understanding is that if we're doing recursion based on the structure of Type, then we need cycle detection. e.g.,

fn completion_kind_from_type<'db>(db: &'db dyn Db, ty: Type<'db>) -> Option<CompletionKind> {
type CompletionKindVisitor<'db> =
CycleDetector<CompletionKind, Type<'db>, Option<CompletionKind>>;
fn imp<'db>(
db: &'db dyn Db,
ty: Type<'db>,
visitor: &CompletionKindVisitor<'db>,
) -> Option<CompletionKind> {
Some(match ty {
Type::FunctionLiteral(_)
| Type::DataclassDecorator(_)
| Type::WrapperDescriptor(_)
| Type::DataclassTransformer(_)
| Type::Callable(_) => CompletionKind::Function,
Type::BoundMethod(_) | Type::KnownBoundMethod(_) => CompletionKind::Method,
Type::ModuleLiteral(_) => CompletionKind::Module,
Type::ClassLiteral(_) | Type::GenericAlias(_) | Type::SubclassOf(_) => {
CompletionKind::Class
}
// This is a little weird for "struct." I'm mostly interpreting
// "struct" here as a more general "object." ---AG
Type::NominalInstance(_)
| Type::PropertyInstance(_)
| Type::BoundSuper(_)
| Type::TypedDict(_)
| Type::NewTypeInstance(_) => CompletionKind::Struct,
Type::IntLiteral(_)
| Type::BooleanLiteral(_)
| Type::TypeIs(_)
| Type::TypeGuard(_)
| Type::StringLiteral(_)
| Type::LiteralString
| Type::BytesLiteral(_) => CompletionKind::Value,
Type::EnumLiteral(_) => CompletionKind::Enum,
Type::ProtocolInstance(_) => CompletionKind::Interface,
Type::TypeVar(_) => CompletionKind::TypeParameter,
Type::Union(union) => union
.elements(db)
.iter()
.find_map(|&ty| imp(db, ty, visitor))?,
Type::Intersection(intersection) => intersection
.iter_positive(db)
.find_map(|ty| imp(db, ty, visitor))?,
Type::Dynamic(_)
| Type::Never
| Type::SpecialForm(_)
| Type::KnownInstance(_)
| Type::AlwaysTruthy
| Type::AlwaysFalsy => return None,
Type::TypeAlias(alias) => {
visitor.visit(ty, || imp(db, alias.value_type(db), visitor))?
}
})
}
imp(db, ty, &CompletionKindVisitor::default())
}

Type::Intersection(intersection) => {
for ty in intersection.iter_positive(db) {
collect_string_literals_from_type(db, ty, seen_types, out);
}
Copy link
Member

Choose a reason for hiding this comment

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

Should this only add values present in all branches of the intersection?

collect_string_literals_from_type(db, alias.value_type(db), seen_types, out);
}
_ => {}
}
Copy link
Member

Choose a reason for hiding this comment

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

Can you add tests the cover each of these cases?

@MichaReiser
Copy link
Member

Can either of you say more about what ExpectedType is used for and what we store, ideally with an example?

@carljm
Copy link
Contributor

carljm commented Feb 14, 2026

Putting back into draft pending addressing review comments.

@carljm carljm marked this pull request as draft February 14, 2026 15:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

server Related to the LSP server ty Multi-file analysis & type inference

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Add autocompletions for TypedDict keys

5 participants