From f448499a48eb6c994a4b750f5514e3443f27b61a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?L=C3=A9r=C3=A8?= Date: Wed, 12 Aug 2026 01:05:19 -0700 Subject: [PATCH] Find references to pytest fixtures --- crates/ty_ide/src/find_references.rs | 922 +++++++++++++++++- crates/ty_ide/src/references.rs | 432 ++++++-- crates/ty_python_semantic/src/lib.rs | 5 +- crates/ty_python_semantic/src/types.rs | 5 +- .../src/types/dedicated/pytest.rs | 24 +- 5 files changed, 1302 insertions(+), 86 deletions(-) diff --git a/crates/ty_ide/src/find_references.rs b/crates/ty_ide/src/find_references.rs index f0b999175f549..ef3ba082deb49 100644 --- a/crates/ty_ide/src/find_references.rs +++ b/crates/ty_ide/src/find_references.rs @@ -1,9 +1,13 @@ use crate::goto::find_goto_target; -use crate::references::{ReferencesMode, references}; +use crate::references::{FixtureReferenceTarget, ReferencesMode, references}; use crate::{Db, ReferenceTarget}; -use ruff_text_size::TextSize; +use ruff_db::parsed::ParsedModuleRef; +use ruff_python_ast::AnyNodeRef; +use ruff_python_ast::find_node::covering_node; +use ruff_python_ast::token::TokenKind; +use ruff_text_size::{Ranged, TextSize}; use ty_python_core::ProgramFile; -use ty_python_semantic::SemanticModel; +use ty_python_semantic::{FixtureNameSource, SemanticModel, fixture_exposures_for_definition}; /// Find all references to a symbol at the given position. /// Search for references across all files in the project. @@ -17,24 +21,108 @@ pub fn find_references( let module = parsed.load(db); let model = SemanticModel::new(db, file); - // Get the definitions for the symbol at the cursor position - let goto_target = find_goto_target(&model, &module, offset)?; - let mode = if include_declaration { ReferencesMode::References } else { ReferencesMode::ReferencesSkipDeclaration }; + // A decorator's `name="..."` literal names a fixture without defining a Python symbol. + // Start a fixture-only search before the ordinary symbol lookup below. + if let Some(target) = explicit_fixture_name_at_offset(&model, &module, offset) { + return target.references(db, file, mode); + } + + // Get the definitions for the symbol at the cursor position + let goto_target = find_goto_target(&model, &module, offset)?; references(db, file, &goto_target, mode) } +/// Returns a target for the explicit fixture name at `offset`. +/// Quotes, prefixes, and token boundaries select the name's contents. +/// +/// This makes it so that an offset within `"public_name"` in the decorator +/// below will target the test parameter: +/// +/// ```python +/// import pytest +/// +/// @pytest.fixture(name="public_name") +/// def implementation(): ... +/// +/// def test_use(public_name): ... +/// ``` +fn explicit_fixture_name_at_offset<'db>( + model: &SemanticModel<'db>, + module: &ParsedModuleRef, + offset: TextSize, +) -> Option> { + let token = module + .tokens() + .at_offset(offset) + .find(|token| token.kind() == TokenKind::String)?; + let covering = covering_node(module.syntax().into(), token.range()); + let AnyNodeRef::StringLiteral(literal) = covering.node() else { + return None; + }; + + // Match a string literal in a function decorator's `name` argument: + // + // @pytest.fixture(name="resource") + // def implementation(): ... + // + // The semantic lookup below verifies that the decorator declares a fixture. + let mut ancestors = covering.ancestors(); + let mut in_name_argument = false; + let function = loop { + match ancestors.next()? { + // The literal must be the value of `name`, not another keyword. + AnyNodeRef::Keyword(keyword) + if keyword.arg.as_deref() == Some("name") + && keyword.value.is_string_literal_expr() => + { + in_name_argument = true; + } + // The decorator must belong directly to a function, not a class. + AnyNodeRef::Decorator(_) if in_name_argument => { + let AnyNodeRef::StmtFunctionDef(function) = ancestors.next()? else { + return None; + }; + break function; + } + // Skip over intermediate nodes that connect the literal, keyword, and decorator. + AnyNodeRef::StringLiteral(_) + | AnyNodeRef::ExprStringLiteral(_) + | AnyNodeRef::Arguments(_) + | AnyNodeRef::ExprCall(_) => {} + // Reject unrelated syntax, such as return annotations, and failed guards. + _ => return None, + } + }; + let definition = ty_python_core::semantic_index(model.db(), model.program_file()) + .expect_single_definition(function); + let exposures = fixture_exposures_for_definition(model.db(), definition); + + let exposure = exposures.iter().find(|exposure| { + matches!( + exposure.name_source(model.db()), + FixtureNameSource::Explicit { + declaration: Some(declaration), .. + } if declaration.file() == model.file() + && declaration.range() == literal.content_range() + ) + })?; + + Some(FixtureReferenceTarget::from_exposure(model.db(), exposure)) +} + #[cfg(test)] mod tests { use super::*; - use crate::tests::{CursorTest, IntoDiagnostic, cursor_test}; + use crate::tests::{CursorTest, IntoDiagnostic, SitePackagesCursorTestBuilder, cursor_test}; use insta::assert_snapshot; use ruff_db::diagnostic::{Annotation, Diagnostic, DiagnosticId, LintName, Severity, Span}; + use ruff_db::source::source_text; impl CursorTest { fn references(&self) -> String { @@ -2239,4 +2327,824 @@ class C: | - "); } + + #[test] + fn references_pytest_fixture_relationships_from_default_name() { + let request_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture + def resource(): ... + + copy = resource + + @pytest.fixture + def dependent(resource): + print(resource) + + def test_use(resource): + print(resource) + "#, + ); + let definition_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture + def resource(): ... + + copy = resource + + @pytest.fixture + def dependent(resource): + print(resource) + + def test_use(resource): + print(resource) + "#, + ); + + let fixture_references = request_test.references(); + assert_eq!(fixture_references, definition_test.references()); + assert_snapshot!(fixture_references, @" + info[references]: Found 6 references + --> src/test_example.py:5:5 + | + 5 | def resource(): ... + | -------- + 6 | + 7 | copy = resource + | -------- + 8 | + 9 | @pytest.fixture + 10 | def dependent(resource): + | -------- + 11 | print(resource) + | -------- + 12 | + 13 | def test_use(resource): + | -------- + 14 | print(resource) + | -------- + "); + } + + #[test] + fn references_pytest_fixture_relationships_from_explicit_name() { + let request_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture(name="resource") + def implementation(): ... + + copy = implementation + + @pytest.fixture + def dependent(resource): + print(resource) + + def test_use(resource): + print(resource) + "#, + ); + let decorator_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture(name="resource") + def implementation(): ... + + copy = implementation + + @pytest.fixture + def dependent(resource): + print(resource) + + def test_use(resource): + print(resource) + "#, + ); + + let fixture_references = request_test.references(); + assert_eq!(fixture_references, decorator_test.references()); + assert_snapshot!(fixture_references, @r#" + info[references]: Found 5 references + --> src/test_example.py:4:23 + | + 4 | @pytest.fixture(name="resource") + | -------- + | + ::: src/test_example.py:10:15 + | + 10 | def dependent(resource): + | -------- + 11 | print(resource) + | -------- + 12 | + 13 | def test_use(resource): + | -------- + 14 | print(resource) + | -------- + "#); + } + + #[test] + fn references_explicit_fixture_name_from_string_token() { + let mut test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture(name=r'''resource''') + def implementation(): ... + + def test_use(resource): + print(resource) + "#, + ); + let source = source_text(&test.db, test.cursor.file); + let end = source + .find(')') + .expect("the fixture decorator should have a closing parenthesis"); + let expected = test.references(); + assert_snapshot!(expected, @" + info[references]: Found 3 references + --> src/test_example.py:4:26 + | + 4 | @pytest.fixture(name=r'''resource''') + | -------- + 5 | def implementation(): ... + 6 | + 7 | def test_use(resource): + | -------- + 8 | print(resource) + | -------- + "); + + // Every position in the prefix, quotes, and contents selects the same name. + for offset in usize::from(test.cursor.offset)..=end { + test.cursor.offset = TextSize::try_from(offset).expect("the test offset should fit"); + assert_eq!(test.references(), expected, "cursor offset {offset}"); + } + } + + #[test] + fn explicit_fixture_name_matching_python_name_keeps_reference_families_separate() { + let definition_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture(name="resource") + def resource(): ... + + copy = resource + + def test_use(resource): + print(resource) + "#, + ); + let request_test = pytest_cursor_test( + r#" + import pytest + + @pytest.fixture(name="resource") + def resource(): ... + + copy = resource + + def test_use(resource): + print(resource) + "#, + ); + + assert_snapshot!(definition_test.references(), @" + info[references]: Found 2 references + --> src/test_example.py:5:5 + | + 5 | def resource(): ... + | -------- + 6 | + 7 | copy = resource + | -------- + "); + assert_snapshot!(request_test.references(), @r#" + info[references]: Found 3 references + --> src/test_example.py:4:23 + | + 4 | @pytest.fixture(name="resource") + | -------- + | + ::: src/test_example.py:9:14 + | + 9 | def test_use(resource): + | -------- + 10 | print(resource) + | -------- + "#); + } + + #[test] + fn references_pytest_fixture_respect_conftest_shadowing() { + let mut builder = pytest_cursor_test_builder(); + let test = builder + .source( + "conftest.py", + r#" + import pytest + + @pytest.fixture + def resource(): ... + "#, + ) + .source( + "tests/test_outer.py", + r#" + def test_outer(resource): + print(resource) + "#, + ) + .source( + "tests/nested/conftest.py", + r#" + import pytest + + @pytest.fixture + def resource(): ... + "#, + ) + .source( + "tests/nested/test_inner.py", + r#" + def test_inner(resource): + print(resource) + "#, + ) + .build(); + + assert_snapshot!(test.references(), @" + info[references]: Found 3 references + --> src/conftest.py:5:5 + | + 5 | def resource(): ... + | -------- + | + ::: src/tests/test_outer.py:2:16 + | + 2 | def test_outer(resource): + | -------- + 3 | print(resource) + | -------- + "); + } + + #[test] + fn references_pytest_imported_fixture_exposure() { + let mut builder = pytest_cursor_test_builder(); + let test = builder + .source( + "fixtures.py", + r#" + import pytest + + @pytest.fixture + def resource(): ... + "#, + ) + .source( + "test_example.py", + r#" + from fixtures import resource as alias + + def test_use(alias): + print(alias) + "#, + ) + .build(); + + assert_snapshot!(test.references(), @" + info[references]: Found 3 references + --> src/test_example.py:2:34 + | + 2 | from fixtures import resource as alias + | ----- + 3 | + 4 | def test_use(alias): + | ----- + 5 | print(alias) + | ----- + "); + assert_snapshot!(test.references_without_declaration(), @" + info[references]: Found 2 references + --> src/test_example.py:4:14 + | + 4 | def test_use(alias): + | ----- + 5 | print(alias) + | ----- + "); + } + + #[test] + fn references_pytest_fixture_through_reexport() { + let mut builder = pytest_cursor_test_builder(); + let test = builder + .source( + "fixtures.py", + r#" + import pytest + + @pytest.fixture + def resource(): ... + "#, + ) + .source( + "reexports.py", + r#" + from fixtures import resource as middle + "#, + ) + .source( + "test_example.py", + r#" + from reexports import middle + + def test_use(middle): + print(middle) + "#, + ) + .build(); + + assert_snapshot!(test.references(), @" + info[references]: Found 4 references + --> src/reexports.py:2:34 + | + 2 | from fixtures import resource as middle + | ------ + | + ::: src/test_example.py:2:23 + | + 2 | from reexports import middle + | ------ + 3 | + 4 | def test_use(middle): + | ------ + 5 | print(middle) + | ------ + "); + } + + #[test] + fn references_function_local_fixture_import_as_ordinary_alias() { + let mut builder = pytest_cursor_test_builder(); + let test = builder + .source( + "fixtures.py", + r#" + import pytest + + @pytest.fixture + def resource(): ... + "#, + ) + .source( + "test_example.py", + r#" + def helper(): + from fixtures import resource as local + print(local) + "#, + ) + .build(); + + assert_snapshot!(test.references(), @" + info[references]: Found 2 references + --> src/test_example.py:3:38 + | + 3 | from fixtures import resource as local + | ----- + 4 | print(local) + | ----- + "); + } + + #[test] + fn references_pytest_fixture_does_not_expand_through_ambiguous_request() { + let test = ambiguous_pytest_fixture_cursor_test( + r#" + import pytest + + @pytest.fixture + def first(): ... + "#, + r#" + flag: bool + if flag: + from first import first as resource + else: + from second import second as resource + + def test_ambiguous(resource): + print(resource) + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 2 references + --> src/first.py:5:5 + | + 5 | def first(): ... + | ----- + | + ::: src/test_ambiguous.py:4:23 + | + 4 | from first import first as resource + | ----- + "); + } + + #[test] + fn references_ambiguous_pytest_fixture_request_includes_all_targets() { + let test = ambiguous_pytest_fixture_cursor_test( + r#" + import pytest + + @pytest.fixture + def first(): ... + "#, + r#" + flag: bool + if flag: + from first import first as resource + else: + from second import second as resource + + def test_ambiguous(resource): + print(resource) + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 4 references + --> src/test_ambiguous.py:4:32 + | + 4 | from first import first as resource + | -------- + 5 | else: + 6 | from second import second as resource + | -------- + 7 | + 8 | def test_ambiguous(resource): + | -------- + 9 | print(resource) + | -------- + "); + } + + #[test] + fn references_pytest_fixture_preserves_non_fixture_ambiguous_target() { + let test = pytest_cursor_test( + r#" + import pytest + + flag: bool + if flag: + @pytest.fixture + def resource(): ... + else: + def resource(): ... + + resource() + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 3 references + --> src/test_example.py:7:9 + | + 7 | def resource(): ... + | -------- + 8 | else: + 9 | def resource(): ... + | -------- + 10 | + 11 | resource() + | -------- + "); + } + + #[test] + fn references_pytest_installed_core_fixture() { + let test = pytest_cursor_test( + r#" + def test_use(tmp_path): + print(tmp_path) + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 3 references + --> site-packages/_pytest/tmpdir.py:5:5 + | + 5 | def tmp_path(): ... + | -------- + | + ::: src/test_example.py:2:14 + | + 2 | def test_use(tmp_path): + | -------- + 3 | print(tmp_path) + | -------- + "); + } + + #[test] + fn references_pytest_fixture_declaration_through_external_stub() { + let definition_test = external_stub_fixture_definition_cursor_test(); + let import_test = external_stub_fixture_cursor_test( + r#" + from third_party_plugin import external_resource as resource + + def test_use(resource): + print(resource) + "#, + ); + let parameter_test = external_stub_fixture_cursor_test( + r#" + from third_party_plugin import external_resource as resource + + def test_use(resource): + print(resource) + "#, + ); + + assert_snapshot!(definition_test.references(), @" + info[references]: Found 1 references + --> src/third_party_plugin.py:5:5 + | + 5 | def external_resource(): ... + | ----------------- + "); + assert_snapshot!(import_test.references(), @" + info[references]: Found 2 references + --> site-packages/third_party_plugin.pyi:2:5 + | + 2 | def external_resource() -> object: ... + | ----------------- + | + ::: src/test_example.py:2:32 + | + 2 | from third_party_plugin import external_resource as resource + | ----------------- + "); + assert_snapshot!(parameter_test.references(), @" + info[references]: Found 3 references + --> src/test_example.py:2:53 + | + 2 | from third_party_plugin import external_resource as resource + | -------- + 3 | + 4 | def test_use(resource): + | -------- + 5 | print(resource) + | -------- + "); + } + + #[test] + fn references_pytest_fixture_through_annotated_stub() { + let test = external_stub_fixture_cursor_test_with_stub( + r#" + from third_party_plugin import external_resource + + def test_use(external_resource): + print(external_resource) + "#, + r#" + external_resource: object + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 4 references + --> site-packages/third_party_plugin.pyi:2:1 + | + 2 | external_resource: object + | ----------------- + | + ::: src/test_example.py:2:32 + | + 2 | from third_party_plugin import external_resource + | ----------------- + 3 | + 4 | def test_use(external_resource): + | ----------------- + 5 | print(external_resource) + | ----------------- + "); + } + + #[test] + fn references_explicit_pytest_fixture_stops_at_external_stub() { + let test = explicit_external_stub_fixture_cursor_test( + r#" + from third_party_plugin import implementation + + def test_use(resource): + print(resource) + "#, + ); + + assert_snapshot!(test.references(), @" + info[references]: Found 2 references + --> src/test_example.py:4:14 + | + 4 | def test_use(resource): + | -------- + 5 | print(resource) + | -------- + "); + } + + fn external_stub_fixture_definition_cursor_test() -> CursorTest { + let mut builder = pytest_cursor_test_builder(); + builder + .source( + "third_party_plugin.py", + r#" + import pytest + + @pytest.fixture + def external_resource(): ... + "#, + ) + .source( + "third_party_plugin.pyi", + r#" + def external_resource() -> object: ... + "#, + ) + .source( + "test_example.py", + r#" + from third_party_plugin import external_resource + "#, + ) + .build() + } + + fn external_stub_fixture_cursor_test(test_source: &str) -> CursorTest { + external_stub_fixture_cursor_test_with_stub( + test_source, + r#" + def external_resource() -> object: ... + "#, + ) + } + + fn external_stub_fixture_cursor_test_with_stub( + test_source: &str, + stub_source: &str, + ) -> CursorTest { + let mut builder = pytest_cursor_test_builder(); + builder + .site_packages( + "third_party_plugin.py", + r#" + import pytest + + @pytest.fixture + def external_resource(): ... + "#, + ) + .site_packages("third_party_plugin.pyi", stub_source) + .source("test_example.py", test_source) + .build() + } + + fn explicit_external_stub_fixture_cursor_test(test_source: &str) -> CursorTest { + let mut builder = pytest_cursor_test_builder(); + builder + .source( + "third_party_plugin.py", + r#" + import pytest + + @pytest.fixture(name="resource") + def implementation(): ... + "#, + ) + .source( + "third_party_plugin.pyi", + r#" + def implementation() -> object: ... + "#, + ) + .source("test_example.py", test_source) + .build() + } + + fn ambiguous_pytest_fixture_cursor_test( + first_fixture: &str, + ambiguous_test: &str, + ) -> CursorTest { + let mut builder = pytest_cursor_test_builder(); + builder + .source("first.py", first_fixture) + .source( + "second.py", + r#" + import pytest + + @pytest.fixture + def second(): ... + "#, + ) + .source("test_ambiguous.py", ambiguous_test) + .source( + "test_second.py", + r#" + from second import second as resource + + def test_second(resource): + print(resource) + "#, + ) + .build() + } + + fn pytest_cursor_test(source: &str) -> CursorTest { + pytest_cursor_test_builder() + .source("test_example.py", source) + .build() + } + + fn pytest_cursor_test_builder() -> SitePackagesCursorTestBuilder { + let mut builder = CursorTest::builder().with_site_packages(); + builder + .site_packages( + "_pytest/__init__.py", + r#" + "#, + ) + .site_packages( + "_pytest/__init__.pyi", + r#" + "#, + ) + .site_packages( + "_pytest/config/__init__.py", + r#" + default_plugins = ("tmpdir",) + "#, + ) + .site_packages( + "_pytest/mark/__init__.pyi", + r#" + "#, + ) + .site_packages( + "_pytest/mark/structures.pyi", + r#" + class MarkDecorator: + def __call__(self, *args: object, **kwargs: object) -> object: ... + + class _ParametrizeMarkDecorator(MarkDecorator): ... + + class MarkGenerator: + parametrize: _ParametrizeMarkDecorator + "#, + ) + .site_packages( + "_pytest/fixtures.pyi", + r#" + from typing import Any, Callable + + def fixture( + function: Callable[..., Any] | None = ..., + *, + name: str | None = ..., + ) -> Any: ... + "#, + ) + .site_packages( + "_pytest/tmpdir.py", + r#" + from _pytest.fixtures import fixture + + @fixture + def tmp_path(): ... + "#, + ) + .site_packages( + "pytest/__init__.pyi", + r#" + from _pytest.fixtures import fixture as fixture + from _pytest.mark.structures import MarkGenerator + + mark: MarkGenerator + "#, + ); + builder + } } diff --git a/crates/ty_ide/src/references.rs b/crates/ty_ide/src/references.rs index ba81acff39568..adb401d344626 100644 --- a/crates/ty_ide/src/references.rs +++ b/crates/ty_ide/src/references.rs @@ -13,20 +13,25 @@ use crate::goto::{Definitions, GotoTarget}; use crate::{Db, ReferenceKind, ReferenceTarget}; use rayon::prelude::*; +use ruff_db::files::File; use ruff_db::parsed::parsed_module; use ruff_python_ast::find_node::{CoveringNode, covering_node}; use ruff_python_ast::token::Tokens; use ruff_python_ast::{ self as ast, AnyNodeRef, + name::Name, visitor::source_order::{SourceOrderVisitor, TraversalSignal}, }; use ruff_text_size::Ranged; +use rustc_hash::{FxHashMap, FxHashSet}; use ty_project::parallel::{ParallelIteratorExt, minimum_parallel_job_len}; use ty_python_core::ProgramFile; use ty_python_core::definition::{Definition, DefinitionKind, DefinitionState}; use ty_python_core::scope::{FileScopeId, NodeWithScopeKind, ScopeKind}; use ty_python_semantic::{ - ImportAliasResolution, ResolvedDefinition, SemanticModel, contains_identifier, + Db as SemanticDb, FixtureExposure, FixtureNameSource, ImportAliasResolution, + ResolvedDefinition, SemanticModel, contains_identifier, fixture_bindings_for_parameter, + fixture_exposures_for_definition, pytest_global_plugin_files, }; /// Salsa snapshots coordinate clone and drop through shared state. For cached files that don't @@ -91,20 +96,55 @@ pub(crate) fn references( goto_target: &GotoTarget, mode: ReferencesMode, ) -> Option> { - let source_file = file.file(db); let model = SemanticModel::new(db, file); - let target_definitions = goto_target.definitions(&model, mode.to_import_alias_resolution())?; + let target_text = goto_target.to_string()?.into_owned(); + + let target_definitions = goto_target + .definitions(&model, mode.to_import_alias_resolution())? + .goto_declaration(&model, goto_target)?; + let import_alias_resolution = mode.to_import_alias_resolution(); + // An identifier can have both ordinary Python references and pytest fixture references. + // Keep its Python definitions alongside any fixture roots used for the same search. + let fixture_target = matches!( + mode, + ReferencesMode::References | ReferencesMode::ReferencesSkipDeclaration + ) + .then(|| FixtureReferenceTarget::from_goto_target(&model, goto_target, &target_text)) + .flatten(); + let fixture_resolution = fixture_target.map(|target| target.resolution); + let is_externally_visible_symbol = has_any_external_visible_definitions(db, &target_definitions); - let target_definitions = target_definitions.goto_declaration(&model, goto_target)?; - // Extract the target text from the goto target for fast comparison - let target_text = goto_target.to_string()?; + let is_parameter = parameter_owner_is_externally_visible(db, &target_definitions); - // Find all of the references to the symbol within this file - let mut references = references_for_file(db, file, &target_definitions, &target_text, mode); + let search = LocalReferenceSearch { + target_text, + target_definitions, + import_alias_resolution, + fixture_resolution, + }; + references_for_search( + db, + file, + &search, + mode, + is_externally_visible_symbol, + is_parameter, + ) +} - // Check if we should search across files based on the mode +fn references_for_search( + db: &dyn Db, + file: ProgramFile<'_>, + search: &LocalReferenceSearch<'_>, + mode: ReferencesMode, + is_externally_visible_symbol: bool, + is_parameter: bool, +) -> Option> { + let source_file = file.file(db); + let mut references = references_for_file(db, file, search, mode); + let has_fixture_target = search.fixture_resolution.is_some(); let search_across_files = matches!( mode, ReferencesMode::References @@ -112,45 +152,71 @@ pub(crate) fn references( | ReferencesMode::RenameMultiFile ); - // Parameters are local by scope, but they can have cross-file references via keyword - // argument labels (e.g. `f(param=...)`). Handle this case with a narrow scan that only - // considers keyword arguments. - let is_parameter = parameter_owner_is_externally_visible(db, &target_definitions); - - if search_across_files && (is_parameter || is_externally_visible_symbol) { - let program = model.program(); - let files = db.project().files(db); - let files: Vec<_> = files.iter().filter(|other| *other != source_file).collect(); + if search_across_files && (has_fixture_target || is_parameter || is_externally_visible_symbol) { + let program = file.program(db); + let files: Vec<_> = if let Some(fixture_resolution) = &search.fixture_resolution { + let mut files: FxHashSet = db.project().files(db).iter().collect(); + files.extend(fixture_resolution.files.iter().copied()); + files.extend( + pytest_global_plugin_files(db, program) + .iter() + .map(|file| file.file(db)), + ); + files.remove(&source_file); + files.into_iter().collect() + } else { + db.project() + .files(db) + .iter() + .filter(|other| *other != source_file) + .collect() + }; let minimum_job_len = minimum_parallel_job_len(files.len(), MAX_MIN_FILES_PER_PARALLEL_JOB); let other_references = files .into_par_iter() .with_min_len(minimum_job_len) .map_with_db(db, |db, other_file| { let source = ruff_db::source::source_text(db, other_file); - if !contains_identifier(&source, &target_text) { + if !contains_identifier(&source, &search.target_text) { return Vec::new(); } let other_file = ProgramFile::new(db, other_file, program); - - if is_externally_visible_symbol { - references_for_file(db, other_file, &target_definitions, &target_text, mode) + if has_fixture_target || is_externally_visible_symbol { + references_for_file(db, other_file, search, mode) } else { - references_for_keyword_arguments_in_file( - db, - other_file, - &target_definitions, - &target_text, - mode, - ) + // Parameters are local by scope, but they can have cross-file references via keyword + // argument labels (e.g. `f(param=...)`). Handle this case with a narrow scan that only + // considers keyword arguments. + references_for_keyword_arguments_in_file(db, other_file, search, mode) } }) .flat_map_iter(|references| references) .collect::>(); - references.extend(other_references); } + if matches!(mode, ReferencesMode::References) + && let Some(fixture_resolution) = &search.fixture_resolution + { + let declarations: FxHashSet<_> = fixture_resolution + .roots + .iter() + .filter_map(|root| match root { + FixtureNameSource::Binding(_) => None, + FixtureNameSource::Explicit { declaration, .. } => *declaration, + }) + .collect(); + + references.extend(declarations.into_iter().map(|declaration| { + ReferenceTarget::new( + declaration.file(), + declaration.range(), + ReferenceKind::Other, + ) + })); + } + if references.is_empty() { None } else { @@ -161,8 +227,7 @@ pub(crate) fn references( fn references_for_keyword_arguments_in_file( db: &dyn Db, file: ProgramFile<'_>, - target_definitions: &Definitions<'_>, - target_text: &str, + search: &LocalReferenceSearch<'_>, mode: ReferencesMode, ) -> Vec { // This path is used for cross-file parameter keyword-label references. @@ -180,10 +245,10 @@ fn references_for_keyword_arguments_in_file( let mut finder = KeywordArgumentReferencesFinder(LocalReferencesFinder { model: &model, tokens: module.tokens(), - target_definitions, + search, references: &mut references, mode, - target_text, + fixture_match_cache: FxHashMap::default(), ancestors: Vec::new(), }); @@ -222,8 +287,7 @@ fn is_slots_assignment(node: AnyNodeRef<'_>, value: AnyNodeRef<'_>) -> bool { fn references_for_file( db: &dyn Db, file: ProgramFile<'_>, - target_definitions: &Definitions<'_>, - target_text: &str, + search: &LocalReferenceSearch<'_>, mode: ReferencesMode, ) -> Vec { let parsed = parsed_module(db, file.python_file(db)); @@ -233,11 +297,11 @@ fn references_for_file( let mut finder = LocalReferencesFinder { model: &model, - target_definitions, + search, references: &mut references, mode, tokens: module.tokens(), - target_text, + fixture_match_cache: FxHashMap::default(), ancestors: Vec::new(), }; @@ -368,14 +432,207 @@ impl From for OccurrenceKind { } } +/// A name used to request a pytest fixture, selected as the starting point for find-references. +pub(crate) struct FixtureReferenceTarget<'db> { + name: Name, + resolution: FixtureReferenceResolution<'db>, +} + +impl<'db> FixtureReferenceTarget<'db> { + /// Finds references starting from an explicit fixture-name declaration. + pub(crate) fn references( + self, + db: &dyn Db, + file: ProgramFile<'_>, + mode: ReferencesMode, + ) -> Option> { + let search = LocalReferenceSearch { + target_text: self.name.to_string(), + target_definitions: Definitions::new(Vec::new()), + import_alias_resolution: ImportAliasResolution::PreserveAliases, + fixture_resolution: Some(self.resolution), + }; + references_for_search(db, file, &search, mode, false, false) + } + + fn from_goto_target( + model: &SemanticModel<'db>, + goto_target: &GotoTarget<'_>, + name: &str, + ) -> Option { + let definitions = goto_target.definitions(model, ImportAliasResolution::PreserveAliases)?; + let mut resolution = FixtureReferenceResolution::default(); + + for resolved in &definitions { + let Some(definition) = resolved.definition() else { + continue; + }; + resolution.extend(fixture_reference_resolution_for_definition( + model.db(), + definition, + name, + )); + } + + (!resolution.roots.is_empty()).then(|| Self { + name: Name::new(name), + resolution, + }) + } + + /// Creates a reference target from a fixture exposure. + pub(crate) fn from_exposure(db: &'db dyn SemanticDb, exposure: &FixtureExposure<'db>) -> Self { + let mut resolution = FixtureReferenceResolution::default(); + collect_fixture_reference_roots(db, exposure, &mut FxHashSet::default(), &mut resolution); + + Self { + name: exposure.name().clone(), + resolution, + } + } +} + +struct LocalReferenceSearch<'db> { + target_text: String, + target_definitions: Definitions<'db>, + import_alias_resolution: ImportAliasResolution, + fixture_resolution: Option>, +} + +/// The name sources used to match fixture references and the files visited to find them. +#[derive(Default)] +struct FixtureReferenceResolution<'db> { + /// Name sources at the roots of import chains that preserve the fixture and its exposed name. + roots: FxHashSet>, + /// Includes intermediate imports, which may be outside the project's files. + files: FxHashSet, +} + +impl FixtureReferenceResolution<'_> { + fn extend(&mut self, other: Self) { + self.roots.extend(other.roots); + self.files.extend(other.files); + } +} + +fn fixture_reference_resolution_for_definition<'db>( + db: &'db dyn SemanticDb, + definition: Definition<'db>, + name: &str, +) -> FixtureReferenceResolution<'db> { + let mut resolution = FixtureReferenceResolution::default(); + + let mut collect = |exposure: &FixtureExposure<'db>| { + collect_fixture_reference_roots(db, exposure, &mut FxHashSet::default(), &mut resolution); + }; + + match definition.kind(db) { + DefinitionKind::Parameter(_) => { + // Fixture requests can use either a binding name or an explicit decorator name. + for binding in fixture_bindings_for_parameter(db, definition) { + binding + .exposures() + .iter() + .filter(|exposure| exposure.name() == name) + .for_each(&mut collect); + } + } + // Avoid fixture lookup for unrelated source bindings, but allow stubs to describe fixtures + // as variables rather than functions. + kind if matches!( + kind, + DefinitionKind::Function(_) + | DefinitionKind::ImportFrom(_) + | DefinitionKind::StarImport(_) + ) || (matches!( + kind, + DefinitionKind::Assignment(_) | DefinitionKind::AnnotatedAssignment(_) + ) && definition.file(db).is_stub(db)) => + { + // These definitions refer to Python bindings. Only `Binding` name sources use + // that Python name as the fixture name; `Explicit` sources instead use the decorator: + // + // @pytest.fixture(name="public_name") + // def implementation(): ... + // copy = implementation # Python function reference + // def test_use(public_name): # Fixture request + // ... + // + // Omit the explicit fixture-name group for these Python bindings. + fixture_exposures_for_definition(db, definition) + .iter() + .filter(|exposure| { + exposure.name() == name + && matches!(exposure.name_source(db), FixtureNameSource::Binding(_)) + }) + .for_each(collect); + } + _ => {} + } + + resolution +} + +/// Follows imports that preserve the fixture name, collecting the name sources at their roots. +/// Returns whether this branch supplied a root; a cycle back to the current path returns false. +fn collect_fixture_reference_roots<'db>( + db: &'db dyn SemanticDb, + exposure: &FixtureExposure<'db>, + path: &mut FxHashSet>, + resolution: &mut FixtureReferenceResolution<'db>, +) -> bool { + // Stop cycles in the current import path. + if !path.insert(exposure.clone()) { + return false; + } + // Intermediate imports must be searched too, including files outside the project. + resolution.files.insert(exposure.local_binding().file(db)); + + // Re-exports share a root only while both the fixture and its exposed name stay the same. + // Renaming a default-named fixture (`from fixtures import resource as local`) starts a new group. + let mut found_source_root = false; + if let Some(source) = exposure.source_binding() { + for source_exposure in + fixture_exposures_for_definition(db, source) + .iter() + .filter(|source_exposure| { + source_exposure.fixture() == exposure.fixture() + && source_exposure.name() == exposure.name() + }) + { + found_source_root |= + collect_fixture_reference_roots(db, source_exposure, path, resolution); + } + } + + // With no root from a same-name source, this exposure starts the reference group. This + // includes direct declarations and renamed imports, as well as chains stopped at a stub + // or cycle. + if !found_source_root { + // Keep references resolved through a stub separate from the explicit-name declaration in + // its runtime implementation, matching reference behavior for ordinary Python symbols. + let root = if exposure.local_binding().file(db).is_stub(db) { + FixtureNameSource::Binding(exposure.local_binding()) + } else { + exposure.name_source(db) + }; + resolution.roots.insert(root); + } + + // Allow other import branches to resolve through this exposure. + path.remove(exposure); + + true +} + /// AST visitor to find all references to a specific symbol by comparing semantic definitions struct LocalReferencesFinder<'a> { model: &'a SemanticModel<'a>, tokens: &'a Tokens, - target_definitions: &'a Definitions<'a>, + search: &'a LocalReferenceSearch<'a>, references: &'a mut Vec, mode: ReferencesMode, - target_text: &'a str, + fixture_match_cache: FxHashMap, bool>, ancestors: Vec>, } @@ -386,7 +643,7 @@ impl<'a> SourceOrderVisitor<'a> for LocalReferencesFinder<'a> { match node { AnyNodeRef::ExprName(name_expr) => { // If the name doesn't match our target text, this isn't a match - if name_expr.id.as_str() != self.target_text { + if name_expr.id.as_str() != self.search.target_text { return TraversalSignal::Traverse; } @@ -462,11 +719,11 @@ impl<'a> SourceOrderVisitor<'a> for LocalReferencesFinder<'a> { { let mut sub_finder = LocalReferencesFinder { model: &sub_model, - target_definitions: self.target_definitions, + search: self.search, references: self.references, mode: self.mode, tokens: sub_ast.tokens(), - target_text: self.target_text, + fixture_match_cache: FxHashMap::default(), ancestors: Vec::new(), }; sub_finder.visit_expr(sub_ast.expr()); @@ -479,7 +736,7 @@ impl<'a> SourceOrderVisitor<'a> for LocalReferencesFinder<'a> { } // Only check the original name if it matches our target text // This is for cases where we're renaming the imported symbol name itself - if alias.name.id == self.target_text { + if alias.name.id == self.search.target_text { self.check_declaration_identifier(&alias.name); } } @@ -535,7 +792,7 @@ impl<'a> LocalReferencesFinder<'a> { fn check_identifier(&mut self, identifier: &ast::Identifier, kind: OccurrenceKind) { // Quick text-based check first - if identifier.id != self.target_text { + if identifier.id != self.search.target_text { return; } @@ -545,37 +802,62 @@ impl<'a> LocalReferencesFinder<'a> { self.check_covering_node(&covering_node, kind); } - /// Returns the covering node's resolved definitions. - fn definitions_for_covering_node( + fn goto_target_for_covering_node<'node>( &self, - covering_node: &CoveringNode<'_>, - ) -> Option> { + covering_node: &CoveringNode<'node>, + ) -> Option> { // Use the start of the covering node as the offset. Any offset within // the node is fine here. Offsets matter only for import statements // where the identifier might be a multi-part module name. let offset = covering_node.node().start(); - let goto_target = - GotoTarget::from_covering_node(self.model, covering_node, offset, self.tokens)?; - - let definitions = goto_target - .definitions(self.model, self.mode.to_import_alias_resolution())? - .goto_declaration(self.model, &goto_target)?; - - Some(definitions) + GotoTarget::from_covering_node(self.model, covering_node, offset, self.tokens) } fn check_covering_node(&mut self, covering_node: &CoveringNode<'_>, kind: OccurrenceKind) { - let Some(current_definitions) = self.definitions_for_covering_node(covering_node) else { + let Some(goto_target) = self.goto_target_for_covering_node(covering_node) else { return; }; - // Check if any of the current definitions match our target definitions - if !self.target_definitions.intersects(¤t_definitions) { + // Fixture references match by exposure roots rather than Python definitions. + let mut fixture_match = false; + let mut fixture_request_match = false; + if let Some(fixture_resolution) = &self.search.fixture_resolution + && let Some(definitions) = + // Preserve import aliases so an imported fixture requested under a new name keeps its own root. + goto_target.definitions(self.model, ImportAliasResolution::PreserveAliases) + { + for definition in definitions + .iter() + .filter_map(ResolvedDefinition::definition) + { + if self.definition_matches_fixture_target(definition, fixture_resolution) { + fixture_match = true; + fixture_request_match |= matches!( + definition.kind(self.model.db()), + DefinitionKind::Parameter(_) + ); + } + } + } + + // Fall back to Python definitions if fixture matching did not identify this occurrence. + // A search starting from an explicit fixture-name literal has no Python definitions. + let ordinary_match = !fixture_match + && self.search.target_definitions.iter().next().is_some() + && goto_target + .definitions(self.model, self.search.import_alias_resolution) + .and_then(|definitions| definitions.goto_declaration(self.model, &goto_target)) + .is_some_and(|definitions| self.search.target_definitions.intersects(&definitions)); + + if !fixture_match && !ordinary_match { return; } if matches!(self.mode, ReferencesMode::ReferencesSkipDeclaration) { let is_declaration = match kind { + // A parameter declares a Python local but references the fixture it requests. + // Keep fixture requests even when the fixture declaration is excluded. + OccurrenceKind::Declaration if fixture_request_match => false, OccurrenceKind::Declaration => true, OccurrenceKind::Reference => false, OccurrenceKind::Binding => self.is_declaration(covering_node), @@ -594,6 +876,32 @@ impl<'a> LocalReferencesFinder<'a> { self.references.push(target); } + fn definition_matches_fixture_target( + &mut self, + definition: Definition<'a>, + fixture_resolution: &FixtureReferenceResolution<'a>, + ) -> bool { + // A parameter and its uses share one definition: + // + // def test_use(resource): + // print(resource) + // print(resource) + // + // The search target is fixed for this visitor, so cache the match result instead of + // resolving and comparing fixture roots for each occurrence. + *self + .fixture_match_cache + .entry(definition) + .or_insert_with(|| { + let resolution = fixture_reference_resolution_for_definition( + self.model.db(), + definition, + &self.search.target_text, + ); + !resolution.roots.is_disjoint(&fixture_resolution.roots) + }) + } + /// Checks a string literal that may be an entry in a class's `__slots__`. /// /// `__slots__` entries are plain strings, but they name instance @@ -612,7 +920,7 @@ impl<'a> LocalReferencesFinder<'a> { let [part] = string_expr.value.as_slice() else { return; }; - if part.value.as_ref() != self.target_text { + if part.value.as_ref() != self.search.target_text { return; } @@ -716,7 +1024,7 @@ impl<'a> LocalReferencesFinder<'a> { scope = node.parent()?; }; - self.target_definitions.iter().any(|resolved| { + self.search.target_definitions.iter().any(|resolved| { let Some(definition) = resolved.definition() else { return false; }; diff --git a/crates/ty_python_semantic/src/lib.rs b/crates/ty_python_semantic/src/lib.rs index 93758d8c6e4e6..80f6ca2842c23 100644 --- a/crates/ty_python_semantic/src/lib.rs +++ b/crates/ty_python_semantic/src/lib.rs @@ -49,8 +49,9 @@ pub use types::ide_support::{ type_hierarchy_supertypes, }; pub use types::{ - DisplaySettings, FixtureBinding, ProgramEnvironment, TypeQualifiers, - fixture_bindings_for_parameter, + DisplaySettings, FixtureBinding, FixtureExposure, FixtureNameSource, ProgramEnvironment, + TypeQualifiers, fixture_bindings_for_parameter, fixture_exposures_for_definition, + pytest_global_plugin_files, }; mod db; diff --git a/crates/ty_python_semantic/src/types.rs b/crates/ty_python_semantic/src/types.rs index ca491c77fd88b..815ae660ea46c 100644 --- a/crates/ty_python_semantic/src/types.rs +++ b/crates/ty_python_semantic/src/types.rs @@ -29,7 +29,10 @@ use self::class::ClassInstanceFlags; use self::cyclic::ActiveRecursionDetector; pub use self::cyclic::CycleDetector; pub(crate) use self::cyclic::TypeTransformer; -pub use self::dedicated::pytest::{FixtureBinding, fixture_bindings_for_parameter}; +pub use self::dedicated::pytest::{ + FixtureBinding, FixtureExposure, FixtureNameSource, fixture_bindings_for_parameter, + fixture_exposures_for_definition, pytest_global_plugin_files, +}; pub(crate) use self::diagnostic::TypeCheckDiagnostics; pub(crate) use self::diagnostic::register_lints; pub use self::diagnostic::{UNDEFINED_REVEAL, UNRESOLVED_REFERENCE}; diff --git a/crates/ty_python_semantic/src/types/dedicated/pytest.rs b/crates/ty_python_semantic/src/types/dedicated/pytest.rs index 2b3009e4fc33c..a7f5f8c383a05 100644 --- a/crates/ty_python_semantic/src/types/dedicated/pytest.rs +++ b/crates/ty_python_semantic/src/types/dedicated/pytest.rs @@ -234,8 +234,7 @@ impl<'db> FixtureBinding<'db> { } /// Returns the equally viable exposures through which the request reaches the fixture. - #[cfg_attr(not(test), expect(dead_code))] - fn exposures(&self) -> &[FixtureExposure<'db>] { + pub fn exposures(&self) -> &[FixtureExposure<'db>] { &self.exposures } } @@ -286,8 +285,7 @@ impl<'db> FixtureBinding<'db> { /// source_binding: Some(Definition(fixtures.resource)), /// } /// ``` -#[cfg_attr(not(test), expect(dead_code))] -fn fixture_exposures_for_definition<'db>( +pub fn fixture_exposures_for_definition<'db>( db: &'db dyn Db, definition: Definition<'db>, ) -> Vec> { @@ -308,7 +306,7 @@ fn fixture_exposures_for_definition<'db>( } /// Returns the installed core pytest plugin files in registration order. -fn pytest_global_plugin_files<'db>( +pub fn pytest_global_plugin_files<'db>( db: &'db dyn Db, program: Program<'db>, ) -> &'db [ProgramFile<'db>] { @@ -727,7 +725,7 @@ struct FixtureDeclaration<'db> { /// The exposure contributed by `test_resource` points to `helper` as its immediate source and to /// `resource` as the canonical fixture declaration. #[derive(Debug, Clone, Eq, Hash, PartialEq, get_size2::GetSize, salsa::SalsaValue)] -struct FixtureExposure<'db> { +pub struct FixtureExposure<'db> { /// The name used to request this fixture (`"test_resource"` in the example above). name: Name, /// The local Python binding that exposes the fixture (`test_resource` in the example above). @@ -741,7 +739,6 @@ struct FixtureExposure<'db> { source_binding: Option>, } -#[cfg_attr(not(test), expect(dead_code))] impl<'db> FixtureExposure<'db> { /// Exposes a declaration under its explicit fixture name or local Python binding name. fn new( @@ -764,27 +761,27 @@ impl<'db> FixtureExposure<'db> { } /// Returns the public name that pytest uses to request this exposure. - fn name(&self) -> &Name { + pub fn name(&self) -> &Name { &self.name } /// Returns the local Python binding through which this fixture is exposed. - fn local_binding(&self) -> Definition<'db> { + pub fn local_binding(&self) -> Definition<'db> { self.local_binding } /// Returns the decorated function that declares the fixture. - fn fixture(&self) -> Definition<'db> { + pub fn fixture(&self) -> Definition<'db> { self.fixture } /// Returns the binding from which this exposure was imported, if any. - fn source_binding(&self) -> Option> { + pub fn source_binding(&self) -> Option> { self.source_binding } /// Returns the binding or decorator from which this exposure gets its public name. - fn name_source(&self, db: &'db dyn Db) -> FixtureNameSource<'db> { + pub fn name_source(&self, db: &'db dyn Db) -> FixtureNameSource<'db> { let Some(declaration) = fixture_declaration(db, self.fixture) else { return FixtureNameSource::Binding(self.local_binding); }; @@ -803,8 +800,7 @@ impl<'db> FixtureExposure<'db> { /// The source from which a fixture obtains its public name. #[derive(Debug, Clone, Copy, Eq, Hash, PartialEq)] -#[cfg_attr(not(test), expect(dead_code))] -enum FixtureNameSource<'db> { +pub enum FixtureNameSource<'db> { /// The Python binding that supplies the fixture name. Binding(Definition<'db>), /// An explicit fixture name supplied by the decorated function.