diff --git a/.config/nextest.toml b/.config/nextest.toml index 508a7a42ac..23ae6da5a3 100644 --- a/.config/nextest.toml +++ b/.config/nextest.toml @@ -7,6 +7,14 @@ serial = { max-threads = 1 } filter = 'binary(file_watching)' test-group = 'serial' +# a divergence harness shard works on `SHARD_WORKERS` blocks at once, each a `by` and +# a python process (`crates/ty/tests/mdtest_divergence.rs`). nextest counts them against +# the machine's cores, so shards running beside one another and beside other tests do +# not oversubscribe it +[[profile.default.overrides]] +filter = 'test(clean_mdtest_blocks_run)' +threads-required = 2 + # the transpiler's runtime tests transpile for the interpreter they run on. a few # programs need a newer python than the one found, or more of it — # `typing_extensions`, the `regex` package — which a machine often does not have. @@ -32,40 +40,19 @@ slow-timeout = { period = "1s", terminate-after = 60 } # Show slow jobs in the final summary final-status-level = "slow" -# The divergence harness runs its blocks in parallel, but on a 2-core CI runner -# it still spends up to a minute or two spawning ~200 transpile/python subprocess -# pairs (plus a possible one-off uv python download), so exempt it from the -# aggressive 60s deadlock cutoff that would otherwise kill it mid-run. -# -# The budget grows with every basedpython feature that adds mdtest blocks — it was -# already at 152s of the previous 180s ceiling — so the ceiling is deliberately -# well clear of the current cost rather than just above it. `type def` blocks are -# the most expensive kind, because transpiling one evaluates its type functions, -# and each evaluation spawns an interpreter. -# -# It had drifted back to "just above it": 279.6s on main and 284.0s after the -# 2026-08-13 upstream merge, against a 360s ceiling. Runner speed varies by more -# than the 21% of headroom that left, and the same commit timed out at 360s on two -# runs out of three while passing at 284s on the third. 10 minutes puts the cost -# back under half the ceiling and still catches a deadlock. -# -# And it drifted back again, the same way. On windows the harness measured 561.1s -# on main against the 600s ceiling — 6.5% of headroom — and basedpython-ui's -# mdtests, which took the runnable corpus from 1086 blocks to 1137 (+4.7%), were -# enough to exceed it. The cost is a transpile/python subprocess pair per block, -# and windows spawns processes far more slowly than linux, so windows is where the -# ceiling binds first. 20 minutes puts the cost back under half, as before. +# The divergence harness transpiles and runs every checker-clean `by` block in the +# mdtests, and checks each with its unused imports removed. A block costs a +# `by transpile`, a `by check --fix` and a python run, and the transpile and the +# check each build a database and infer what the block needs of the builtins from +# scratch: that, not starting the processes, is the cost, and it grows with every +# basedpython feature that adds mdtest blocks. # -# The harness already parallelises internally over a work-stealing pool, so this -# is not a serialisation problem: it is that the corpus only ever grows while a CI -# runner has two cores. Cutting the per-block cost is the lever, and the harness -# now takes the cheapest one — a block naming a module the interpreter does not -# have is dropped before it is transpiled rather than after, which measured 46.3s -# to 40.4s (12.7%) locally. That more than pays for the blocks added here, but the -# ceiling is raised anyway: 561.1s of 600s was already too close before any of -# them. The next lever, if this drifts a fourth time, is calling the transpiler -# in-process instead of spawning `by` per block, which would remove half the -# process spawns outright. +# The corpus is spread over eight shards by `shard_of` in +# `crates/ty/tests/mdtest_divergence.rs`, each a test of its own, so the limit holds +# one shard rather than the whole corpus. Even so a shard is minutes of work on a +# two-core runner, and windows spawns processes far more slowly than linux, so the +# shards are exempt from the 60s deadlock cutoff. If a shard approaches this limit, +# the lever is more shards, not a higher limit. [[profile.ci.overrides]] filter = 'test(clean_mdtest_blocks_run)' slow-timeout = { period = "60s", terminate-after = 20 } diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index fae323fc84..08d97020d2 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -769,7 +769,10 @@ jobs: version: "0.13.5" checksum: "a38b8d1902b7cfbc76b423ba1bb456f623f42064cdc72628b6075b89afd1019a" enable-cache: "true" - cache-python: true + # basedpython: the install below gives the interpreter a minor version link, a + # junction on windows, which the cache does not restore as one: every run after + # the one that saved it failed to replace it (`os error 267`) + cache-python: false - name: "Install uv (macOS)" if: runner.os == 'macOS' uses: astral-sh/setup-uv@1c37ad07a6a961277cf70c0d37d6f313000f5884 # v10.3.0 diff --git a/Cargo.lock b/Cargo.lock index df9bd46619..32e71bca83 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4898,6 +4898,7 @@ dependencies = [ "serde", "serde_json", "tempfile", + "test-case", "tikv-jemallocator", "toml 1.1.6+spec-1.1.0", "tracing", diff --git a/crates/by_transforms/src/transforms/ast_driver.rs b/crates/by_transforms/src/transforms/ast_driver.rs index 7001cc1073..b8fc6362bc 100644 --- a/crates/by_transforms/src/transforms/ast_driver.rs +++ b/crates/by_transforms/src/transforms/ast_driver.rs @@ -3239,9 +3239,68 @@ mod subsumed_lowerings { check(&type_test_target_cases(), PythonVersion::PY312); } + /// the passes [`type_expression_cases`] has cases for. each pass's cases are a test of their + /// own, which runs beside the others rather than after them + const TYPE_EXPRESSION_PASSES: &[&str] = &[ + "TupleLiteralTypePass", + "CallableSyntaxPass", + "AnonNamedTuplePass", + "ProtocolTypePass", + "TypedDictLiteralPass", + "CheckedCastPass", + ]; + + fn check_type_expressions_of(pass: &str) { + let cases: Vec = type_expression_cases() + .into_iter() + .filter(|case| case.pass == pass) + .collect(); + assert!(!cases.is_empty(), "no type expression case is for `{pass}`"); + check(&cases, PythonVersion::PY310); + } + #[test] - fn a_type_expression_a_pass_moves_is_written_lowered() { - check(&type_expression_cases(), PythonVersion::PY310); + fn a_type_expression_a_tuple_literal_type_moves_is_written_lowered() { + check_type_expressions_of("TupleLiteralTypePass"); + } + + #[test] + fn a_type_expression_a_callable_moves_is_written_lowered() { + check_type_expressions_of("CallableSyntaxPass"); + } + + #[test] + fn a_type_expression_an_anonymous_named_tuple_moves_is_written_lowered() { + check_type_expressions_of("AnonNamedTuplePass"); + } + + #[test] + fn a_type_expression_a_protocol_moves_is_written_lowered() { + check_type_expressions_of("ProtocolTypePass"); + } + + #[test] + fn a_type_expression_a_typed_dict_literal_moves_is_written_lowered() { + check_type_expressions_of("TypedDictLiteralPass"); + } + + #[test] + fn a_type_expression_a_checked_cast_moves_is_written_lowered() { + check_type_expressions_of("CheckedCastPass"); + } + + /// a case for a pass with no test of its own would never run + #[test] + fn every_type_expression_pass_has_a_test() { + let mut passes: Vec<&str> = type_expression_cases() + .iter() + .map(|case| case.pass) + .collect(); + passes.sort_unstable(); + passes.dedup(); + let mut tested = TYPE_EXPRESSION_PASSES.to_vec(); + tested.sort_unstable(); + assert_eq!(passes, tested); } #[test] diff --git a/crates/ty/Cargo.toml b/crates/ty/Cargo.toml index 34ed634de8..d58ca7819a 100644 --- a/crates/ty/Cargo.toml +++ b/crates/ty/Cargo.toml @@ -83,6 +83,7 @@ insta = { workspace = true, features = ["filters", "json"] } insta-cmd = { workspace = true } regex = { workspace = true } tempfile = { workspace = true } +test-case = { workspace = true } toml = { workspace = true } zip = { workspace = true } diff --git a/crates/ty/tests/mdtest_divergence.rs b/crates/ty/tests/mdtest_divergence.rs index 5fa332f893..8f28974971 100644 --- a/crates/ty/tests/mdtest_divergence.rs +++ b/crates/ty/tests/mdtest_divergence.rs @@ -30,6 +30,8 @@ use std::process::{Command, Stdio}; use std::sync::Mutex; use std::sync::atomic::{AtomicUsize, Ordering}; +use test_case::test_case; + fn mdtest_dir() -> PathBuf { Path::new(env!("CARGO_MANIFEST_DIR")).join("../ty_python_semantic/resources/mdtest") } @@ -449,12 +451,46 @@ fn with_reveal_stub(transpiled: &str) -> String { } } -#[test] +/// how many tests the blocks are spread over. each is a test of its own, so nextest runs them +/// beside one another and holds each to its own time limit, where one test running the whole +/// corpus was held to a limit that the corpus outgrows as features add blocks +const SHARDS: u64 = 8; + +/// the shard the blocks of the mdtest at `name` run in. a path's FNV-1a hash keeps a file in +/// one shard as others come and go, and spreads the files without a list to rebalance. an +/// entry of [`EXCLUDED`], [`REQUIRES`] or [`DIVERGES_BELOW`] goes to its file's shard the same +/// way, so the shard that runs a section is the one that checks the entries naming it +fn shard_of(name: &str) -> u64 { + name.bytes().fold(0xcbf2_9ce4_8422_2325, |hash, byte| { + (hash ^ u64::from(byte)).wrapping_mul(0x0100_0000_01b3) + }) % SHARDS +} + +/// the blocks a shard works on at once. each alternates a cpu-bound `by` with a wait on the +/// python it runs, so two keep a core busy. nextest runs shards beside one another and beside +/// other tests, so a shard declares these threads to it, as `threads-required` in +/// `.config/nextest.toml`, rather than sizing itself to the whole machine +const SHARD_WORKERS: usize = 2; + +#[test_case(0)] +#[test_case(1)] +#[test_case(2)] +#[test_case(3)] +#[test_case(4)] +#[test_case(5)] +#[test_case(6)] +#[test_case(7)] +fn clean_mdtest_blocks_run(shard: u64) { + run_shard(shard); +} + +/// the checker-clean blocks of the mdtests in `shard`, each transpiled and run #[expect( clippy::print_stderr, reason = "skip diagnostic when python is unavailable" )] -fn clean_mdtest_blocks_run() { +fn run_shard(shard: u64) { + assert!(shard < SHARDS, "shard {shard} is past the last of {SHARDS}"); let Some(python) = python() else { eprintln!("skipping: no python 3.13 interpreter available (uv on unix, or set PYTHON)"); return; @@ -581,6 +617,9 @@ fn clean_mdtest_blocks_run() { .expect("an mdtest is under the mdtest directory") .to_string_lossy() .replace('\\', "/"); + if shard_of(&name) != shard { + continue; + } let markdown = fs::read_to_string(file).expect("read mdtest"); for (i, block) in by_blocks(&markdown).into_iter().enumerate() { if has_expected_diagnostics(&block.source) || block.multi_file { @@ -624,13 +663,13 @@ fn clean_mdtest_blocks_run() { let stale: Vec = EXCLUDED .iter() .zip(&excluded_seen) - .filter(|(_, seen)| !**seen) + .filter(|((file, ..), seen)| shard_of(file) == shard && !**seen) .map(|((file, heading, _), _)| format!("{file}: {heading}")) .chain( REQUIRES .iter() .zip(&requires_seen) - .filter(|(_, seen)| !**seen) + .filter(|((file, ..), seen)| shard_of(file) == shard && !**seen) .map(|((file, heading, _, _), _)| { format!("{file}: {}", heading.unwrap_or("every section")) }), @@ -639,7 +678,7 @@ fn clean_mdtest_blocks_run() { DIVERGES_BELOW .iter() .zip(&diverges_below_seen) - .filter(|(_, seen)| !**seen) + .filter(|((file, ..), seen)| shard_of(file) == shard && !**seen) .map(|((file, heading, _, _), _)| format!("{file}: {heading}")), ) .collect(); @@ -665,14 +704,7 @@ fn clean_mdtest_blocks_run() { None => failures.lock().unwrap().push(report), }; let next = AtomicUsize::new(0); - // each block alternates a cpu-bound `by transpile` with a wait on the python - // subprocess, and `by` startup also reads typeshed from disk — so a little - // oversubscription past the core count overlaps that i/o and keeps the cores - // busy, while the cap bounds how many processes are ever live at once - let workers = std::thread::available_parallelism() - .map_or(4, |n| n.get() * 2) - .clamp(1, 16) - .min(total); + let workers = SHARD_WORKERS.min(total); // work-stealing pool: each worker claims the next index and processes it // until the list is drained. the closures borrow the shared state (the @@ -774,7 +806,7 @@ fn clean_mdtest_blocks_run() { let refused = refused.into_inner().expect("refusals mutex poisoned"); let diverged = diverged.into_inner().expect("divergences mutex poisoned"); for (entry, (file, heading, floor, _)) in REQUIRES.iter().enumerate() { - if minor < *floor && !refused[entry] { + if shard_of(file) == shard && minor < *floor && !refused[entry] { failures.push(format!( "{file}: {}: no block is refused below 3.{floor} any more; remove it from `REQUIRES`", heading.unwrap_or("every section") @@ -782,7 +814,7 @@ fn clean_mdtest_blocks_run() { } } for (entry, (file, heading, floor, _)) in DIVERGES_BELOW.iter().enumerate() { - if minor < *floor && !diverged[entry] { + if shard_of(file) == shard && minor < *floor && !diverged[entry] { failures.push(format!( "{file}: {heading}: no block diverges below 3.{floor} any more; remove it from \ `DIVERGES_BELOW`" diff --git a/crates/ty_ide/src/hover.rs b/crates/ty_ide/src/hover.rs index 4904442065..a1493b8f2e 100644 --- a/crates/ty_ide/src/hover.rs +++ b/crates/ty_ide/src/hover.rs @@ -8,15 +8,18 @@ use ruff_db::files::FileRange; use ruff_db::parsed::{ParsedModuleRef, parsed_module}; use ruff_db::source::source_text; use ruff_python_ast::find_node::covering_node; +use ruff_python_ast::name::Name; use ruff_python_ast::{self as ast, AnyNodeRef}; use ruff_python_literal::format::FormatSpec; use ruff_python_literal::strftime; use ruff_text_size::{Ranged, TextRange, TextSize}; use std::fmt::{self, Display, Formatter}; use ty_python_core::ProgramFile; -use ty_python_semantic::ProgramEnvironment; use ty_python_semantic::types::ide_support::{resolved_call_signature, typed_dict_key_hover}; -use ty_python_semantic::types::{KnownInstanceType, SpecialFormType, Type, TypeVarVariance}; +use ty_python_semantic::types::{ + BoundTypeVarInstance, KnownInstanceType, SpecialFormType, Type, TypeVarVariance, +}; +use ty_python_semantic::{DisplaySettings, ProgramEnvironment}; use ty_python_semantic::types::format::{SpecLanguage, spec_language}; use ty_python_semantic::{HasType, SemanticModel, TypeQualifiers, with_display_for_file}; @@ -72,6 +75,7 @@ fn hover_inner<'db>( range: FileRange::new(file.file(db), range), value: Hover { program_file: file, + type_params_in_scope: Vec::new(), contents: vec![ HoverContent::Type { ty, @@ -158,7 +162,7 @@ fn hover_inner<'db>( }, |typevar| HoverContent::Type { ty: Type::TypeVar(typevar), - variance: Some(typevar.variance(db)), + variance: (!typevar.binds_function(db)).then(|| typevar.variance(db)), qualifiers, }, ) @@ -181,7 +185,7 @@ fn hover_inner<'db>( } Type::TypeVar(typevar) => HoverContent::Type { ty, - variance: Some(typevar.variance(db)), + variance: (!typevar.binds_function(db)).then(|| typevar.variance(db)), qualifiers, }, _ => HoverContent::Type { @@ -204,11 +208,22 @@ fn hover_inner<'db>( return None; } + // the semantic index records no scope for a parameter's name, only for the parameter + let type_params_in_scope = if let GotoTarget::Parameter(parameter) = goto_target { + model.type_params_in_scope(AnyNodeRef::from(parameter)) + } else { + covering_node(parsed.syntax().into(), goto_target.range()) + .find_first(|node| node.is_identifier() || node.is_expression()) + .map(|covering| model.type_params_in_scope(covering.node())) + .unwrap_or_default() + }; + Some(RangedValue { range: FileRange::new(file.file(db), goto_target.range()), value: Hover { program_file: file, contents, + type_params_in_scope, }, }) } @@ -428,6 +443,7 @@ fn format_spec_hover<'db>( value: Hover { program_file: file, contents, + type_params_in_scope: Vec::new(), }, }) } @@ -467,6 +483,9 @@ impl fmt::Display for FormatSpecHover { pub struct Hover<'db> { program_file: ProgramFile<'db>, contents: Vec>, + /// the type parameters an annotation at the hovered position names by a bare name, which + /// the hover writes that way too + type_params_in_scope: Vec<(BoundTypeVarInstance<'db>, Name)>, } impl<'db> Hover<'db> { @@ -485,7 +504,9 @@ impl<'db> Hover<'db> { kind.horizontal_line().fmt(f)?; } - content.display(db, &env, kind).fmt(f)?; + content + .display(db, &env, &self.type_params_in_scope, kind) + .fmt(f)?; first = false; } @@ -550,11 +571,13 @@ impl<'db> HoverContent<'db> { &'a self, db: &'db dyn Db, env: &'a ProgramEnvironment<'db>, + type_params_in_scope: &'a [(BoundTypeVarInstance<'db>, Name)], kind: MarkupKind, ) -> DisplayHoverContent<'a, 'db> { DisplayHoverContent { db, env, + type_params_in_scope, content: self, kind, } @@ -564,6 +587,7 @@ impl<'db> HoverContent<'db> { pub(crate) struct DisplayHoverContent<'a, 'db> { db: &'db dyn Db, env: &'a ProgramEnvironment<'db>, + type_params_in_scope: &'a [(BoundTypeVarInstance<'db>, Name)], content: &'a HoverContent<'db>, kind: MarkupKind, } @@ -573,7 +597,12 @@ impl<'db> DisplayHoverContent<'_, 'db> { let db = self.db; // Special types like `` // render poorly with python syntax-highlighting but well as xml - let ty_string = ty.display(db, self.env).multiline().to_string(); + let settings = DisplaySettings::from_possibly_ambiguous_types(db, self.env, [*ty]) + .with_type_params_in_scope(db, self.type_params_in_scope.iter().cloned()); + let ty_string = ty + .display_with(db, self.env, settings) + .multiline() + .to_string(); let syntax = if ty_string.starts_with('<') { "xml" } else { @@ -4507,10 +4536,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - AB@Alias1 (invariant) + AB (invariant) --------------------------------------------- ```python - AB@Alias1 (invariant) + AB (invariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -4533,10 +4562,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - AB@Alias1 (invariant) + AB (invariant) --------------------------------------------- ```python - AB@Alias1 (invariant) + AB (invariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -4560,10 +4589,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - AB@Alias2 (contravariant) + AB (contravariant) --------------------------------------------- ```python - AB@Alias2 (contravariant) + AB (contravariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -4589,10 +4618,10 @@ def function(): // TODO: This should just be `**AB@Alias2 ()` // https://github.com/astral-sh/ty/issues/1581 assert_snapshot!(test.hover(), @" - (**AB@Alias2) -> tuple[Unknown] + (**AB) -> tuple[Unknown] --------------------------------------------- ```python - (**AB@Alias2) -> tuple[Unknown] + (**AB) -> tuple[Unknown] ``` --------------------------------------------- info[hover]: Hovered content is @@ -4626,10 +4655,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - AB@Alias3 (covariant) + AB (covariant) --------------------------------------------- ```python - AB@Alias3 (covariant) + AB (covariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -4733,10 +4762,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@Alias (invariant) + T (invariant) --------------------------------------------- ```python - T@Alias (invariant) + T (invariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -4759,10 +4788,10 @@ def function(): // TODO: Should this be constravariant instead? assert_snapshot!(test.hover(), @" - P@Alias (covariant) + P (covariant) --------------------------------------------- ```python - P@Alias (covariant) + P (covariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -4784,10 +4813,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - Ts@Alias (covariant) + Ts (covariant) --------------------------------------------- ```python - Ts@Alias (covariant) + Ts (covariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -4800,6 +4829,194 @@ def function(): "); } + #[test] + fn hover_type_var_in_scope_is_written_by_its_name() { + let test = hover_test( + r#" + def f[T](t: T): + a = t + "#, + ); + + assert_snapshot!(test.hover(), @" + T + --------------------------------------------- + ```python + T + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:3:5 + | + 3 | a = t + | ^- Cursor offset + | | + | source + "); + } + + #[test] + fn hover_parameter_type_var_in_scope_is_written_by_its_name() { + let test = hover_test( + r#" + def f[T](t: T): ... + "#, + ); + + assert_snapshot!(test.hover(), @" + T + --------------------------------------------- + ```python + T + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:2:10 + | + 2 | def f[T](t: T): ... + | ^- Cursor offset + | | + | source + "); + } + + #[test] + fn hover_type_var_in_its_own_signature_is_written_by_its_name() { + let test = hover_test( + r#" + def f[T](t: T): ... + "#, + ); + + assert_snapshot!(test.hover(), @" + T + --------------------------------------------- + ```python + T + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:2:13 + | + 2 | def f[T](t: T): ... + | ^- Cursor offset + | | + | source + "); + } + + #[test] + fn hover_legacy_parameter_type_var_in_scope_is_written_by_its_name() { + let test = hover_test( + r#" + from typing import TypeVar + + T = TypeVar("T") + + def f(t: T): ... + "#, + ); + + assert_snapshot!(test.hover(), @" + T + --------------------------------------------- + ```python + T + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:6:7 + | + 6 | def f(t: T): ... + | ^- Cursor offset + | | + | source + "); + } + + #[test] + fn hover_legacy_type_var_in_scope_is_written_by_its_name() { + let test = hover_test( + r#" + from typing import TypeVar + + T = TypeVar("T") + + def f(t: T): + a = t + "#, + ); + + assert_snapshot!(test.hover(), @" + T + --------------------------------------------- + ```python + T + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:7:5 + | + 7 | a = t + | ^- Cursor offset + | | + | source + "); + } + + #[test] + fn hover_type_var_hidden_by_a_nearer_one_keeps_its_scope() { + let test = hover_test( + r#" + def f[T](t: T): + def g[T](u: T): + a = (t, u) + "#, + ); + + assert_snapshot!(test.hover(), @" + tuple[T@f, T] + --------------------------------------------- + ```python + tuple[T@f, T] + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:4:9 + | + 4 | a = (t, u) + | ^- Cursor offset + | | + | source + "); + } + + #[test] + fn hover_param_spec_component_in_scope_is_written_by_its_name() { + let test = hover_test( + r#" + def f[**P](*args: P.args, **kwargs: P.kwargs): + a = args + "#, + ); + + assert_snapshot!(test.hover(), @" + P.args + --------------------------------------------- + ```python + P.args + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:3:5 + | + 3 | a = args + | ^- Cursor offset + | | + | source + "); + } + #[test] fn hover_variable_assignment() { let test = hover_test( @@ -6013,10 +6230,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@Covariant (covariant) + T (covariant) --------------------------------------------- ```python - T@Covariant (covariant) + T (covariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -6037,10 +6254,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@Covariant (covariant) + T (covariant) --------------------------------------------- ```python - T@Covariant (covariant) + T (covariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -6061,10 +6278,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@Contravariant (contravariant) + T (contravariant) --------------------------------------------- ```python - T@Contravariant (contravariant) + T (contravariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -6085,10 +6302,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@Contravariant (contravariant) + T (contravariant) --------------------------------------------- ```python - T@Contravariant (contravariant) + T (contravariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -6101,6 +6318,57 @@ def function(): "); } + #[test] + fn hover_method_typevar_variance() { + let test = hover_test( + r#" + class C[T]: + def m[U](self, t: T, u: U): + pass + "#, + ); + + assert_snapshot!(test.hover(), @" + T (contravariant) + --------------------------------------------- + ```python + T (contravariant) + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:3:23 + | + 3 | def m[U](self, t: T, u: U): + | ^- Cursor offset + | | + | source + "); + + let test = hover_test( + r#" + class C[T]: + def m[U](self, t: T, u: U): + pass + "#, + ); + + assert_snapshot!(test.hover(), @" + U + --------------------------------------------- + ```python + U + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:3:29 + | + 3 | def m[U](self, t: T, u: U): + | ^- Cursor offset + | | + | source + "); + } + #[test] fn hover_function_typevar_variance() { let test = hover_test( @@ -6111,10 +6379,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@covariant (covariant) + T --------------------------------------------- ```python - T@covariant (covariant) + T ``` --------------------------------------------- info[hover]: Hovered content is @@ -6134,10 +6402,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@covariant (covariant) + T --------------------------------------------- ```python - T@covariant (covariant) + T ``` --------------------------------------------- info[hover]: Hovered content is @@ -6157,10 +6425,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@contravariant (contravariant) + T --------------------------------------------- ```python - T@contravariant (contravariant) + T ``` --------------------------------------------- info[hover]: Hovered content is @@ -6180,10 +6448,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@contravariant (contravariant) + T --------------------------------------------- ```python - T@contravariant (contravariant) + T ``` --------------------------------------------- info[hover]: Hovered content is @@ -6205,10 +6473,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@List (invariant) + T (invariant) --------------------------------------------- ```python - T@List (invariant) + T (invariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -6227,10 +6495,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@List (invariant) + T (invariant) --------------------------------------------- ```python - T@List (invariant) + T (invariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -6249,10 +6517,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@Tuple (covariant) + T (covariant) --------------------------------------------- ```python - T@Tuple (covariant) + T (covariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -6271,10 +6539,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@Tuple (covariant) + T (covariant) --------------------------------------------- ```python - T@Tuple (covariant) + T (covariant) ``` --------------------------------------------- info[hover]: Hovered content is @@ -6563,10 +6831,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@covariant (covariant) + T@covariant --------------------------------------------- ```python - T@covariant (covariant) + T@covariant ``` --------------------------------------------- info[hover]: Hovered content is @@ -6617,10 +6885,10 @@ def function(): ); assert_snapshot!(test.hover(), @" - T@contravariant (contravariant) + T@contravariant --------------------------------------------- ```python - T@contravariant (contravariant) + T@contravariant ``` --------------------------------------------- info[hover]: Hovered content is @@ -6631,6 +6899,34 @@ def function(): | | | source "); + + let test = hover_test( + r#" + from typing import Generic, TypeVar + + T = TypeVar('T', contravariant=True) + + class Contravariant(Generic[T]): + def m(self, x: T): + pass + "#, + ); + + assert_snapshot!(test.hover(), @" + T (contravariant) + --------------------------------------------- + ```python + T (contravariant) + ``` + --------------------------------------------- + info[hover]: Hovered content is + --> main.py:7:20 + | + 7 | def m(self, x: T): + | ^- Cursor offset + | | + | source + "); } #[test] diff --git a/crates/ty_ide/src/inlay_hints.rs b/crates/ty_ide/src/inlay_hints.rs index 79e8df49bb..79d01de34e 100644 --- a/crates/ty_ide/src/inlay_hints.rs +++ b/crates/ty_ide/src/inlay_hints.rs @@ -63,9 +63,16 @@ impl InlayHint { ty: Type<'db>, allow_edits: bool, named_type_arguments: bool, + type_params_in_scope: &[(BoundTypeVarInstance<'db>, Name)], ) -> Option { let position = expr.range().end(); - let details = type_display_details(context.db, context.file, ty, named_type_arguments, &[]); + let details = type_display_details( + context.db, + context.file, + ty, + named_type_arguments, + type_params_in_scope, + ); // Filter out repetitive hints like `x: T = T()` if call_matches_name(rhs, &details.label) { @@ -79,7 +86,7 @@ impl InlayHint { expr.into(), expr.range().start(), &details, - &[], + type_params_in_scope, ); let text_edits = if spelled.editable && allow_edits { @@ -485,9 +492,16 @@ impl InlayHint { context: InlayHintImportContext<'_, 'db>, function: &ast::StmtFunctionDef, returned: Type<'db>, + type_params_in_scope: &[(BoundTypeVarInstance<'db>, Name)], ) -> Self { let position = function.parameters.end(); - let details = type_display_details(context.db, context.file, returned, false, &[]); + let details = type_display_details( + context.db, + context.file, + returned, + false, + type_params_in_scope, + ); // each hint carries its own imports, because a client can apply one without the others let spelled = spell_type( @@ -496,7 +510,7 @@ impl InlayHint { function.into(), function.start(), &details, - &[], + type_params_in_scope, ); let text_edits = if spelled.editable { @@ -1513,18 +1527,22 @@ fn spell_type<'db>( /// `ty` written as an annotation in `file`, for a position in the scope around /// `scope_node`, with the imports it needs; `None` when no annotation the file /// could hold says it. +/// +/// A type parameter in `type_params_in_scope` is written by the name it comes +/// with: see [`spell_type`]. pub(crate) fn annotation_for_type<'db>( db: &'db dyn Db, file: ProgramFile<'db>, scope_node: AnyNodeRef<'_>, scope_offset: TextSize, ty: Type<'db>, + type_params_in_scope: &[(BoundTypeVarInstance<'db>, Name)], ) -> Option<(String, Vec)> { let ast = parsed_module(db, file.python_file(db)).load(db); let importer = Importer::new(db, file, &ast); // a basedpython file writes types in basedpython's own spelling with_display_for_file(db, file.file(db), || { - let details = type_display_details(db, file, ty, false, &[]); + let details = type_display_details(db, file, ty, false, type_params_in_scope); let spelled = spell_type( InlayHintImportContext { db, @@ -1535,7 +1553,7 @@ pub(crate) fn annotation_for_type<'db>( scope_node, scope_offset, &details, - &[], + type_params_in_scope, ); spelled .editable @@ -1741,9 +1759,16 @@ impl<'a, 'db> InlayHintVisitor<'a, 'db> { importer: &self.importer, }; - if let Some(inlay_hint) = - InlayHint::variable_type(context, expr, rhs, ty, allow_edits, named_type_arguments) - { + let type_params_in_scope = self.model.type_params_in_scope(expr.into()); + if let Some(inlay_hint) = InlayHint::variable_type( + context, + expr, + rhs, + ty, + allow_edits, + named_type_arguments, + &type_params_in_scope, + ) { self.push(inlay_hint); } } @@ -2430,7 +2455,13 @@ impl<'a, 'db> InlayHintVisitor<'a, 'db> { file: self.model.program_file(), importer: &self.importer, }; - self.push(InlayHint::inferred_return(context, function, returned)); + let type_params_in_scope = self.model.type_params_in_signature(function); + self.push(InlayHint::inferred_return( + context, + function, + returned, + &type_params_in_scope, + )); } /// basedpython: hint the type a property declaration leaves to its accessors @@ -3928,7 +3959,7 @@ Source with applied edits: «_raises TypeError» «out_» «reified_» - «_-> T@make» + «_-> T» «unstable_» «_reads doubled» «_depends on count» @@ -4128,13 +4159,25 @@ Source with applied edits: assert_snapshot!(test.inlay_hints(), @" - def deco[Parameters: (*: *, **: *), R](fn: (**Parameters) -> R)[ -> (**Parameters@deco) -> R@deco]: + def deco[Parameters: (*: *, **: *), R](fn: (**Parameters) -> R)[ -> (**Parameters) -> R]: def inner(*args: *Parameters, **kwargs: **Parameters) -> R: - positional[: *Parameters@deco] = args - keyword[: **Parameters@deco] = kwargs + positional[: *Parameters] = args + keyword[: **Parameters] = kwargs return fn(*args, **kwargs) return inner + + --------------------------------------------- + info[inlay-hint-location]: Inlay Hint Target + --> main.by:2:37 + | + 2 | def deco[Parameters: (*: *, **: *), R](fn: (**Parameters) -> R): + | ^ + info: Source + --> main2.py:2:87 + | + 2 | def deco[Parameters: (*: *, **: *), R](fn: (**Parameters) -> R)[ -> (**Parameters) -> R]: + | ^ "); } @@ -6674,8 +6717,8 @@ Source with applied edits: class MyClass[T, U]: def __init__(self, x: list[T], y: tuple[U, U]): - self.x[: list[T@MyClass]] = x - self.y[: tuple[U@MyClass, U@MyClass]] = y + self.x[: list[T]] = x + self.y[: tuple[U, U]] = y x[: MyClass[int, str]] = MyClass[[int, str]]([x=][42], [y=]("a", "b")) y[: tuple[MyClass[int, str], MyClass[int, str]]] = (MyClass[[int, str]]([x=][42], [y=]("a", "b")), MyClass[[int, str]]([x=][42], [y=]("a", "b"))) @@ -6691,9 +6734,20 @@ Source with applied edits: info: Source --> main2.py:LL:18 | - LL | self.x[: list[T@MyClass]] = x + LL | self.x[: list[T]] = x | ^^^^ + info[inlay-hint-location]: Inlay Hint Target + --> main.py:2:15 + | + 2 | class MyClass[T, U]: + | ^ + info: Source + --> main2.py:4:23 + | + 4 | self.x[: list[T]] = x + | ^ + info[inlay-hint-location]: Inlay Hint Target --> stdlib/builtins.byi:LL:7 | @@ -6702,9 +6756,31 @@ Source with applied edits: info: Source --> main2.py:LL:18 | - LL | self.y[: tuple[U@MyClass, U@MyClass]] = y + LL | self.y[: tuple[U, U]] = y | ^^^^^ + info[inlay-hint-location]: Inlay Hint Target + --> main.py:2:18 + | + 2 | class MyClass[T, U]: + | ^ + info: Source + --> main2.py:5:24 + | + 5 | self.y[: tuple[U, U]] = y + | ^ + + info[inlay-hint-location]: Inlay Hint Target + --> main.py:2:18 + | + 2 | class MyClass[T, U]: + | ^ + info: Source + --> main2.py:5:27 + | + 5 | self.y[: tuple[U, U]] = y + | ^ + info[inlay-hint-location]: Inlay Hint Target --> main.py:2:7 | @@ -7259,6 +7335,11 @@ Source with applied edits: info[inlay-hint-edit]: Inlay hint edits --> main.py:1:1 | + 3 | def __init__(self, x: list[T], y: tuple[U, U]): + - self.x = x + - self.y = y + 4 + self.x: list[T] = x + 5 + self.y: tuple[U, U] = y 6 | - x = MyClass([42], ("a", "b")) - y = (MyClass([42], ("a", "b")), MyClass([42], ("a", "b"))) @@ -10353,6 +10434,58 @@ Source with applied edits: "); } + #[test] + fn typevar_in_scope_inlay_hint() { + let mut test = inlay_hint_test( + " + def f[T](t: T): + a = t", + ); + + assert_snapshot!(test.inlay_hints(), @" + + def f[T](t: T): + a[: T] = t + --------------------------------------------- + info[inlay-hint-location]: Inlay Hint Target + --> main.py:2:7 + | + 2 | def f[T](t: T): + | ^ + info: Source + --> main2.py:3:9 + | + 3 | a[: T] = t + | ^ + + --------------------------------------------- + info[inlay-hint-edit]: Inlay hint edits + --> main.py:1:1 + | + 2 | def f[T](t: T): + - a = t + 3 + a: T = t + | + "); + } + + #[test] + fn hidden_typevar_inlay_hint() { + let mut test = inlay_hint_test( + " + def f[T](t: T): + def g[T](u: T): + a = t", + ); + + assert_snapshot!(test.inlay_hints(), @" + + def f[T](t: T): + def g[T](u: T): + a[: T@f] = t + "); + } + #[test] fn test_meta_typevar_inlay_hint() { let mut test = inlay_hint_test( @@ -10364,7 +10497,7 @@ Source with applied edits: assert_snapshot!(test.inlay_hints(), @" def f[T](x: type[T]): - y[: type[T@f]] = x + y[: type[T]] = x --------------------------------------------- info[inlay-hint-location]: Inlay Hint Target --> stdlib/builtins.byi:LL:7 @@ -10374,7 +10507,7 @@ Source with applied edits: info: Source --> main2.py:LL:9 | - LL | y[: type[T@f]] = x + LL | y[: type[T]] = x | ^^^^ info[inlay-hint-location]: Inlay Hint Target @@ -10385,8 +10518,8 @@ Source with applied edits: info: Source --> main2.py:3:14 | - 3 | y[: type[T@f]] = x - | ^^^ + 3 | y[: type[T]] = x + | ^ "); } diff --git a/crates/ty_ide/src/refactor/return_annotation.rs b/crates/ty_ide/src/refactor/return_annotation.rs index 37f044e144..be9940d237 100644 --- a/crates/ty_ide/src/refactor/return_annotation.rs +++ b/crates/ty_ide/src/refactor/return_annotation.rs @@ -49,6 +49,7 @@ pub(super) fn plan(context: &RefactorContext<'_>, range: TextRange) -> Result Self { - PlaceExpr::Symbol(Symbol::new(name.id.clone())) + Self::from_name(name.id.clone()) + } + + /// a `PlaceExpr::Symbol` named `name`, with empty flags + pub fn from_name(name: Name) -> Self { + PlaceExpr::Symbol(Symbol::new(name)) } /// Tries to create a `PlaceExpr` from an expression. diff --git a/crates/ty_python_semantic/src/place_load.rs b/crates/ty_python_semantic/src/place_load.rs index fe4477f9f5..3217670b68 100644 --- a/crates/ty_python_semantic/src/place_load.rs +++ b/crates/ty_python_semantic/src/place_load.rs @@ -96,7 +96,11 @@ use ty_python_core::{ use crate::Db; use crate::assumed::{is_at_or_below_stop_line, seeded_place}; -use crate::types::Type; +use crate::place::{ + Place, PlaceAndQualifiers, builtins_module_scope, class_body_implicit_symbol, + explicit_global_symbol, implicit_builtins_symbol, module_type_implicit_global_symbol, +}; +use crate::types::{ProgramEnvironment, Type, original_class_type}; use ruff_text_size::Ranged as _; /// Returns an iterator over the steps that resolve a value for a place load. @@ -794,6 +798,45 @@ pub(crate) enum ImplicitPlaceLoad<'db> { Builtin(Name), } +impl<'db> ImplicitPlaceLoad<'db> { + /// what this source supplies to a load made in `scope` + pub(crate) fn place( + self, + db: &'db dyn Db, + env: &ProgramEnvironment<'db>, + scope: ScopeId<'db>, + ) -> PlaceAndQualifiers<'db> { + match self { + ImplicitPlaceLoad::DunderClass(definition) => original_class_type(db, definition) + .map_or_else( + || Place::Undefined.into(), + |class| Place::bound(class).into(), + ), + ImplicitPlaceLoad::ClassBodySymbol(name) => { + let implicit = class_body_implicit_symbol(db, env, &name); + if implicit.place.is_definitely_bound() { + implicit + } else { + Place::Undefined.into() + } + } + ImplicitPlaceLoad::ExplicitGlobalSymbol { file, name } => { + explicit_global_symbol(db, file, &name) + } + ImplicitPlaceLoad::ModuleImplicitGlobal { file, name } => { + module_type_implicit_global_symbol(db, file, &name) + } + ImplicitPlaceLoad::Builtin(name) => { + if Some(scope) == builtins_module_scope(db, env) { + Place::Undefined.into() + } else { + implicit_builtins_symbol(db, env, &name) + } + } + } + } +} + /// The role a source plays in a place load. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum PlaceLoadSourceRole { diff --git a/crates/ty_python_semantic/src/semantic_model.rs b/crates/ty_python_semantic/src/semantic_model.rs index 186214eb87..2676bb8ee4 100644 --- a/crates/ty_python_semantic/src/semantic_model.rs +++ b/crates/ty_python_semantic/src/semantic_model.rs @@ -33,23 +33,25 @@ use crate::place_load::{ use crate::types::AnonNamedTupleClass; use crate::types::TypeExpressionFlags; use crate::types::check_order::{InferredInCheckOrder, infer_file_in_check_order}; -use crate::types::ide_support::{ImportAliasResolution, definition_for_name}; +use crate::types::ide_support::{ + ImportAliasResolution, definition_for_name, type_params_named_from, type_params_named_in, +}; use crate::types::implicit_names::implicit_name; use crate::types::list_members::{all_members, all_reachable_members}; use crate::types::reified_infer::WrittenTarget; use crate::types::runtime_spelling::RuntimeSite; use crate::types::soundness::CastCheck; use crate::types::{ - ClassPatternPositionalResult, ClassPatternPositionalSource, CycleDetector, ProgramEnvironment, - SpecialFormType, Type, TypeQualifiers, binding_type, class_pattern_positional_result, - class_pattern_positional_sources, infer_complete_scope_types, infer_definition_types, - inferred_declaration, is_discarded_dict_key_assignment, + BoundTypeVarInstance, ClassPatternPositionalResult, ClassPatternPositionalSource, + CycleDetector, ProgramEnvironment, SpecialFormType, Type, TypeQualifiers, binding_type, + class_pattern_positional_result, class_pattern_positional_sources, infer_complete_scope_types, + infer_definition_types, inferred_declaration, is_discarded_dict_key_assignment, }; use ty_python_core::definition::{Definition, DefinitionKind}; use ty_python_core::node_key::NodeKey; use ty_python_core::place::PlaceExpr; use ty_python_core::place_table; -use ty_python_core::scope::{FileScopeId, Scope}; +use ty_python_core::scope::{FileScopeId, NodeWithScopeRef, Scope}; use ty_python_core::semantic_index; use ty_python_core::symbol::Symbol; use ty_python_core::{BindingWithConstraintsIterator, Program, ProgramFile}; @@ -1766,6 +1768,47 @@ impl<'db> SemanticModel<'db> { .flat_map(move |scope| index.ancestor_scopes(scope)) } + /// the type parameters an annotation written at `node` names by their bare names, each with + /// that name + /// + /// a type parameter whose name means something else there is left out, as `f`'s `T` is + /// inside `g`: + /// + /// ```py + /// def f[T](t: T): + /// def g[T](u: T): ... + /// ``` + pub fn type_params_in_scope( + &self, + node: ast::AnyNodeRef<'_>, + ) -> Vec<(BoundTypeVarInstance<'db>, Name)> { + self.scope(node) + .map(|scope| type_params_named_in(self.db, self.file, scope)) + .unwrap_or_default() + } + + /// [`Self::type_params_in_scope`] for an annotation in `function`'s signature, which names + /// the function's own type parameters too + pub fn type_params_in_signature( + &self, + function: &ast::StmtFunctionDef, + ) -> Vec<(BoundTypeVarInstance<'db>, Name)> { + let index = semantic_index(self.db, self.file); + let body = index.node_scope(NodeWithScopeRef::Function(function)); + // a signature is evaluated outside the body, in the scope that holds the type + // parameters, or the one the function is defined in when it declares none + let Some(signature) = index.scope(body).parent() else { + return Vec::new(); + }; + type_params_named_from( + self.db, + self.file, + body, + signature, + Some(function.definition(self)), + ) + } + /// Returns the first local definition created by `covering_node`, if any. /// /// A local definition is a user-visible definition associated with `covering_node` itself, or diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index 56d158d3c6..35c9033f87 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -47,7 +47,7 @@ pub(crate) use self::infer::{ ArgumentContextOrigin, InferredDeclaration, TruthinessAnalyzer, TypeContext, TypeExpressionFlags, infer_complete_scope_types, infer_deferred_types, infer_definition_types, infer_expression_type, infer_expression_types, infer_same_file_expression_type, - infer_scope_types, is_discarded_dict_key_assignment, + infer_scope_types, is_discarded_dict_key_assignment, original_class_type, }; use self::infer::{ implicit_alias_parameters, infer_function_default_types, infer_implicit_alias_type, diff --git a/crates/ty_python_semantic/src/types/display.rs b/crates/ty_python_semantic/src/types/display.rs index b5967b8448..fc32637540 100644 --- a/crates/ty_python_semantic/src/types/display.rs +++ b/crates/ty_python_semantic/src/types/display.rs @@ -2719,9 +2719,15 @@ impl<'db> BoundTypeVarIdentity<'db> { ParamSpecAttrKind::Kwargs => "**", })?; } - f.write_str(self.identity.name(db))?; + // `P.args` names the same `P` that a bare `P` does + let in_scope = settings.type_params_in_scope.get(&match paramspec_attr { + Some(_) => self.without_paramspec_attr(db), + None => self, + }); + f.write_str(in_scope.map_or_else(|| self.identity.name(db), ast::name::Name::as_str))?; let binding_context = self.binding_context; - if let Some(binding_context_name) = binding_context.name(db) + if in_scope.is_none() + && let Some(binding_context_name) = binding_context.name(db) && let Some(definition) = binding_context.definition() && !settings.active_scopes.contains(&definition) { @@ -4225,15 +4231,12 @@ impl<'db> FmtDetailed<'db> for DisplayParameters<'_, 'db> { } ParametersKind::ParamSpec(typevar) => { let parameter_name = format!("**{}", typevar.name(db)); - let mut parameter = f.with_detail(TypeDetail::Parameter(parameter_name.clone())); - write!(parameter, "{parameter_name}")?; - let binding_context = typevar.binding_context(db); - if let Some(binding_context_name) = binding_context.name(db) - && let Some(definition) = binding_context.definition() - && !self.settings.active_scopes.contains(&definition) - { - write!(parameter, "@{binding_context_name}")?; - } + let mut parameter = f.with_detail(TypeDetail::Parameter(parameter_name)); + write!( + parameter, + "**{}", + typevar.identity(db).display_with(db, self.settings.clone()) + )?; } } diff --git a/crates/ty_python_semantic/src/types/ide_support.rs b/crates/ty_python_semantic/src/types/ide_support.rs index 21ad0ad8f4..1f9ed66463 100644 --- a/crates/ty_python_semantic/src/types/ide_support.rs +++ b/crates/ty_python_semantic/src/types/ide_support.rs @@ -4,7 +4,13 @@ use std::sync::LazyLock; use crate::FxIndexSet; use std::ops::ControlFlow; -use crate::place::{Place, implicit_builtins_symbol_scope, imported_symbol}; +use crate::place::{ + ConsideredDefinitions, Place, RequiresExplicitReExport, implicit_builtins_symbol_scope, + imported_symbol, place_by_id, place_from_bindings, +}; +use crate::place_load::{ + PlaceLoadMode, PlaceLoadResolutionStep, PlaceLoadSourceKind, resolve_place_load, +}; use crate::reachability::is_range_reachable; use crate::types::EnumLiteralType; use crate::types::abstract_methods::type_as_abstract_method; @@ -21,7 +27,7 @@ use crate::types::extensions::{ applicable_extensions, extension_can_apply, resolve_extension_member, }; use crate::types::function::FunctionDecorators; -use crate::types::generics::GenericContext; +use crate::types::generics::{GenericContext, bind_typevar, enclosing_generic_contexts}; use crate::types::implicit_names::{ImplicitNamePosition, implicit_name}; use crate::types::infer::{ infer_definition_types, nearest_enclosing_function, original_class_type, @@ -39,8 +45,8 @@ use crate::types::signatures::{ }; use crate::types::{ BoundTypeVarInstance, CallDunderError, ClassBase, ClassLiteral, ClassType, KnownClass, - KnownFunction, KnownUnion, PropertyAccessorRole, SpecialFormType, Type, TypeContext, - TypeVarBoundOrConstraints, TypeVarKind, TypeVarVariance, binding_type, + KnownFunction, KnownInstanceType, KnownUnion, PropertyAccessorRole, SpecialFormType, Type, + TypeContext, TypeVarBoundOrConstraints, TypeVarKind, TypeVarVariance, binding_type, }; use crate::{Db, HasDefinition, HasType, ProgramEnvironment, SemanticModel}; use ruff_db::files::{File, FileRange}; @@ -56,7 +62,8 @@ use ty_module_resolver::{ ImportingFile, Module, ModuleName, ResolverFile, resolve_module_confident, }; use ty_python_core::definition::{Definition, DefinitionKind, ParameterDefinitionNodeKind}; -use ty_python_core::scope::{FileScopeId, NodeWithScopeRef, ScopeId}; +use ty_python_core::place::PlaceExpr; +use ty_python_core::scope::{FileScopeId, NodeWithScopeKind, NodeWithScopeRef, ScopeId}; use ty_python_core::symbol::ScopedSymbolId; use ty_python_core::{ProgramFile, attribute_scopes, semantic_index, use_def_map}; @@ -80,6 +87,120 @@ pub use crate::types::state_invalidations::WriteSite; static CLASS_MATCHER: LazyLock> = LazyLock::new(|| NameMatcher::keyword("class")); +/// the type parameters a bare name written in `scope` names, each with that name +pub(crate) fn type_params_named_in<'db>( + db: &'db dyn Db, + file: ProgramFile<'db>, + scope: FileScopeId, +) -> Vec<(BoundTypeVarInstance<'db>, Name)> { + let index = semantic_index(db, file); + let module = parsed_module(db, file.python_file(db)).load(db); + // a type parameter scope holds the signature of what declares the parameters: its own + // scope is inside it rather than around it, and it binds the type variables the signature + // names + let owner = match index.scope(scope).node() { + NodeWithScopeKind::FunctionTypeParameters(function) => Some(( + NodeWithScopeRef::Function(function.node(&module)), + index.expect_single_definition(function), + )), + NodeWithScopeKind::ClassTypeParameters(class) => Some(( + NodeWithScopeRef::Class(class.node(&module)), + index.expect_single_definition(class), + )), + NodeWithScopeKind::TypeAliasTypeParameters(alias) => Some(( + NodeWithScopeRef::TypeAlias(alias.node(&module)), + index.expect_single_definition(alias), + )), + _ => None, + }; + match owner { + Some((owner, definition)) => { + type_params_named_from(db, file, index.node_scope(owner), scope, Some(definition)) + } + None => type_params_named_from(db, file, scope, scope, None), + } +} + +/// the type parameters of `scope` and the scopes around it that a bare name written in +/// `written` resolves to, as an annotation that binds type variables to `binding_context` +/// resolves it, each with that name +pub(crate) fn type_params_named_from<'db>( + db: &'db dyn Db, + file: ProgramFile<'db>, + scope: FileScopeId, + written: FileScopeId, + binding_context: Option>, +) -> Vec<(BoundTypeVarInstance<'db>, Name)> { + let index = semantic_index(db, file); + enclosing_generic_contexts(db, index, scope) + .flat_map(|context| context.variables(db)) + .filter_map(|param| { + let name = param.name(db).clone(); + let Some(Type::KnownInstance(KnownInstanceType::TypeVar(typevar))) = + deferred_name_load(db, file, written, name.clone()) + else { + return None; + }; + bind_typevar(db, index, written, binding_context, typevar) + .is_some_and(|bound| bound.is_same_occurrence(db, param)) + .then_some((param, name)) + }) + .collect() +} + +/// what `name` holds when an annotation in `scope` that is evaluated lazily reads it, or `None` +/// when it is not definitely bound to one value +/// +/// the annotation is one the source does not hold, so `name` is read as a string annotation's is: +/// the semantic index has no place for it in the scopes it is not written in +fn deferred_name_load<'db>( + db: &'db dyn Db, + file: ProgramFile<'db>, + scope: FileScopeId, + name: Name, +) -> Option> { + let env = ProgramEnvironment::from_file(file); + let index = semantic_index(db, file); + let mut resolution = resolve_place_load( + db, + index, + scope.to_scope_id(db, file), + PlaceExpr::from_name(name), + PlaceLoadMode::StringAnnotation, + ); + while let Some(PlaceLoadResolutionStep::Source(source)) = resolution.next() { + let place = match source.kind { + PlaceLoadSourceKind::Bindings(bindings) => { + place_from_bindings(db, &env, bindings).place + } + PlaceLoadSourceKind::DefinitionsFromOwningScope { scope, id } => { + place_by_id( + db, + scope, + id, + RequiresExplicitReExport::No, + ConsideredDefinitions::AllReachable, + ) + .place + } + PlaceLoadSourceKind::Implicit(implicit) => { + implicit.place(db, &env, scope.to_scope_id(db, file)).place + } + // only a program carrying a debugger's assumptions observes a place + PlaceLoadSourceKind::Observed(_) => return None, + }; + if place.is_definitely_bound() { + return place.ignore_possibly_undefined(); + } + // bound on some paths only: the name means this on some of them and something else on + // the others + if place.ignore_possibly_undefined().is_some() { + return None; + } + } + None +} + /// Get the primary definition kind for a name expression within a specific file. /// Returns the first definition kind that is reachable for this name in its scope. /// This is useful for IDE features like semantic tokens. diff --git a/crates/ty_python_semantic/src/types/infer/builder.rs b/crates/ty_python_semantic/src/types/infer/builder.rs index 42d5c80463..6e9894eb88 100644 --- a/crates/ty_python_semantic/src/types/infer/builder.rs +++ b/crates/ty_python_semantic/src/types/infer/builder.rs @@ -40,11 +40,10 @@ use super::{ use crate::diagnostic::format_enumeration; use crate::place::{ ConsideredDefinitions, DefinedPlace, Definedness, LookupError, Place, PlaceAndQualifiers, - RequiresExplicitReExport, TypeOrigin, builtins_module_scope, class_body_implicit_symbol, - declared_type_at_load, explicit_global_symbol, implicit_builtins_symbol, - loop_header_reachability, module_type_implicit_global_declaration, - module_type_implicit_global_symbol, place_by_id, place_from_bindings_with_reachability_cache, - place_from_declarations_with_reachability_cache, typing_extensions_symbol, + RequiresExplicitReExport, TypeOrigin, declared_type_at_load, loop_header_reachability, + module_type_implicit_global_declaration, module_type_implicit_global_symbol, place_by_id, + place_from_bindings_with_reachability_cache, place_from_declarations_with_reachability_cache, + typing_extensions_symbol, }; use crate::place_load::{ ImplicitPlaceLoad, PlaceExprPrefixLoad, PlaceExprPrefixLoads, PlaceLoadFailure, PlaceLoadMode, @@ -14793,34 +14792,7 @@ impl<'db, 'ast> TypeInferenceBuilder<'db, 'ast> { RequiresExplicitReExport::No, ConsideredDefinitions::AllReachable, ), - PlaceLoadSourceKind::Implicit(implicit) => match implicit { - ImplicitPlaceLoad::DunderClass(definition) => original_class_type(db, definition) - .map_or_else( - || Place::Undefined.into(), - |class| Place::bound(class).into(), - ), - ImplicitPlaceLoad::ClassBodySymbol(name) => { - let implicit = class_body_implicit_symbol(db, env, &name); - if implicit.place.is_definitely_bound() { - implicit - } else { - Place::Undefined.into() - } - } - ImplicitPlaceLoad::ExplicitGlobalSymbol { file, name } => { - explicit_global_symbol(db, file, &name) - } - ImplicitPlaceLoad::ModuleImplicitGlobal { file, name } => { - module_type_implicit_global_symbol(db, file, &name) - } - ImplicitPlaceLoad::Builtin(name) => { - if Some(self.scope()) == builtins_module_scope(db, env) { - Place::Undefined.into() - } else { - implicit_builtins_symbol(db, env, &name) - } - } - }, + PlaceLoadSourceKind::Implicit(implicit) => implicit.place(db, env, self.scope()), }; if narrowing_constraints.is_empty() { diff --git a/crates/ty_python_semantic/src/types/typevar.rs b/crates/ty_python_semantic/src/types/typevar.rs index 0fc098eaca..805a7cfe4d 100644 --- a/crates/ty_python_semantic/src/types/typevar.rs +++ b/crates/ty_python_semantic/src/types/typevar.rs @@ -2138,6 +2138,17 @@ impl<'db> BoundTypeVarInstance<'db> { binding_type(db, definition).is_class_literal() } + /// basedpython: whether this type parameter belongs to a function. + /// + /// Such a parameter has no variance to speak of: a specialized function is compared as the + /// plain callable it becomes, so nothing ever relates `f[A]` to `f[B]` through it. + pub fn binds_function(self, db: &'db dyn Db) -> bool { + let BindingContext::Definition(definition) = self.binding_context(db) else { + return false; + }; + binding_type(db, definition).is_function_literal() + } + /// basedpython: whether this parameter is declared `in out` on a class whose /// body never writes through it. /// diff --git a/crates/ty_test/src/lib.rs b/crates/ty_test/src/lib.rs index 9db8d752b9..c3f25d26a8 100644 --- a/crates/ty_test/src/lib.rs +++ b/crates/ty_test/src/lib.rs @@ -433,11 +433,20 @@ fn run_test( // Test to fix all fixable diagnostics and verify that they don't introduce any syntax errors. // But don't try to run fixes for tests that are expected to panic. + // + // basedpython: an mdtest calls `reveal_type` without importing it as a matter of course, and + // fixing that adds the same import to nearly every test. applying it alone would type check + // the whole test a second time, a fifth of the suite's time, for a fix that + // `directives/reveal_type.md` covers, so it is left out here as it is left out of the + // snapshots above if test.should_expect_panic().is_err() { let token_source = CancellationTokenSource::new(); let result = fix_all_diagnostics( db, - all_diagnostics, + all_diagnostics + .into_iter() + .filter(|diagnostic| !diagnostic.id().is_lint_named(&UNDEFINED_REVEAL.name())) + .collect(), Applicability::Unsafe, &token_source.token(), )