Skip to content

Commit bca8525

Browse files
kinto0meta-codesync[bot]
authored andcommitted
Route TSP None through the stdlib's version-aware NoneType
Summary: Follow-up to D110961669 addressing the review feedback on #4043. D110961669 correctly stopped emitting the off-spec `none` `BuiltInType` and began encoding `None` as a `NoneType` `ClassType`, but it re-derived the `NoneType` location by name (`types` + `"NoneType"`), hardcoding `ModuleName::types()` and bundled `types.pyi`. That diverges from pyrefly's authoritative `Stdlib::none_type()`, which resolves `NoneType` from `types` only on Python 3.10+ and from `_typeshed` on older versions. On a project targeting < 3.10 the TSP declaration would therefore point at a different module than pyrefly's own `None` resolution and goto-definition. This routes `None` through the real `Stdlib::none_type()` `ClassType` via the existing `convert_class_type(..., TypeFlags::INSTANCE)` path. `TypeConverter` now takes the resolved class, threaded down from the `server.rs` caller (which already computes `get_stdlib` on the warm transaction). Because the encoding reuses the actual class, it inherits the version-correct module and range automatically and is identical to an explicit `types.NoneType` annotation — no duplicated module/name/path logic. The `types_class`/`types_class_declaration`/`make_types_class_declaration` helpers (and their hardcoded `types.pyi`) are removed; resolver-free unit tests build a stand-in `NoneType` class so they still exercise the production path. Tests are extended to cover the concrete #4035 symptom: a new interaction test asserts `str | None` round-trips with its `None` member as a reconstructable `NoneType` `ClassType`, and the existing test documents that the downstream Pylance hover (`str | Unknown` → `str | None`) should be confirmed against a real client. Reviewed By: stroxler Differential Revision: D111355122 fbshipit-source-id: 0486507ab1ab0b766d8cbca4cbf34309f4433d08
1 parent 9a08906 commit bca8525

3 files changed

Lines changed: 115 additions & 67 deletions

File tree

pyrefly/lib/lsp/non_wasm/server.rs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6329,11 +6329,16 @@ impl Server {
63296329
let resolve_export = |module_name: ModuleName, name: &Name| {
63306330
resolve_export_location(transaction, source_handle, module_name, name)
63316331
};
6332+
// `None` is encoded as the stdlib's version-aware `NoneType` class.
6333+
// Computing `ty` already populated this transaction's `Stdlib`, so
6334+
// `get_stdlib` stays on the warm path (see the doc comment above).
6335+
let stdlib = transaction.get_stdlib(source_handle);
63326336
convert_type_with_resolvers(
63336337
ty,
63346338
Some(&resolve_func_range),
63356339
Some(&resolve_module_path),
63366340
Some(&resolve_export),
6341+
stdlib.none_type(),
63376342
)
63386343
}
63396344
}

pyrefly/lib/test/tsp/tsp_interaction/get_type_queries.rs

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,14 @@ fn test_get_computed_type_string_is_class() {
281281

282282
#[test]
283283
fn test_get_computed_type_none_is_class() {
284+
// Regression test for https://github.com/facebook/pyrefly/issues/4035:
285+
// `None` must be a `NoneType` `ClassType`, not an off-spec `none`
286+
// `BuiltInType`, so consumers can reconstruct unions like `str | None`.
287+
// The declaration is sourced from the stdlib's `NoneType` class, so it
288+
// carries a real location (e.g. `types.pyi`) that matches goto-definition
289+
// rather than a synthesized stub. This asserts the wire shape; the
290+
// downstream Pylance hover fix (`str | Unknown` → `str | None`) should be
291+
// confirmed against a real client, which this test harness cannot drive.
284292
let (mut tsp, file_uri, snapshot) = setup_project("x = None\n");
285293

286294
let result = get_computed_type_ok(&mut tsp, &file_uri, 0, 0, snapshot);
@@ -290,6 +298,50 @@ fn test_get_computed_type_none_is_class() {
290298
let name = declaration.get("name").and_then(|v| v.as_str());
291299
assert_eq!(name, Some("NoneType"), "Expected class name 'NoneType'");
292300

301+
// The declaration points at NoneType's real definition, not an empty stub.
302+
let uri = declaration
303+
.get("node")
304+
.and_then(|n| n.get("uri"))
305+
.and_then(|v| v.as_str())
306+
.expect("Expected declaration node URI");
307+
assert!(
308+
!uri.is_empty(),
309+
"Expected a real NoneType definition URI, got empty"
310+
);
311+
312+
tsp.shutdown();
313+
}
314+
315+
#[test]
316+
fn test_get_computed_type_str_or_none_union_reconstructs_none() {
317+
// The concrete #4035 symptom: `str | None` must round-trip with its `None`
318+
// member encoded as a reconstructable `NoneType` `ClassType` (previously an
319+
// off-spec `none` `BuiltInType` that Pylance rendered as `Unknown`).
320+
let code = "x: str | None = None\n";
321+
let (mut tsp, file_uri, snapshot) = setup_project(code);
322+
323+
tsp.server.get_declared_type(&file_uri, 0, 0, snapshot);
324+
let resp = tsp.client.receive_response_skip_notifications();
325+
let result = resp.result.expect("Expected result");
326+
assert_kind(&result, TypeKind::Union);
327+
328+
let sub_types = result
329+
.get("subTypes")
330+
.and_then(|v| v.as_array())
331+
.expect("Expected subTypes array");
332+
let has_none_class = sub_types.iter().any(|member| {
333+
member.get("kind").and_then(|v| v.as_u64()) == Some(TypeKind::Class as u64)
334+
&& member
335+
.get("declaration")
336+
.and_then(|d| d.get("name"))
337+
.and_then(|v| v.as_str())
338+
== Some("NoneType")
339+
});
340+
assert!(
341+
has_none_class,
342+
"Expected a NoneType Class member in the union, got {sub_types:?}"
343+
);
344+
293345
tsp.shutdown();
294346
}
295347

pyrefly/lib/tsp/type_conversion.rs

Lines changed: 58 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@
2626
//! - `Any`, `Never`, `Ellipsis` → TSP `BuiltInType`.
2727
//! - Solver-internal types → TSP `BuiltInType` with a representative name.
2828
//!
29-
//! Note: `None` is emitted as a `types.NoneType` `ClassType`, not as a
30-
//! `BuiltInType`, because `BuiltInType.name` is restricted to protocol
31-
//! sentinel names.
29+
//! Note: `None` is emitted as a `NoneType` `ClassType`, not as a `BuiltInType`,
30+
//! because `BuiltInType.name` is restricted to protocol sentinel names. See
31+
//! [`convert_type_with_resolvers`] for how the version-correct class is sourced.
3232
//! All `Type` variants are explicitly handled; no types fall through to a
3333
//! generic `SynthesizedType` stub.
3434
@@ -110,16 +110,24 @@ pub type ExportLocationResolver<'a> =
110110

111111
/// Convert a pyrefly `Type` to a TSP protocol `Type` using optional
112112
/// source-range and module-URI resolvers.
113+
///
114+
/// `none_type` is the stdlib's authoritative `NoneType` class, used to encode
115+
/// `None`. Passing the real class (rather than re-deriving `NoneType` by name)
116+
/// keeps the declaration version-correct — sourced from `types` on Python 3.10+
117+
/// and `_typeshed` before — and identical to an explicit `types.NoneType`
118+
/// annotation.
113119
pub fn convert_type_with_resolvers<'a>(
114120
ty: &PyreflyType,
115121
func_range_resolver: Option<&'a FuncRangeResolver<'a>>,
116122
module_path_resolver: Option<&'a ModulePathResolver<'a>>,
117123
export_location_resolver: Option<&'a ExportLocationResolver<'a>>,
124+
none_type: &'a PyreflyClassType,
118125
) -> TspType {
119126
TypeConverter {
120127
resolve_func_range: func_range_resolver,
121128
resolve_module_path: module_path_resolver,
122129
resolve_export: export_location_resolver,
130+
none_type,
123131
}
124132
.convert(ty)
125133
}
@@ -131,14 +139,43 @@ pub fn convert_type_with_resolvers<'a>(
131139
/// locations are needed.
132140
#[cfg(test)]
133141
pub fn convert_type(ty: &PyreflyType) -> TspType {
134-
convert_type_with_resolvers(ty, None, None, None)
142+
convert_type_with_resolvers(ty, None, None, None, &test_none_type())
143+
}
144+
145+
/// Build a stand-in for `Stdlib::none_type()` used by the resolver-free tests,
146+
/// which have no real stdlib. Mirrors the real class closely enough to exercise
147+
/// the production `None` path: a top-level `NoneType` class in bundled `types`.
148+
#[cfg(test)]
149+
fn test_none_type() -> PyreflyClassType {
150+
use pyrefly_python::module::Module;
151+
use pyrefly_python::nesting_context::NestingContext;
152+
use pyrefly_types::class::ClassDefIndex;
153+
use pyrefly_types::types::TArgs;
154+
use ruff_python_ast::Identifier;
155+
156+
let module = Module::new(
157+
ModuleName::types(),
158+
ModulePath::bundled_typeshed(PathBuf::from("types.pyi")),
159+
Arc::new(String::new()),
160+
);
161+
let class = Class::new(
162+
ClassDefIndex(0),
163+
Identifier::new(Name::new("NoneType"), TextRange::default()),
164+
NestingContext::toplevel(),
165+
module,
166+
None,
167+
);
168+
PyreflyClassType::new(class, TArgs::default())
135169
}
136170

137171
/// Holds an optional range resolver and drives recursive type conversion.
138172
struct TypeConverter<'a> {
139173
resolve_func_range: Option<&'a FuncRangeResolver<'a>>,
140174
resolve_module_path: Option<&'a ModulePathResolver<'a>>,
141175
resolve_export: Option<&'a ExportLocationResolver<'a>>,
176+
/// The stdlib's authoritative `NoneType` class, used to encode `None`; see
177+
/// [`convert_type_with_resolvers`].
178+
none_type: &'a PyreflyClassType,
142179
}
143180

144181
impl TypeConverter<'_> {
@@ -148,7 +185,8 @@ impl TypeConverter<'_> {
148185
// --- Built-in special types ---
149186
PyreflyType::Any(_) => builtin("any"),
150187
PyreflyType::Never(_) => builtin("never"),
151-
PyreflyType::None => self.types_class("NoneType", TypeFlags::INSTANCE),
188+
// `None` → the stdlib's real `NoneType` class (see `none_type`).
189+
PyreflyType::None => self.convert_class_type(self.none_type, TypeFlags::INSTANCE),
152190
PyreflyType::Ellipsis => builtin("ellipsis"),
153191

154192
// --- Class instances (int, str, list[int], user-defined classes, etc.) ---
@@ -662,19 +700,6 @@ impl TypeConverter<'_> {
662700
})
663701
}
664702

665-
/// Build a TSP `ClassType` whose declaration points at `types.<name>`.
666-
fn types_class(&self, name: &str, flags: TypeFlags) -> TspType {
667-
TspType::Class(TspClassType {
668-
declaration: Declaration::Regular(self.types_class_declaration(name)),
669-
flags,
670-
id: next_id(),
671-
kind: TypeKind::Class,
672-
literal_value: None,
673-
type_alias_info: None,
674-
type_args: None,
675-
})
676-
}
677-
678703
/// Build a class declaration for `typing.<name>`. Resolves the real source
679704
/// range via the export-location resolver; falls back to a zero range
680705
/// pointing at bundled `typing.pyi` when unavailable.
@@ -697,28 +722,6 @@ impl TypeConverter<'_> {
697722
make_typing_class_declaration(name)
698723
}
699724

700-
/// Build a class declaration for `types.<name>`. Resolves the real source
701-
/// range via the export-location resolver; falls back to a zero range
702-
/// pointing at bundled `types.pyi` when unavailable.
703-
fn types_class_declaration(&self, name: &str) -> RegularDeclaration {
704-
let symbol = Name::new(name);
705-
if let Some((module_path, lsp_range)) = self
706-
.resolve_export
707-
.and_then(|resolve| resolve(ModuleName::types(), &symbol))
708-
{
709-
return RegularDeclaration {
710-
kind: DeclarationKind::Regular,
711-
category: DeclarationCategory::Class,
712-
name: Some(name.to_owned()),
713-
node: Node {
714-
range: lsp_range_to_tsp(lsp_range),
715-
uri: path_to_uri(&module_path),
716-
},
717-
};
718-
}
719-
make_types_class_declaration(name)
720-
}
721-
722725
/// Convert a pyrefly `Overload` to a TSP `OverloadedType`.
723726
fn convert_overload_to_tsp(
724727
&self,
@@ -875,21 +878,6 @@ fn make_typing_class_declaration(name: &str) -> RegularDeclaration {
875878
}
876879
}
877880

878-
/// Build a declaration for a class in `types.pyi`.
879-
fn make_types_class_declaration(name: &str) -> RegularDeclaration {
880-
let module_path =
881-
pyrefly_python::module_path::ModulePath::bundled_typeshed(PathBuf::from("types.pyi"));
882-
RegularDeclaration {
883-
kind: DeclarationKind::Regular,
884-
category: DeclarationCategory::Class,
885-
name: Some(name.to_owned()),
886-
node: Node {
887-
range: zero_range(),
888-
uri: path_to_uri(&module_path),
889-
},
890-
}
891-
}
892-
893881
/// Build a TSP `TypeVar` with a synthesized declaration. Used for
894882
/// solver-internal TypeVar-like placeholders (Quantified, Args, Kwargs,
895883
/// ElementOfTypeVarTuple) where there is no real source location.
@@ -1422,7 +1410,13 @@ mod tests {
14221410
None
14231411
}
14241412
};
1425-
let tsp = convert_type_with_resolvers(&ty, None, Some(&module_path_resolver), None);
1413+
let tsp = convert_type_with_resolvers(
1414+
&ty,
1415+
None,
1416+
Some(&module_path_resolver),
1417+
None,
1418+
&test_none_type(),
1419+
);
14261420
match tsp {
14271421
TspType::Module(m) => {
14281422
assert_eq!(m.module_name, "pkg");
@@ -1499,7 +1493,7 @@ mod tests {
14991493
range,
15001494
))
15011495
};
1502-
match convert_type_with_resolvers(&ty, None, None, Some(&resolver)) {
1496+
match convert_type_with_resolvers(&ty, None, None, Some(&resolver), &test_none_type()) {
15031497
TspType::Class(c) => {
15041498
let Declaration::Regular(decl) = c.declaration else {
15051499
panic!("expected RegularDeclaration");
@@ -1618,7 +1612,7 @@ mod tests {
16181612
))
16191613
};
16201614
let ty = PyreflyType::TypeAlias(Box::new(TypeAliasData::Ref(make_ref())));
1621-
match convert_type_with_resolvers(&ty, None, None, Some(&resolver)) {
1615+
match convert_type_with_resolvers(&ty, None, None, Some(&resolver), &test_none_type()) {
16221616
TspType::Class(c) => {
16231617
let Declaration::Regular(decl) = c.declaration else {
16241618
panic!("expected RegularDeclaration");
@@ -1679,7 +1673,7 @@ mod tests {
16791673
range,
16801674
))
16811675
};
1682-
match convert_type_with_resolvers(&ty, None, None, Some(&resolver)) {
1676+
match convert_type_with_resolvers(&ty, None, None, Some(&resolver), &test_none_type()) {
16831677
TspType::Var(v) => {
16841678
let Declaration::Regular(decl) = v.declaration else {
16851679
panic!("expected RegularDeclaration");
@@ -1710,7 +1704,7 @@ mod tests {
17101704
lsp_types::Range::default(),
17111705
))
17121706
};
1713-
match convert_type_with_resolvers(&ty, None, None, Some(&resolver)) {
1707+
match convert_type_with_resolvers(&ty, None, None, Some(&resolver), &test_none_type()) {
17141708
TspType::Var(v) => {
17151709
let Declaration::Regular(decl) = v.declaration else {
17161710
panic!("expected RegularDeclaration");
@@ -1857,22 +1851,19 @@ mod tests {
18571851
character: 12,
18581852
},
18591853
};
1854+
// The `None` return converts through the stdlib `NoneType` class, not
1855+
// the export resolver, so the only lookup here is `typing.overload`;
1856+
// any other lookup is a regression.
18601857
let resolver = |module: ModuleName, name: &Name| {
18611858
if module == ModuleName::typing() && name.as_str() == "overload" {
18621859
return Some((
18631860
ModulePath::filesystem(PathBuf::from("/typeshed/typing.pyi")),
18641861
range,
18651862
));
18661863
}
1867-
if module == ModuleName::types() && name.as_str() == "NoneType" {
1868-
return Some((
1869-
ModulePath::filesystem(PathBuf::from("/typeshed/types.pyi")),
1870-
range,
1871-
));
1872-
}
18731864
panic!("unexpected export lookup for {module}.{name}");
18741865
};
1875-
match convert_type_with_resolvers(&ty, None, None, Some(&resolver)) {
1866+
match convert_type_with_resolvers(&ty, None, None, Some(&resolver), &test_none_type()) {
18761867
TspType::Function(f) => {
18771868
let Declaration::Regular(decl) = f.declaration else {
18781869
panic!("expected RegularDeclaration");

0 commit comments

Comments
 (0)