From b93bb7731c19906a5733dd76c8652b0ed8e99bcf Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Mon, 26 Jan 2026 15:36:27 -0500 Subject: [PATCH 01/10] add NodeIds to string annotations --- crates/ruff_db/src/parsed.rs | 58 +++++++++++++++++-- crates/ty/docs/rules.md | 12 ++-- .../ty_python_semantic/src/semantic_model.rs | 4 +- .../src/types/string_annotation.rs | 3 +- 4 files changed, 62 insertions(+), 15 deletions(-) diff --git a/crates/ruff_db/src/parsed.rs b/crates/ruff_db/src/parsed.rs index 9d9604aea0815f..9dc5d754495627 100644 --- a/crates/ruff_db/src/parsed.rs +++ b/crates/ruff_db/src/parsed.rs @@ -3,8 +3,12 @@ use std::sync::Arc; use arc_swap::ArcSwapOption; use get_size2::GetSize; -use ruff_python_ast::{AnyRootNodeRef, ModModule, NodeIndex}; -use ruff_python_parser::{ParseOptions, Parsed, parse_unchecked}; +use ruff_python_ast::{ + AnyRootNodeRef, HasNodeIndex, ModExpression, ModModule, NodeIndex, StringLiteral, +}; +use ruff_python_parser::{ + ParseError, ParseOptions, Parsed, parse_string_annotation, parse_unchecked, +}; use crate::Db; use crate::files::File; @@ -45,6 +49,18 @@ pub fn parsed_module_impl(db: &dyn Db, file: File) -> Parsed { .expect("PySourceType always parses into a module") } +pub fn parsed_string_annotation( + source: &str, + string: &StringLiteral, +) -> Result, ParseError> { + let expr = parse_string_annotation(source, string)?; + + // We need the sub-ast of the string annotation to be indexed + indexed::ensure_indexed(&expr, string.node_index().load()); + + Ok(expr) +} + /// A wrapper around a parsed module. /// /// This type manages instances of the module AST. A particular instance of the AST @@ -169,12 +185,40 @@ mod indexed { pub parsed: Parsed, } + /// Ensure the following sub-AST is indexed, using the parent node's index + /// as a basis for unambiguous AST node indices. + pub fn ensure_indexed(parsed: &Parsed, parent_node_index: NodeIndex) { + // High level idea: + // + // With 0x0000_00AB we want to shift away all the leading 0's so we + // have 0xAB00_0000, and then start counting up from there. To avoid + // ambiguity around 0, we actually only shift up to the second-most + // significant digit and then set the most significant digit to 1. + // + // Because these are 32-bit, you would need *gigabytes* of code in + // a single python file to cause any conflicts, so we don't try to + // handle those conflicts at all. + let index = if let Some(parent) = parent_node_index.as_u32() { + let space = parent.leading_zeros(); + parent << space.saturating_sub(1) | (1 << 31) + } else { + 0 + }; + + let mut visitor = Visitor { + nodes: Some(Vec::new()), + index, + }; + + AnyNodeRef::from(parsed.syntax()).visit_source_order(&mut visitor); + } + impl IndexedModule { /// Create a new [`IndexedModule`] from the given AST. #[allow(clippy::unnecessary_cast)] pub fn new(parsed: Parsed) -> Arc { let mut visitor = Visitor { - nodes: Vec::new(), + nodes: Some(Vec::new()), index: 0, }; @@ -185,7 +229,7 @@ mod indexed { AnyNodeRef::from(inner.parsed.syntax()).visit_source_order(&mut visitor); - let index: Box<[AnyRootNodeRef<'_>]> = visitor.nodes.into_boxed_slice(); + let index: Box<[AnyRootNodeRef<'_>]> = visitor.nodes.unwrap().into_boxed_slice(); // SAFETY: We cast from `Box<[AnyRootNodeRef<'_>]>` to `Box<[AnyRootNodeRef<'static>]>`, // faking the 'static lifetime to create the self-referential struct. The node references @@ -214,7 +258,7 @@ mod indexed { /// A visitor that collects nodes in source order. pub struct Visitor<'a> { pub index: u32, - pub nodes: Vec>, + pub nodes: Option>>, } impl<'a> Visitor<'a> { @@ -224,7 +268,9 @@ mod indexed { AnyRootNodeRef<'a>: From<&'a T>, { node.node_index().set(NodeIndex::from(self.index)); - self.nodes.push(AnyRootNodeRef::from(node)); + if let Some(nodes) = &mut self.nodes { + nodes.push(AnyRootNodeRef::from(node)); + } self.index += 1; } } diff --git a/crates/ty/docs/rules.md b/crates/ty/docs/rules.md index d86c1225cf09f1..08ac379dd550f6 100644 --- a/crates/ty/docs/rules.md +++ b/crates/ty/docs/rules.md @@ -126,7 +126,7 @@ def _(x: int): Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -533,7 +533,7 @@ def bar() -> str: Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -558,7 +558,7 @@ def foo() -> "intt\b": ... Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -620,7 +620,7 @@ a = 20 / 0 # ty: ignore[division-by-zero] Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -1915,7 +1915,7 @@ super(B, A) # error: `A` does not satisfy `issubclass(A, B)` Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source @@ -2711,7 +2711,7 @@ print(x) # NameError: name 'x' is not defined Default level: error · Added in 0.0.1-alpha.1 · Related issues · -View source +View source diff --git a/crates/ty_python_semantic/src/semantic_model.rs b/crates/ty_python_semantic/src/semantic_model.rs index 5c53cbfdcdc4d1..20d8b623052b66 100644 --- a/crates/ty_python_semantic/src/semantic_model.rs +++ b/crates/ty_python_semantic/src/semantic_model.rs @@ -1,4 +1,5 @@ use ruff_db::files::{File, FilePath}; +use ruff_db::parsed::parsed_string_annotation; use ruff_db::source::{line_index, source_text}; use ruff_python_ast::{self as ast, ExprStringLiteral, ModExpression}; use ruff_python_ast::{Expr, ExprRef, HasNodeIndex, name::Name}; @@ -374,8 +375,7 @@ impl<'db> SemanticModel<'db> { // are not in the File's AST! let source = source_text(self.db, self.file); let string_literal = string_expr.as_single_part_string()?; - let ast = - ruff_python_parser::parse_string_annotation(source.as_str(), string_literal).ok()?; + let ast = parsed_string_annotation(source.as_str(), string_literal).ok()?; let model = Self { db: self.db, file: self.file, diff --git a/crates/ty_python_semantic/src/types/string_annotation.rs b/crates/ty_python_semantic/src/types/string_annotation.rs index cddbd9ec57db2c..d13e8a4ca64a92 100644 --- a/crates/ty_python_semantic/src/types/string_annotation.rs +++ b/crates/ty_python_semantic/src/types/string_annotation.rs @@ -1,3 +1,4 @@ +use ruff_db::parsed::parsed_string_annotation; use ruff_db::source::source_text; use ruff_python_ast::{self as ast, ModExpression}; use ruff_python_parser::Parsed; @@ -193,7 +194,7 @@ pub(crate) fn parse_string_annotation( // Compare the raw contents (without quotes) of the expression with the parsed contents // contained in the string literal. } else if &source[string_literal.content_range()] == string_literal.as_str() { - match ruff_python_parser::parse_string_annotation(source.as_str(), string_literal) { + match parsed_string_annotation(source.as_str(), string_literal) { Ok(parsed) => return Some(parsed), Err(parse_error) => { if let Some(builder) = From 26b2a58fc4304f502ede5cba6be893c04b5204ad Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Mon, 26 Jan 2026 16:35:22 -0500 Subject: [PATCH 02/10] Store the types of string annotations --- crates/ruff_db/src/parsed.rs | 15 ++-- crates/ty_ide/src/goto.rs | 17 ++-- crates/ty_ide/src/goto_type_definition.rs | 80 ++++++++++++++++++- crates/ty_ide/src/hover.rs | 38 ++++++++- .../src/types/infer/builder.rs | 15 ++-- .../types/infer/builder/type_expression.rs | 8 +- 6 files changed, 135 insertions(+), 38 deletions(-) diff --git a/crates/ruff_db/src/parsed.rs b/crates/ruff_db/src/parsed.rs index 9dc5d754495627..35d2ca2a9f196d 100644 --- a/crates/ruff_db/src/parsed.rs +++ b/crates/ruff_db/src/parsed.rs @@ -198,12 +198,15 @@ mod indexed { // Because these are 32-bit, you would need *gigabytes* of code in // a single python file to cause any conflicts, so we don't try to // handle those conflicts at all. - let index = if let Some(parent) = parent_node_index.as_u32() { - let space = parent.leading_zeros(); - parent << space.saturating_sub(1) | (1 << 31) - } else { - 0 - }; + // + // We panic here if no proper parent because this really should never + // happen and if it does any fallback we do will break invariants + // that code needs to depend on. + let parent = parent_node_index + .as_u32() + .expect("Indexed string annotations must have a valid parent node index"); + let space = parent.leading_zeros(); + let index = parent << space.saturating_sub(1) | (1 << 31); let mut visitor = Visitor { nodes: Some(Vec::new()), diff --git a/crates/ty_ide/src/goto.rs b/crates/ty_ide/src/goto.rs index 1cb7778e96035c..f24e6fd0b99bcb 100644 --- a/crates/ty_ide/src/goto.rs +++ b/crates/ty_ide/src/goto.rs @@ -325,18 +325,11 @@ impl GotoTarget<'_> { subrange, .. } => { - let (subast, _submodel) = model.enter_string_annotation(string_expr)?; - let submod = subast.syntax(); - let subnode = covering_node(submod.into(), *subrange).node(); - - // The type checker knows the type of the full annotation but nothing else - if AnyNodeRef::from(&*submod.body) == subnode { - string_expr.inferred_type(model) - } else { - // TODO: force the typechecker to tell us its secrets - // (it computes but then immediately discards these types) - None - } + let (subast, submodel) = model.enter_string_annotation(string_expr)?; + let subexpr = covering_node(subast.syntax().into(), *subrange) + .node() + .as_expr_ref()?; + subexpr.inferred_type(&submodel) } GotoTarget::BinOp { expression, .. } => { let (_, ty) = ty_python_semantic::definitions_for_bin_op(model, expression)?; diff --git a/crates/ty_ide/src/goto_type_definition.rs b/crates/ty_ide/src/goto_type_definition.rs index 65cb1e59ec0a90..b85b222f84c54e 100644 --- a/crates/ty_ide/src/goto_type_definition.rs +++ b/crates/ty_ide/src/goto_type_definition.rs @@ -764,7 +764,25 @@ mod tests { "#, ); - assert_snapshot!(test.goto_type_definition(), @"No goto target found"); + assert_snapshot!(test.goto_type_definition(), @r#" + info[goto-type definition]: Go to type definition + --> main.py:2:12 + | + 2 | a: "None | MyClass" = 1 + | ^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 type definition + --> main.py:4:7 + | + 2 | a: "None | MyClass" = 1 + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | + "#); } #[test] @@ -818,7 +836,25 @@ mod tests { "#, ); - assert_snapshot!(test.goto_type_definition(), @"No goto target found"); + assert_snapshot!(test.goto_type_definition(), @r#" + info[goto-type definition]: Go to type definition + --> main.py:2:12 + | + 2 | a: "None | MyClass" = 1 + | ^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 type definition + --> main.py:4:7 + | + 2 | a: "None | MyClass" = 1 + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | + "#); } #[test] @@ -904,7 +940,25 @@ mod tests { "#, ); - assert_snapshot!(test.goto_type_definition(), @"No goto target found"); + assert_snapshot!(test.goto_type_definition(), @r#" + info[goto-type definition]: Go to type definition + --> main.py:2:5 + | + 2 | a: "MyClass | No" = 1 + | ^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 type definition + --> main.py:4:7 + | + 2 | a: "MyClass | No" = 1 + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | + "#); } #[test] @@ -918,7 +972,25 @@ mod tests { "#, ); - assert_snapshot!(test.goto_type_definition(), @"No goto target found"); + assert_snapshot!(test.goto_type_definition(), @r#" + info[goto-type definition]: Go to type definition + --> main.py:2:15 + | + 2 | a: "MyClass | No" = 1 + | ^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 type definition + --> stdlib/ty_extensions.pyi:14:1 + | + 13 | # Types + 14 | Unknown = object() + | ------- + 15 | AlwaysTruthy = object() + 16 | AlwaysFalsy = object() + | + "#); } #[test] diff --git a/crates/ty_ide/src/hover.rs b/crates/ty_ide/src/hover.rs index 70e960502eb42d..94d9f7785955fa 100644 --- a/crates/ty_ide/src/hover.rs +++ b/crates/ty_ide/src/hover.rs @@ -975,9 +975,15 @@ mod tests { ); assert_snapshot!(test.hover(), @r#" + MyClass + --------------------------------------------- some docs --------------------------------------------- + ```python + MyClass + ``` + --- some docs --------------------------------------------- info[hover]: Hovered content is @@ -1020,9 +1026,15 @@ mod tests { ); assert_snapshot!(test.hover(), @r#" + MyClass + --------------------------------------------- some docs --------------------------------------------- + ```python + MyClass + ``` + --- some docs --------------------------------------------- info[hover]: Hovered content is @@ -1078,9 +1090,15 @@ mod tests { ); assert_snapshot!(test.hover(), @r#" + MyClass + --------------------------------------------- some docs --------------------------------------------- + ```python + MyClass + ``` + --- some docs --------------------------------------------- info[hover]: Hovered content is @@ -1108,7 +1126,25 @@ mod tests { "#, ); - assert_snapshot!(test.hover(), @"Hover provided no content"); + assert_snapshot!(test.hover(), @r#" + Unknown + --------------------------------------------- + ```python + Unknown + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:2:15 + | + 2 | a: "MyClass | No" = 1 + | ^- + | || + | |Cursor offset + | source + 3 | + 4 | class MyClass: + | + "#); } #[test] diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index d65c30d1e9583e..cbd7c4ab966db8 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -9714,13 +9714,8 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { ty: Type<'db>, tcx: TypeContext<'db>, ) { - if self.deferred_state.in_string_annotation() - || self.inner_expression_inference_state.is_get() - { - // Avoid storing the type of expressions that are part of a string annotation because - // the expression ids don't exists in the semantic index. Instead, we'll store the type - // on the string expression itself that represents the annotation. - // Also, if `inner_expression_inference_state` is `Get`, the expression type has already been stored. + if self.inner_expression_inference_state.is_get() { + // If `inner_expression_inference_state` is `Get`, the expression type has already been stored. return; } @@ -9731,7 +9726,11 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { MultiInferenceState::Panic => { let previous = self.expressions.insert(expression.into(), ty); - assert_eq!(previous, None); + // TODO: We store (sub)string annotations now but the code is a bit inconsistent about + // only doing it once, so downgrade this into `MultiInferenceState::Overwrite` + if !self.deferred_state.in_string_annotation() { + assert_eq!(previous, None); + } } MultiInferenceState::Overwrite => { diff --git a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs index d4b21684ce53b2..e34888184b2e00 100644 --- a/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs +++ b/crates/ty_python_semantic/src/types/infer/builder/type_expression.rs @@ -609,13 +609,7 @@ impl<'db> TypeInferenceBuilder<'db, '_> { element_ty.exact_tuple_instance_spec(builder.db()).is_none() } ast::Expr::Subscript(ast::ExprSubscript { value, .. }) => { - let value_ty = if builder.deferred_state.in_string_annotation() { - // Using `.expression_type` does not work in string annotations, because - // we do not store types for sub-expressions. Re-infer the type here. - builder.infer_expression(value, TypeContext::default()) - } else { - builder.expression_type(value) - }; + let value_ty = builder.expression_type(value); value_ty == Type::SpecialForm(SpecialFormType::Unpack) } From fa8822d1811bc3dd4aefc373b652f539eb001b77 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 27 Jan 2026 12:18:08 -0500 Subject: [PATCH 03/10] much more robust index encoding --- crates/ruff_db/src/parsed.rs | 72 ++++++++++----- crates/ruff_python_ast/src/node_index.rs | 92 +++++++++++++++++++ .../src/types/infer/builder.rs | 6 +- 3 files changed, 140 insertions(+), 30 deletions(-) diff --git a/crates/ruff_db/src/parsed.rs b/crates/ruff_db/src/parsed.rs index 35d2ca2a9f196d..9a35a7a2f439b4 100644 --- a/crates/ruff_db/src/parsed.rs +++ b/crates/ruff_db/src/parsed.rs @@ -4,10 +4,11 @@ use std::sync::Arc; use arc_swap::ArcSwapOption; use get_size2::GetSize; use ruff_python_ast::{ - AnyRootNodeRef, HasNodeIndex, ModExpression, ModModule, NodeIndex, StringLiteral, + AnyRootNodeRef, HasNodeIndex, ModExpression, ModModule, NodeIndex, NodeIndexError, + StringLiteral, }; use ruff_python_parser::{ - ParseError, ParseOptions, Parsed, parse_string_annotation, parse_unchecked, + ParseError, ParseErrorType, ParseOptions, Parsed, parse_string_annotation, parse_unchecked, }; use crate::Db; @@ -56,7 +57,25 @@ pub fn parsed_string_annotation( let expr = parse_string_annotation(source, string)?; // We need the sub-ast of the string annotation to be indexed - indexed::ensure_indexed(&expr, string.node_index().load()); + indexed::ensure_indexed(&expr, string.node_index().load()).map_err(|err| { + let message = match err { + NodeIndexError::NoParent => { + "Internal Error: string annotation's parent had no NodeIndex".to_owned() + } + NodeIndexError::OutOfIndices => { + "File too long, ran out of encoding space for string annotations".to_owned() + } + NodeIndexError::OutOfSubIndices => { + "Substring annotation is too complex, ran out of encoding space".to_owned() + } + NodeIndexError::TooNested => "Too many levels of nested string annotations".to_owned(), + }; + + ParseError { + error: ParseErrorType::OtherError(message), + location: string.range, + } + })?; Ok(expr) } @@ -187,33 +206,26 @@ mod indexed { /// Ensure the following sub-AST is indexed, using the parent node's index /// as a basis for unambiguous AST node indices. - pub fn ensure_indexed(parsed: &Parsed, parent_node_index: NodeIndex) { - // High level idea: - // - // With 0x0000_00AB we want to shift away all the leading 0's so we - // have 0xAB00_0000, and then start counting up from there. To avoid - // ambiguity around 0, we actually only shift up to the second-most - // significant digit and then set the most significant digit to 1. - // - // Because these are 32-bit, you would need *gigabytes* of code in - // a single python file to cause any conflicts, so we don't try to - // handle those conflicts at all. - // - // We panic here if no proper parent because this really should never - // happen and if it does any fallback we do will break invariants - // that code needs to depend on. - let parent = parent_node_index - .as_u32() - .expect("Indexed string annotations must have a valid parent node index"); - let space = parent.leading_zeros(); - let index = parent << space.saturating_sub(1) | (1 << 31); - + pub fn ensure_indexed( + parsed: &Parsed, + parent_node_index: NodeIndex, + ) -> Result<(), NodeIndexError> { + let parent_index = parent_node_index.as_u32().ok_or(NodeIndexError::NoParent)?; + let (index, max_index) = sub_indices(parent_index)?; let mut visitor = Visitor { + overflowed: false, nodes: Some(Vec::new()), index, + max_index, }; AnyNodeRef::from(parsed.syntax()).visit_source_order(&mut visitor); + + if visitor.overflowed { + return Err(NodeIndexError::OutOfSubIndices); + } + + Ok(()) } impl IndexedModule { @@ -223,6 +235,8 @@ mod indexed { let mut visitor = Visitor { nodes: Some(Vec::new()), index: 0, + max_index: MAX_REAL_INDEX, + overflowed: false, }; let mut inner = Arc::new(IndexedModule { @@ -261,7 +275,9 @@ mod indexed { /// A visitor that collects nodes in source order. pub struct Visitor<'a> { pub index: u32, + pub max_index: u32, pub nodes: Option>>, + pub overflowed: bool, } impl<'a> Visitor<'a> { @@ -270,7 +286,13 @@ mod indexed { T: HasNodeIndex + std::fmt::Debug, AnyRootNodeRef<'a>: From<&'a T>, { - node.node_index().set(NodeIndex::from(self.index)); + // Only check on write (the maximum is orders of magnitude less than u32::MAX) + if self.index > self.max_index { + self.overflowed = true; + } else { + node.node_index().set(NodeIndex::from(self.index)); + } + if let Some(nodes) = &mut self.nodes { nodes.push(AnyRootNodeRef::from(node)); } diff --git a/crates/ruff_python_ast/src/node_index.rs b/crates/ruff_python_ast/src/node_index.rs index d40a7fc2bb86cc..e8cd1b2b9f9d36 100644 --- a/crates/ruff_python_ast/src/node_index.rs +++ b/crates/ruff_python_ast/src/node_index.rs @@ -17,6 +17,45 @@ where } /// A unique index for a node within an AST. +/// +/// Our encoding of 32-bit AST node indices is as follows: +/// +/// * `u32::MAX` (1111...1) is reserved as a forbidden value (mapped to 0 for `NonZero`) +/// * `u32::MAX - 1` (1111...0) is reserved for `NodeIndex::NONE` +/// * The top two bits encode the sub-AST level: +/// * 00 is top-level AST +/// * 01 is sub-AST (string annotation) +/// * 10 is sub-sub-AST (string annotation in string annotation) +/// * 11 is forbidden (well, it only appears in the above reserved values) +/// * The remaining 30 bits are the real (sub)-AST node index +/// +/// To get the first sub-index of a node's sub-AST we: +/// +/// * increment the sub-AST level in the high-bits +/// * at level 1, multiply the real index by 256 +/// * at level 2, multiply the real index by 8 +/// +/// The multiplication gives each node a reserved space of 256 nodes for its sub-AST +/// to work with ("should be enough for anybody"), and 8 nodes for a sub-sub-AST +/// (enough for an identifier and maybe some simple unions). +/// +/// Here are some implications: +/// +/// * We have 2^30 top-level AST nodes (1 billion) +/// * To have a string annotation, the parent node needs to be multiplied by 256 without +/// overflowing 30 bits, so string annotations cannot be used after 2^22 nodes (4 million), +/// which would be like, a million lines of code. +/// * To have a sub-string annotation, the top-level node needs to be multiplied +/// by 256 * 8, so sub-string annotations cannot be used after 2^19 nodes (500 thousand), +/// or about 100k lines of code. +/// +/// This feels like a pretty reasonable compromise that will work well in practice, +/// although it creates some very wonky boundary conditions that will be very unpleasant +/// if someone runs into them. +/// +/// That said, string annotations are in many regards "legacy" and so new code ideally +/// doesn't have to use them, and there's never a real reason to use sub-annotation +/// let-alone a sub-sub-annotation. #[derive(PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash)] #[cfg_attr(feature = "get-size", derive(get_size2::GetSize))] pub struct NodeIndex(NonZeroU32); @@ -39,6 +78,59 @@ impl NodeIndex { } } +pub enum NodeIndexError { + TooNested, + OutOfSubIndices, + NoParent, + OutOfIndices, +} + +const MAX_LEVEL: u32 = 2; +const LEVEL_BITS: u32 = 32 - MAX_LEVEL.leading_zeros(); +const LEVEL_SHIFT: u32 = 32 - LEVEL_BITS; +const LEVEL_MASK: u32 = ((LEVEL_BITS << 1) - 1) << LEVEL_SHIFT; +const SUB_NODES: u32 = 256; +const SUB_SUB_NODES: u32 = 8; +pub const MAX_REAL_INDEX: u32 = (1 << LEVEL_SHIFT) - 1; + +/// sub-AST level is stored in the top two bits +fn sub_ast_level(index: u32) -> u32 { + (index & LEVEL_MASK) >> LEVEL_SHIFT +} + +/// Get the first and last index of the sub-AST of the input +pub fn sub_indices(index: u32) -> Result<(u32, u32), NodeIndexError> { + let level = sub_ast_level(index); + if level >= MAX_LEVEL { + return Err(NodeIndexError::TooNested); + } + let next_level = (level + 1) << LEVEL_SHIFT; + let without_level = index & !LEVEL_MASK; + let nodes_in_level = if level == 0 { + SUB_NODES + } else if level == 1 { + SUB_SUB_NODES + } else { + unreachable!( + "Someone made a mistake updating the encoding of node indices: {index:08X} had level {level}" + ); + }; + + // If this overflows the file has hundreds of thousands of lines of code, + // but that *can* happen (we just can't support string annotations that deep) + let sub_index_without_level = without_level + .checked_mul(SUB_NODES) + .ok_or(NodeIndexError::OutOfIndices)?; + if sub_index_without_level > MAX_REAL_INDEX { + return Err(NodeIndexError::OutOfIndices); + } + + let first_index = sub_index_without_level | next_level; + // Can't overflow by construction + let last_index = first_index + nodes_in_level - 1; + Ok((first_index, last_index)) +} + impl From for NodeIndex { fn from(value: u32) -> Self { match NonZeroU32::new(value + 1).map(NodeIndex) { diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index cbd7c4ab966db8..af5243ba589f63 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -9726,11 +9726,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { MultiInferenceState::Panic => { let previous = self.expressions.insert(expression.into(), ty); - // TODO: We store (sub)string annotations now but the code is a bit inconsistent about - // only doing it once, so downgrade this into `MultiInferenceState::Overwrite` - if !self.deferred_state.in_string_annotation() { - assert_eq!(previous, None); - } + assert_eq!(previous, None); } MultiInferenceState::Overwrite => { From 6ccaee387898dce7ab70a42e32a270d5623add4d Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 27 Jan 2026 13:53:17 -0500 Subject: [PATCH 04/10] add tokenizing test --- crates/ty_ide/src/semantic_tokens.rs | 59 ++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/crates/ty_ide/src/semantic_tokens.rs b/crates/ty_ide/src/semantic_tokens.rs index ed30199e407898..4b9c8bed45355d 100644 --- a/crates/ty_ide/src/semantic_tokens.rs +++ b/crates/ty_ide/src/semantic_tokens.rs @@ -1663,6 +1663,65 @@ w5: "float "#); } + #[test] + fn str_annotation_nested() { + let test = SemanticTokenTest::new( + r#" +x: int +y: "int" +z: "'int'" +w: """'"int"'""" + +a: list[int | str] | None +b: list["int | str"] | None +c: "list[int | str] | None" +d: "list[int | str]" | "None" +e: 'list["int | str"] | "None"' +f: """'list["int | str"]' | 'None'""" +"#, + ); + + let tokens = test.highlight_file(); + + assert_snapshot!(test.to_snapshot(&tokens), @r#" + "x" @ 1..2: Variable [definition] + "int" @ 4..7: Class + "y" @ 8..9: Variable [definition] + "int" @ 12..15: Class + "z" @ 17..18: Variable [definition] + "'int'" @ 21..26: String + "w" @ 28..29: Variable [definition] + "'\"int\"'" @ 34..41: String + "a" @ 46..47: Variable [definition] + "list" @ 49..53: Class + "int" @ 54..57: Class + "str" @ 60..63: Class + "None" @ 67..71: BuiltinConstant + "b" @ 72..73: Variable [definition] + "list" @ 75..79: Class + "int" @ 81..84: Class + "str" @ 87..90: Class + "None" @ 95..99: BuiltinConstant + "c" @ 100..101: Variable [definition] + "list" @ 104..108: Class + "int" @ 109..112: Class + "str" @ 115..118: Class + "None" @ 122..126: BuiltinConstant + "d" @ 128..129: Variable [definition] + "list" @ 132..136: Class + "int" @ 137..140: Class + "str" @ 143..146: Class + "None" @ 152..156: BuiltinConstant + "e" @ 158..159: Variable [definition] + "list" @ 162..166: Class + "\"int | str\"" @ 167..178: String + "\"None\"" @ 182..188: String + "f" @ 190..191: Variable [definition] + "'list[\"int | str\"]'" @ 196..215: String + "'None'" @ 218..224: String + "#); + } + #[test] fn attribute_classification() { let test = SemanticTokenTest::new( From d7b049730ac453139b96802e83558451d156286d Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 27 Jan 2026 14:05:11 -0500 Subject: [PATCH 05/10] allow for nested string annotations --- crates/ty_ide/src/semantic_tokens.rs | 14 ++++++++------ crates/ty_python_semantic/src/semantic_model.rs | 17 ++++++++++------- 2 files changed, 18 insertions(+), 13 deletions(-) diff --git a/crates/ty_ide/src/semantic_tokens.rs b/crates/ty_ide/src/semantic_tokens.rs index 4b9c8bed45355d..e3dc050cb91294 100644 --- a/crates/ty_ide/src/semantic_tokens.rs +++ b/crates/ty_ide/src/semantic_tokens.rs @@ -1689,9 +1689,9 @@ f: """'list["int | str"]' | 'None'""" "y" @ 8..9: Variable [definition] "int" @ 12..15: Class "z" @ 17..18: Variable [definition] - "'int'" @ 21..26: String + "int" @ 22..25: Class "w" @ 28..29: Variable [definition] - "'\"int\"'" @ 34..41: String + "\"int\"" @ 35..40: String "a" @ 46..47: Variable [definition] "list" @ 49..53: Class "int" @ 54..57: Class @@ -1714,11 +1714,13 @@ f: """'list["int | str"]' | 'None'""" "None" @ 152..156: BuiltinConstant "e" @ 158..159: Variable [definition] "list" @ 162..166: Class - "\"int | str\"" @ 167..178: String - "\"None\"" @ 182..188: String + "int" @ 168..171: Class + "str" @ 174..177: Class + "None" @ 183..187: BuiltinConstant "f" @ 190..191: Variable [definition] - "'list[\"int | str\"]'" @ 196..215: String - "'None'" @ 218..224: String + "list" @ 197..201: Class + "\"int | str\"" @ 202..213: String + "None" @ 219..223: BuiltinConstant "#); } diff --git a/crates/ty_python_semantic/src/semantic_model.rs b/crates/ty_python_semantic/src/semantic_model.rs index 20d8b623052b66..ea9597e37bdd7e 100644 --- a/crates/ty_python_semantic/src/semantic_model.rs +++ b/crates/ty_python_semantic/src/semantic_model.rs @@ -354,16 +354,15 @@ impl<'db> SemanticModel<'db> { &self, string_expr: &ExprStringLiteral, ) -> Option<(Parsed, Self)> { - // String annotations can't contain string annotations - if self.in_string_annotation_expr.is_some() { - return None; - } - // Ask the inference engine whether this is actually a string annotation let expr = ExprRef::StringLiteral(string_expr); let index = semantic_index(self.db, self.file); - let file_scope = index.expression_scope_id(&expr); + // When looking up scopes, use the expr in the top-level AST + // (we might be trying to enter a sub-sub-AST, so this isn't silly) + let file_scope = index.expression_scope_id(&self.expr_ref_in_ast(expr)); let scope = file_scope.to_scope_id(self.db, self.file); + // When querying whether the expr is a string annotation, we do however use the actual expr + // (the inference engine should record this information even for sub-nodes) if !infer_complete_scope_types(self.db, scope).is_string_annotation(expr) { return None; } @@ -379,7 +378,11 @@ impl<'db> SemanticModel<'db> { let model = Self { db: self.db, file: self.file, - in_string_annotation_expr: Some(Box::new(Expr::StringLiteral(string_expr.clone()))), + // Use expr_in_ast here because we might be entering a sub-sub-AST + in_string_annotation_expr: Some(Box::new( + self.expr_in_ast(&Expr::StringLiteral(string_expr.clone())) + .clone(), + )), }; Some((ast, model)) } From ea18244eafb3e2e7cef91fc5b97d23e39e291158 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 27 Jan 2026 14:14:48 -0500 Subject: [PATCH 06/10] add goto-type tests --- crates/ty_ide/src/goto_type_definition.rs | 259 ++++++++++++++++++++++ 1 file changed, 259 insertions(+) diff --git a/crates/ty_ide/src/goto_type_definition.rs b/crates/ty_ide/src/goto_type_definition.rs index b85b222f84c54e..2a5ae1185bc931 100644 --- a/crates/ty_ide/src/goto_type_definition.rs +++ b/crates/ty_ide/src/goto_type_definition.rs @@ -1047,6 +1047,265 @@ mod tests { "#); } + #[test] + fn goto_type_string_annotation_nested1() { + let test = cursor_test( + r#" + x: "list['MyClass | int'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_type_definition(), @r#" + info[goto-type definition]: Go to type definition + --> main.py:2:4 + | + 2 | x: "list['MyClass | int'] | None" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 2 type definitions + --> stdlib/builtins.pyi:2829:7 + | + 2828 | @disjoint_base + 2829 | class list(MutableSequence[_T]): + | ---- + 2830 | """Built-in mutable sequence. + | + ::: stdlib/types.pyi:969:11 + | + 967 | if sys.version_info >= (3, 10): + 968 | @final + 969 | class NoneType: + | -------- + 970 | """The type of the None singleton.""" + | + "#); + } + + #[test] + fn goto_type_string_annotation_nested2() { + let test = cursor_test( + r#" + x: "list['int | MyClass'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_type_definition(), @r#" + info[goto-type definition]: Go to type definition + --> main.py:2:4 + | + 2 | x: "list['int | MyClass'] | None" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 2 type definitions + --> stdlib/builtins.pyi:2829:7 + | + 2828 | @disjoint_base + 2829 | class list(MutableSequence[_T]): + | ---- + 2830 | """Built-in mutable sequence. + | + ::: stdlib/types.pyi:969:11 + | + 967 | if sys.version_info >= (3, 10): + 968 | @final + 969 | class NoneType: + | -------- + 970 | """The type of the None singleton.""" + | + "#); + } + + #[test] + fn goto_type_string_annotation_nested3() { + let test = cursor_test( + r#" + x: "list['int | None'] | MyClass" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_type_definition(), @r#" + info[goto-type definition]: Go to type definition + --> main.py:2:26 + | + 2 | x: "list['int | None'] | MyClass" + | ^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 type definition + --> main.py:4:7 + | + 2 | x: "list['int | None'] | MyClass" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | + "#); + } + + #[test] + fn goto_type_string_annotation_nested4() { + let test = cursor_test( + r#" + x: "list['int' | 'MyClass'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_type_definition(), @r#" + info[goto-type definition]: Go to type definition + --> main.py:2:4 + | + 2 | x: "list['int' | 'MyClass'] | None" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 2 type definitions + --> stdlib/builtins.pyi:2829:7 + | + 2828 | @disjoint_base + 2829 | class list(MutableSequence[_T]): + | ---- + 2830 | """Built-in mutable sequence. + | + ::: stdlib/types.pyi:969:11 + | + 967 | if sys.version_info >= (3, 10): + 968 | @final + 969 | class NoneType: + | -------- + 970 | """The type of the None singleton.""" + | + "#); + } + + #[test] + fn goto_type_string_annotation_nested5() { + let test = cursor_test( + r#" + x: "list['MyClass' | 'str'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_type_definition(), @r#" + info[goto-type definition]: Go to type definition + --> main.py:2:4 + | + 2 | x: "list['MyClass' | 'str'] | None" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 2 type definitions + --> stdlib/builtins.pyi:2829:7 + | + 2828 | @disjoint_base + 2829 | class list(MutableSequence[_T]): + | ---- + 2830 | """Built-in mutable sequence. + | + ::: stdlib/types.pyi:969:11 + | + 967 | if sys.version_info >= (3, 10): + 968 | @final + 969 | class NoneType: + | -------- + 970 | """The type of the None singleton.""" + | + "#); + } + + #[test] + fn goto_type_string_annotation_too_nested1() { + let test = cursor_test( + r#" + x: """'list["MyClass" | "str"]' | None""" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_type_definition(), @r#" + info[goto-type definition]: Go to type definition + --> main.py:2:4 + | + 2 | x: """'list["MyClass" | "str"]' | None""" + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 2 type definitions + --> stdlib/builtins.pyi:2829:7 + | + 2828 | @disjoint_base + 2829 | class list(MutableSequence[_T]): + | ---- + 2830 | """Built-in mutable sequence. + | + ::: stdlib/types.pyi:969:11 + | + 967 | if sys.version_info >= (3, 10): + 968 | @final + 969 | class NoneType: + | -------- + 970 | """The type of the None singleton.""" + | + "#); + } + + #[test] + fn goto_type_string_annotation_too_nested2() { + let test = cursor_test( + r#" + x: """'list["int" | "str"]' | MyClass""" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_type_definition(), @r#" + info[goto-type definition]: Go to type definition + --> main.py:2:31 + | + 2 | x: """'list["int" | "str"]' | MyClass""" + | ^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 type definition + --> main.py:4:7 + | + 2 | x: """'list["int" | "str"]' | MyClass""" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | + "#); + } + #[test] fn goto_type_match_name_stmt() { let test = cursor_test( From d0b31152fca9c0858bd8e89f811e41a09a99874a Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 27 Jan 2026 15:10:13 -0500 Subject: [PATCH 07/10] implement goto for sub-sub-asts --- crates/ty_ide/src/goto.rs | 98 ++++++++++++--- crates/ty_ide/src/goto_type_definition.rs | 145 ++++++++-------------- 2 files changed, 139 insertions(+), 104 deletions(-) diff --git a/crates/ty_ide/src/goto.rs b/crates/ty_ide/src/goto.rs index f24e6fd0b99bcb..905b96a012e554 100644 --- a/crates/ty_ide/src/goto.rs +++ b/crates/ty_ide/src/goto.rs @@ -9,7 +9,7 @@ use crate::stub_mapping::StubMapper; use ruff_db::parsed::ParsedModuleRef; use ruff_python_ast::find_node::{CoveringNode, covering_node}; use ruff_python_ast::token::{TokenKind, Tokens}; -use ruff_python_ast::{self as ast, AnyNodeRef}; +use ruff_python_ast::{self as ast, AnyNodeRef, ExprRef}; use ruff_text_size::{Ranged, TextRange, TextSize}; use ty_python_semantic::ResolvedDefinition; @@ -215,6 +215,10 @@ pub(crate) enum GotoTarget<'a> { string_expr: &'a ast::ExprStringLiteral, /// The range to query in the sub-AST for the sub-expression. subrange: TextRange, + /// "How many levels of sub-ASTs are you on?" + /// "idk maybe 1 or 2?" + /// "You are like ch-- actually that's the hardcoded limit we don't allow more" + levels: usize, /// If the expression is a Name of some kind this is the name (just a cached result). name: Option, }, @@ -323,13 +327,37 @@ impl GotoTarget<'_> { GotoTarget::StringAnnotationSubexpr { string_expr, subrange, + levels, .. } => { + let model_to_use; + let expr_to_use; + let subsubast; let (subast, submodel) = model.enter_string_annotation(string_expr)?; + // Must filter to exprs to get ExprStringLiteral over StringLiteral let subexpr = covering_node(subast.syntax().into(), *subrange) + .find_first(AnyNodeRef::is_expression) + .ok()? .node() .as_expr_ref()?; - subexpr.inferred_type(&submodel) + if *levels == 2 + && let ExprRef::StringLiteral(string_expr) = subexpr + { + // Do it again if we're a nested string annotation! + let (new_subast, subsubmodel) = + submodel.enter_string_annotation(string_expr)?; + subsubast = new_subast; + model_to_use = subsubmodel; + expr_to_use = covering_node(subsubast.syntax().into(), *subrange) + .find_first(AnyNodeRef::is_expression) + .ok()? + .node() + .as_expr_ref()?; + } else { + model_to_use = submodel; + expr_to_use = subexpr; + } + expr_to_use.inferred_type(&model_to_use) } GotoTarget::BinOp { expression, .. } => { let (_, ty) = ty_python_semantic::definitions_for_bin_op(model, expression)?; @@ -537,13 +565,37 @@ impl GotoTarget<'_> { GotoTarget::StringAnnotationSubexpr { string_expr, subrange, + levels, .. } => { + let model_to_use; + let expr_to_use; + let subsubast; let (subast, submodel) = model.enter_string_annotation(string_expr)?; + // Must filter to exprs to get ExprStringLiteral over StringLiteral let subexpr = covering_node(subast.syntax().into(), *subrange) + .find_first(AnyNodeRef::is_expression) + .ok()? .node() .as_expr_ref()?; - definitions_for_expression(&submodel, subexpr, alias_resolution) + if *levels == 2 + && let ExprRef::StringLiteral(string_expr) = subexpr + { + // Do it again if we're a nested string annotation! + let (new_subast, subsubmodel) = + submodel.enter_string_annotation(string_expr)?; + subsubast = new_subast; + model_to_use = subsubmodel; + expr_to_use = covering_node(subsubast.syntax().into(), *subrange) + .find_first(AnyNodeRef::is_expression) + .ok()? + .node() + .as_expr_ref()?; + } else { + model_to_use = submodel; + expr_to_use = subexpr; + } + definitions_for_expression(&model_to_use, expr_to_use, alias_resolution) } // nonlocal and global are essentially loads, but again they're statements, @@ -853,23 +905,41 @@ impl GotoTarget<'_> { node @ AnyNodeRef::ExprStringLiteral(string_expr) => { // Check if we've clicked on a sub-GotoTarget inside a string annotation's sub-AST if let Some((subast, submodel)) = model.enter_string_annotation(string_expr) - && let Some(GotoTarget::Expression(subexpr)) = find_goto_target_impl( + && let Some(sub_goto_target) = find_goto_target_impl( &submodel, subast.tokens(), subast.syntax().into(), offset, ) { - let name = match subexpr { - ast::ExprRef::Name(name) => Some(name.id.to_string()), - ast::ExprRef::Attribute(attr) => Some(attr.attr.to_string()), - _ => None, - }; - Some(GotoTarget::StringAnnotationSubexpr { - string_expr, - subrange: subexpr.range(), - name, - }) + match sub_goto_target { + // Regrettably, nested string annotations are supported + GotoTarget::StringAnnotationSubexpr { + string_expr: _, + subrange, + levels, + name, + } => Some(GotoTarget::StringAnnotationSubexpr { + string_expr, + subrange, + levels: levels + 1, + name, + }), + GotoTarget::Expression(subexpr) => { + let name = match subexpr { + ast::ExprRef::Name(name) => Some(name.id.to_string()), + ast::ExprRef::Attribute(attr) => Some(attr.attr.to_string()), + _ => None, + }; + Some(GotoTarget::StringAnnotationSubexpr { + string_expr, + subrange: subexpr.range(), + levels: 1, + name, + }) + } + _ => node.as_expr_ref().map(GotoTarget::Expression), + } } else { node.as_expr_ref().map(GotoTarget::Expression) } diff --git a/crates/ty_ide/src/goto_type_definition.rs b/crates/ty_ide/src/goto_type_definition.rs index 2a5ae1185bc931..451517ef29663d 100644 --- a/crates/ty_ide/src/goto_type_definition.rs +++ b/crates/ty_ide/src/goto_type_definition.rs @@ -1060,29 +1060,22 @@ mod tests { assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type definition]: Go to type definition - --> main.py:2:4 + --> main.py:2:11 | 2 | x: "list['MyClass | int'] | None" - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Clicking here + | ^^^^^^^ Clicking here 3 | 4 | class MyClass: | - info: Found 2 type definitions - --> stdlib/builtins.pyi:2829:7 - | - 2828 | @disjoint_base - 2829 | class list(MutableSequence[_T]): - | ---- - 2830 | """Built-in mutable sequence. - | - ::: stdlib/types.pyi:969:11 - | - 967 | if sys.version_info >= (3, 10): - 968 | @final - 969 | class NoneType: - | -------- - 970 | """The type of the None singleton.""" - | + info: Found 1 type definition + --> main.py:4:7 + | + 2 | x: "list['MyClass | int'] | None" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | "#); } @@ -1099,29 +1092,22 @@ mod tests { assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type definition]: Go to type definition - --> main.py:2:4 + --> main.py:2:17 | 2 | x: "list['int | MyClass'] | None" - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Clicking here + | ^^^^^^^ Clicking here 3 | 4 | class MyClass: | - info: Found 2 type definitions - --> stdlib/builtins.pyi:2829:7 - | - 2828 | @disjoint_base - 2829 | class list(MutableSequence[_T]): - | ---- - 2830 | """Built-in mutable sequence. - | - ::: stdlib/types.pyi:969:11 - | - 967 | if sys.version_info >= (3, 10): - 968 | @final - 969 | class NoneType: - | -------- - 970 | """The type of the None singleton.""" - | + info: Found 1 type definition + --> main.py:4:7 + | + 2 | x: "list['int | MyClass'] | None" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | "#); } @@ -1170,29 +1156,22 @@ mod tests { assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type definition]: Go to type definition - --> main.py:2:4 + --> main.py:2:19 | 2 | x: "list['int' | 'MyClass'] | None" - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Clicking here + | ^^^^^^^ Clicking here 3 | 4 | class MyClass: | - info: Found 2 type definitions - --> stdlib/builtins.pyi:2829:7 - | - 2828 | @disjoint_base - 2829 | class list(MutableSequence[_T]): - | ---- - 2830 | """Built-in mutable sequence. - | - ::: stdlib/types.pyi:969:11 - | - 967 | if sys.version_info >= (3, 10): - 968 | @final - 969 | class NoneType: - | -------- - 970 | """The type of the None singleton.""" - | + info: Found 1 type definition + --> main.py:4:7 + | + 2 | x: "list['int' | 'MyClass'] | None" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | "#); } @@ -1209,29 +1188,22 @@ mod tests { assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type definition]: Go to type definition - --> main.py:2:4 + --> main.py:2:11 | 2 | x: "list['MyClass' | 'str'] | None" - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Clicking here + | ^^^^^^^ Clicking here 3 | 4 | class MyClass: | - info: Found 2 type definitions - --> stdlib/builtins.pyi:2829:7 - | - 2828 | @disjoint_base - 2829 | class list(MutableSequence[_T]): - | ---- - 2830 | """Built-in mutable sequence. - | - ::: stdlib/types.pyi:969:11 - | - 967 | if sys.version_info >= (3, 10): - 968 | @final - 969 | class NoneType: - | -------- - 970 | """The type of the None singleton.""" - | + info: Found 1 type definition + --> main.py:4:7 + | + 2 | x: "list['MyClass' | 'str'] | None" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | "#); } @@ -1248,29 +1220,22 @@ mod tests { assert_snapshot!(test.goto_type_definition(), @r#" info[goto-type definition]: Go to type definition - --> main.py:2:4 + --> main.py:2:13 | 2 | x: """'list["MyClass" | "str"]' | None""" - | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Clicking here + | ^^^^^^^^^ Clicking here 3 | 4 | class MyClass: | - info: Found 2 type definitions - --> stdlib/builtins.pyi:2829:7 - | - 2828 | @disjoint_base - 2829 | class list(MutableSequence[_T]): - | ---- - 2830 | """Built-in mutable sequence. - | - ::: stdlib/types.pyi:969:11 - | - 967 | if sys.version_info >= (3, 10): - 968 | @final - 969 | class NoneType: - | -------- - 970 | """The type of the None singleton.""" - | + info: Found 1 type definition + --> stdlib/ty_extensions.pyi:14:1 + | + 13 | # Types + 14 | Unknown = object() + | ------- + 15 | AlwaysTruthy = object() + 16 | AlwaysFalsy = object() + | "#); } From 45d9d4c2e5204d2cc18eba41a202dd9a338eb721 Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 27 Jan 2026 15:15:17 -0500 Subject: [PATCH 08/10] add hover/goto-decl tests --- crates/ty_ide/src/goto_declaration.rs | 206 +++++++++++++++++++++ crates/ty_ide/src/hover.rs | 254 ++++++++++++++++++++++++++ 2 files changed, 460 insertions(+) diff --git a/crates/ty_ide/src/goto_declaration.rs b/crates/ty_ide/src/goto_declaration.rs index ad5ea2bbb9318b..687c42ab39fded 100644 --- a/crates/ty_ide/src/goto_declaration.rs +++ b/crates/ty_ide/src/goto_declaration.rs @@ -1109,6 +1109,212 @@ def another_helper(path): assert_snapshot!(test.goto_declaration(), @"No goto target found"); } + #[test] + fn goto_declaration_string_annotation_nested1() { + let test = cursor_test( + r#" + x: "list['MyClass | int'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_declaration(), @r#" + info[goto-declaration]: Go to declaration + --> main.py:2:11 + | + 2 | x: "list['MyClass | int'] | None" + | ^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 declaration + --> main.py:4:7 + | + 2 | x: "list['MyClass | int'] | None" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | + "#); + } + + #[test] + fn goto_declaration_string_annotation_nested2() { + let test = cursor_test( + r#" + x: "list['int | MyClass'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_declaration(), @r#" + info[goto-declaration]: Go to declaration + --> main.py:2:17 + | + 2 | x: "list['int | MyClass'] | None" + | ^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 declaration + --> main.py:4:7 + | + 2 | x: "list['int | MyClass'] | None" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | + "#); + } + + #[test] + fn goto_declaration_string_annotation_nested3() { + let test = cursor_test( + r#" + x: "list['int | None'] | MyClass" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_declaration(), @r#" + info[goto-declaration]: Go to declaration + --> main.py:2:26 + | + 2 | x: "list['int | None'] | MyClass" + | ^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 declaration + --> main.py:4:7 + | + 2 | x: "list['int | None'] | MyClass" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | + "#); + } + + #[test] + fn goto_declaration_string_annotation_nested4() { + let test = cursor_test( + r#" + x: "list['int' | 'MyClass'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_declaration(), @r#" + info[goto-declaration]: Go to declaration + --> main.py:2:19 + | + 2 | x: "list['int' | 'MyClass'] | None" + | ^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 declaration + --> main.py:4:7 + | + 2 | x: "list['int' | 'MyClass'] | None" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | + "#); + } + + #[test] + fn goto_declaration_string_annotation_nested5() { + let test = cursor_test( + r#" + x: "list['MyClass' | 'str'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_declaration(), @r#" + info[goto-declaration]: Go to declaration + --> main.py:2:11 + | + 2 | x: "list['MyClass' | 'str'] | None" + | ^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 declaration + --> main.py:4:7 + | + 2 | x: "list['MyClass' | 'str'] | None" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | + "#); + } + + #[test] + fn goto_declaration_string_annotation_too_nested1() { + let test = cursor_test( + r#" + x: """'list["MyClass" | "str"]' | None""" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_declaration(), @"No goto target found"); + } + + #[test] + fn goto_declaration_string_annotation_too_nested2() { + let test = cursor_test( + r#" + x: """'list["int" | "str"]' | MyClass""" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.goto_declaration(), @r#" + info[goto-declaration]: Go to declaration + --> main.py:2:31 + | + 2 | x: """'list["int" | "str"]' | MyClass""" + | ^^^^^^^ Clicking here + 3 | + 4 | class MyClass: + | + info: Found 1 declaration + --> main.py:4:7 + | + 2 | x: """'list["int" | "str"]' | MyClass""" + 3 | + 4 | class MyClass: + | ------- + 5 | """some docs""" + | + "#); + } + #[test] fn goto_declaration_nested_instance_attribute() { let test = cursor_test( diff --git a/crates/ty_ide/src/hover.rs b/crates/ty_ide/src/hover.rs index 94d9f7785955fa..fd32baa2e3f8c3 100644 --- a/crates/ty_ide/src/hover.rs +++ b/crates/ty_ide/src/hover.rs @@ -1201,6 +1201,260 @@ mod tests { "#); } + #[test] + fn goto_type_string_annotation_nested1() { + let test = cursor_test( + r#" + x: "list['MyClass | int'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.hover(), @r#" + MyClass + --------------------------------------------- + some docs + + --------------------------------------------- + ```python + MyClass + ``` + --- + some docs + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:2:11 + | + 2 | x: "list['MyClass | int'] | None" + | ^^-^^^^ + | | | + | | Cursor offset + | source + 3 | + 4 | class MyClass: + | + "#); + } + + #[test] + fn goto_type_string_annotation_nested2() { + let test = cursor_test( + r#" + x: "list['int | MyClass'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.hover(), @r#" + MyClass + --------------------------------------------- + some docs + + --------------------------------------------- + ```python + MyClass + ``` + --- + some docs + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:2:17 + | + 2 | x: "list['int | MyClass'] | None" + | ^^-^^^^ + | | | + | | Cursor offset + | source + 3 | + 4 | class MyClass: + | + "#); + } + + #[test] + fn goto_type_string_annotation_nested3() { + let test = cursor_test( + r#" + x: "list['int | None'] | MyClass" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.hover(), @r#" + MyClass + --------------------------------------------- + some docs + + --------------------------------------------- + ```python + MyClass + ``` + --- + some docs + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:2:26 + | + 2 | x: "list['int | None'] | MyClass" + | ^^-^^^^ + | | | + | | Cursor offset + | source + 3 | + 4 | class MyClass: + | + "#); + } + + #[test] + fn goto_type_string_annotation_nested4() { + let test = cursor_test( + r#" + x: "list['int' | 'MyClass'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.hover(), @r#" + MyClass + --------------------------------------------- + some docs + + --------------------------------------------- + ```python + MyClass + ``` + --- + some docs + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:2:19 + | + 2 | x: "list['int' | 'MyClass'] | None" + | ^^-^^^^ + | | | + | | Cursor offset + | source + 3 | + 4 | class MyClass: + | + "#); + } + + #[test] + fn goto_type_string_annotation_nested5() { + let test = cursor_test( + r#" + x: "list['MyClass' | 'str'] | None" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.hover(), @r#" + MyClass + --------------------------------------------- + some docs + + --------------------------------------------- + ```python + MyClass + ``` + --- + some docs + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:2:11 + | + 2 | x: "list['MyClass' | 'str'] | None" + | ^^-^^^^ + | | | + | | Cursor offset + | source + 3 | + 4 | class MyClass: + | + "#); + } + + #[test] + fn goto_type_string_annotation_too_nested1() { + let test = cursor_test( + r#" + x: """'list["MyClass" | "str"]' | None""" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.hover(), @r#" + Unknown + --------------------------------------------- + ```python + Unknown + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:2:13 + | + 2 | x: """'list["MyClass" | "str"]' | None""" + | ^^^-^^^^^ + | | | + | | Cursor offset + | source + 3 | + 4 | class MyClass: + | + "#); + } + + #[test] + fn goto_type_string_annotation_too_nested2() { + let test = cursor_test( + r#" + x: """'list["int" | "str"]' | MyClass""" + + class MyClass: + """some docs""" + "#, + ); + + assert_snapshot!(test.hover(), @r#" + MyClass + --------------------------------------------- + some docs + + --------------------------------------------- + ```python + MyClass + ``` + --- + some docs + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:2:31 + | + 2 | x: """'list["int" | "str"]' | MyClass""" + | ^^-^^^^ + | | | + | | Cursor offset + | source + 3 | + 4 | class MyClass: + | + "#); + } + #[test] fn hover_overload_type_disambiguated1() { let test = CursorTest::builder() From ff2cd59b60e71009c47fa7d85ef762b15065046a Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 27 Jan 2026 15:21:56 -0500 Subject: [PATCH 09/10] update mdtest to check another case --- .../resources/mdtest/annotations/string.md | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/crates/ty_python_semantic/resources/mdtest/annotations/string.md b/crates/ty_python_semantic/resources/mdtest/annotations/string.md index a4e9fb5ee0982b..874987e1a020b4 100644 --- a/crates/ty_python_semantic/resources/mdtest/annotations/string.md +++ b/crates/ty_python_semantic/resources/mdtest/annotations/string.md @@ -128,18 +128,24 @@ reveal_type(Aliases.not_forward) # revealed: str ```py a: "int" = 1 b: "'int'" = 1 -c: "Foo" +# error: [invalid-syntax-in-forward-annotation] "Too many levels of nested string annotations" +c: """'"int"'""" = 1 +d: "Foo" # error: [invalid-assignment] "Object of type `Literal[1]` is not assignable to `Foo`" -d: "Foo" = 1 +e: "Foo" = 1 +# error: [invalid-syntax-in-forward-annotation] "too complex" +f: "'str | int | bool | Foo | Bar'" = 1 class Foo: ... -c = Foo() +d = Foo() reveal_type(a) # revealed: Literal[1] reveal_type(b) # revealed: Literal[1] -reveal_type(c) # revealed: Foo +reveal_type(c) # revealed: Literal[1] reveal_type(d) # revealed: Foo +reveal_type(e) # revealed: Foo +reveal_type(f) # revealed: Literal[1] ``` ## Parameter From eca363d9625208f216a7ff5067823e358319af6a Mon Sep 17 00:00:00 2001 From: Aria Desires Date: Tue, 27 Jan 2026 23:20:46 -0500 Subject: [PATCH 10/10] do less work --- crates/ruff_db/src/parsed.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/ruff_db/src/parsed.rs b/crates/ruff_db/src/parsed.rs index 9a35a7a2f439b4..a002ea4cdfb39f 100644 --- a/crates/ruff_db/src/parsed.rs +++ b/crates/ruff_db/src/parsed.rs @@ -214,7 +214,7 @@ mod indexed { let (index, max_index) = sub_indices(parent_index)?; let mut visitor = Visitor { overflowed: false, - nodes: Some(Vec::new()), + nodes: None, index, max_index, };